diff --git a/.coveragerc b/.coveragerc index 058a98eba4a..cad1fbd3a73 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,6 +10,7 @@ omit = */tests/* */setup.py */*/setup.py + Orange/classification/utils/fasterrisk/* [report] exclude_lines = diff --git a/.github/workflows/build-wheels.yaml b/.github/workflows/build-wheels.yaml new file mode 100644 index 00000000000..a611ace46f5 --- /dev/null +++ b/.github/workflows/build-wheels.yaml @@ -0,0 +1,138 @@ +name: Build + +on: + release: + types: [published] + # Enable manual run + workflow_dispatch: + +jobs: + generate-wheels-matrix: + # Create a matrix of all architectures & versions to build. + # This enables the next step to run cibuildwheel in parallel. + # From https://iscinumpy.dev/post/cibuildwheel-2-10-0/#only-210 + name: Generate wheels matrix + runs-on: ubuntu-latest + outputs: + include: ${{ steps.set-matrix.outputs.include }} + steps: + - uses: actions/checkout@v6 + - name: Install cibuildwheel + # Nb. keep cibuildwheel version pin consistent with job below + run: pipx install cibuildwheel==3.3.0 + - id: set-matrix + run: | + MATRIX=$( + { + cibuildwheel --print-build-identifiers --platform linux \ + | jq -nRc '{"only": inputs, "os": "ubuntu-latest"}' \ + && cibuildwheel --print-build-identifiers --platform macos \ + | jq -nRc '{"only": inputs, "os": "macos-latest"}' \ + && cibuildwheel --print-build-identifiers --platform windows \ + | jq -nRc '{"only": inputs, "os": "windows-latest"}' + } | jq -sc + ) + echo "include=$MATRIX" >> $GITHUB_OUTPUT + + build_wheels: + name: Build wheels on ${{ matrix.only }} + needs: generate-wheels-matrix + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.generate-wheels-matrix.outputs.include) }} + + steps: + - name: Check out the repo + uses: actions/checkout@v6 + with: + submodules: 'true' + + - name: Set up QEMU + if: runner.os == 'Linux' + uses: docker/setup-qemu-action@v4 + with: + platforms: all + + - name: Build wheels + uses: pypa/cibuildwheel@v3.3.0 + with: + only: ${{ matrix.only }} + + - uses: actions/upload-artifact@v7 + with: + name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} + path: ./wheelhouse/*.whl + + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + with: + submodules: 'true' + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + + - name: Build sdist (pep517) + run: | + python -m pip install build numpy cython + python -m build -s + + - name: Upload sdist + uses: actions/upload-artifact@v7 + with: + name: cibw-sdist + path: dist/*.tar.gz + + upload_release_assets: + name: Upload Release Assets + needs: [ build_wheels ] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags') + + steps: + - name: Download bdist files + id: download_artifact + uses: actions/download-artifact@v8 + with: + pattern: cibw-wheels-* + path: ~/downloads + merge-multiple: true + + - name: List downloaded artifacts + run: ls -la ~/downloads + + - name: Upload to release + uses: shogo82148/actions-upload-release-asset@v1.2.3 + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ${{ steps.download_artifact.outputs.download-path }}/*.whl + + pypi_publish: + name: PyPI Publish + needs: [ build_wheels, build_sdist ] + runs-on: ubuntu-latest + permissions: + id-token: write # this permission is mandatory for trusted publishing + if: startsWith(github.ref, 'refs/tags') + + steps: + - name: Download bdist files + uses: actions/download-artifact@v8 + with: + pattern: cibw-* + path: downloads/ + merge-multiple: true + + - name: Display structure of downloaded files + run: ls -R downloads/ + + - name: Publish packages to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: downloads/ diff --git a/.github/workflows/check_pylint_diff.sh b/.github/workflows/check_pylint_diff.sh index 0306be4e894..b53aae6eba0 100755 --- a/.github/workflows/check_pylint_diff.sh +++ b/.github/workflows/check_pylint_diff.sh @@ -15,7 +15,7 @@ UNCOMMITED_PATCH="$TMP_REPO/uncommited.patch" SCRIPT=$(basename "$0") PYLINT="$(command -v pylint 2>/dev/null || true)" RADON="$(command -v radon 2>/dev/null || true)" -PYLINT_ARGS="--output-format=parseable" +PYLINT_ARGS="--msg-template='{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}'" RADON_ARGS='cc --min C --no-assert --show-closures --show-complexity --average' trap "status=\$?; cd '$GIT_REPO'; rm -rf '$TMP_REPO'; exit \$status" EXIT @@ -127,7 +127,7 @@ Number_of_issues () cached="$1" { cat "$cached" 2>/dev/null || echo "$CHANGED_FILES" | - xargs "$PYLINT" $PYLINT_ARGS | + xargs "$PYLINT" "$PYLINT_ARGS" | tee "$cached" } | awk -F'[\\. ]' '/^Your code has been rated at /{ print $7 }' || true } diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index ac38174cddf..4cd5c593a18 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -14,13 +14,15 @@ jobs: strategy: fail-fast: False matrix: - python: [3.7] - os: [ubuntu-18.04] + python: ['3.11'] + os: [ubuntu-22.04] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + with: + submodules: 'true' - name: Setup Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} @@ -31,4 +33,6 @@ jobs: run: pip install tox - name: Build documentation - run: xvfb-run -a -s "-screen 0 1280x1024x24" tox -e build_doc + run: tox -e build_doc + env: + QT_QPA_PLATFORM: offscreen diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml deleted file mode 100644 index 44eb2e4cbfd..00000000000 --- a/.github/workflows/rebase.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Automatic Rebase -on: - issue_comment: - types: [created] -jobs: - rebase: - name: Rebase - if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase') - runs-on: ubuntu-latest - steps: - - name: Checkout the latest code - uses: actions/checkout@v2 - with: - token: ${{ secrets.BIOLAB_HELPER_PAT }} - fetch-depth: 0 # otherwise, you will fail to push refs to dest repo - - name: Automatic Rebase - uses: cirrus-actions/rebase@1.4 - env: - GITHUB_TOKEN: ${{ secrets.BIOLAB_HELPER_PAT }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8181681dc7a..30171343af0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,21 +10,22 @@ on: jobs: lint: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 with: + submodules: 'true' fetch-depth: '2' - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: - python-version: 3.8 + python-version: '3.11' - name: Install Tox run: | python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-pip-version + python -m pip install --upgrade tox - name: Run Pylint run: tox -e pylint-ci @@ -36,15 +37,27 @@ jobs: strategy: fail-fast: False matrix: - os: [ubuntu-18.04] - python-version: [3.7, 3.8, 3.9] + os: [ubuntu-latest] + python-version: ['3.11', '3.12', '3.13', '3.14'] tox_env: [orange-released] name: [Released] include: - - os: ubuntu-18.04 - python-version: 3.8 + - os: ubuntu-latest + python-version: '3.14' tox_env: orange-latest name: Latest + - os: ubuntu-22.04 + python-version: '3.11' + tox_env: orange-oldest + name: Oldest dependencies + - os: ubuntu-latest + python-version: '3.13' + tox_env: pyqt6 + name: PyQt6 + - os: ubuntu-latest + python-version: '3.14' + tox_env: beta + name: "Scientific Python nightly wheels" services: postgres: @@ -66,33 +79,40 @@ jobs: - 1433:1433 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install linux system dependencies - run: sudo apt-get install -y libxkbcommon-x11-0 + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libxcb-cursor0 glibc-tools libegl1 - name: Install Tox run: | python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-pip-version + python -m pip install --upgrade tox + + - name: Skip testing workflows at coverage + if: | + matrix.python-version == '3.12' && matrix.tox_env == 'orange-released' + run: | + echo 'SKIP_EXAMPLE_WORKFLOWS=1' >> $GITHUB_ENV - name: Run Tox - run: xvfb-run -a -s "-screen 0 1280x1024x24" tox -e ${{ matrix.tox_env }} + run: catchsegv xvfb-run -a -s "$XVFBARGS" tox -e ${{ matrix.tox_env }} env: - # QT_QPA_PLATFORM: offscreen + XVFBARGS: "-screen 0 1280x1024x24" ORANGE_TEST_DB_URI: postgres://postgres_user:postgres_password@localhost:5432/postgres_db|mssql://SA:sqlServerPassw0rd@localhost:1433 - name: Upload code coverage - if: | - matrix.python-version == '3.8' && - matrix.tox_env == 'orange-released' - run: | - pip install codecov - codecov + if: matrix.python-version == '3.12' && matrix.tox_env == 'orange-released' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} test_on_macos_and_windows: runs-on: ${{ matrix.os }} @@ -101,35 +121,39 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-10.15, windows-2016] - python-version: [3.7, 3.8, 3.9] + os: [macos-latest, windows-latest] + python-version: ['3.11', '3.12', '3.13', '3.14'] tox_env: [orange-released] name: [Released] include: - - os: windows-2016 - python-version: 3.8 + - os: windows-latest + python-version: '3.14' tox_env: orange-latest name: Latest - - os: macos-10.15 - python-version: 3.8 + - os: macos-latest + python-version: '3.14' tox_env: orange-latest name: Latest + - os: windows-latest + python-version: '3.13' + tox_env: pyqt6 + name: PyQt6 + - os: macos-latest + python-version: '3.13' + tox_env: pyqt6 + name: PyQt6 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install system dependencies on MacOS - run: brew install libomp - if: matrix.os == 'macos-10.15' || matrix.os == 'macos-11.0' - - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-pip-version + python -m pip install --upgrade tox - name: Test with Tox run: | diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml new file mode 100644 index 00000000000..c61aa0018d3 --- /dev/null +++ b/.github/workflows/translations.yml @@ -0,0 +1,15 @@ +name: Check translations + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test-translations: + uses: biolab/orange-ci-cd/.github/workflows/test-translations.yml@master + with: + package-dir: Orange diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000000..3842eea91bc --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "doc/visual-programming"] + path = doc/visual-programming + url = https://github.com/biolab/orange3-doc-visual-programming.git diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000000..4a4eb73362f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + +submodules: + include: all + +sphinx: + # Path to the shared conf.py file. + configuration: doc/conf.py + +python: + install: + - requirements: requirements-readthedocs.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d42748bfef..076fba2254d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,393 @@ Change Log [next] - TBA ------------ + +[3.40.0] - 2025-12-20 +-------------------- +##### Enhancements +* SQL: Limit tables in dropdown ([#7214](../../pull/7214)) +* Neighbors: Add an option to retain reference example(s) ([#7199](../../pull/7199)) +* File: Allow selecting files with arbitrary extensions ([#6894](../../pull/6894)) +* SliderGraph: thicker threshold line ([#7091](../../pull/7091)) +* Tree Viewer: correctly show the first category (escape class values) ([#7124](../../pull/7124)) +* CN2 Rules: prefer equality (with optional restriction) for categorical variables ([#7128](../../pull/7128)) + +##### Bugfixes +* Manifold Learning: Fix reseting of selected number of components ([#7191](../../pull/7191)) +* HeaderView: Do nothing in initStyleOptionForIndex if model is None ([#7216](../../pull/7216)) +* Save dialogs: Avoid weird behavior with extensions on macOS ([#7195](../../pull/7195)) +* requirements-core.txt: Raise minimum pandas version to 2.0.* ([#7203](../../pull/7203)) +* CSV Import: Fix non-functioning encodings selection ([#7181](../../pull/7181)) +* CSV Import timezone aware ([#7140](../../pull/7140)) +* Projections: When the number of colors equals the limit, do not introduce 'other' ([#7130](../../pull/7130)) +* Predictions: allow loading of workflows saved in a localized version (target class setting fix) ([#7123](../../pull/7123)) +* OWDBSCAN: handle empty X ([#7107](../../pull/7107)) + + +[3.39.0] - 2025-06-13 +-------------------- +##### Enhancements +* Feature as Predictor ([#6852](../../pull/6852)) +* K-means: clusters can be inferred for new data ([#7010](../../pull/7010)) +* Parameter Fitter: more responsive when changing settings ([#7027](../../pull/7027)) +* Paint Data: Slight optimization ([#6990](../../pull/6990)) +* Numpy 2.0 compatibility ([#6850](../../pull/6850)) + +##### Bugfixes +* Order available PostgreSQL tables ([#7099](../../pull/7099)) +* tableview: Avoid unnecessary calls to setSortingEnabled ([#7109](../../pull/7109)) +* Update check and notifications: avoid compression ([#7097](../../pull/7097)) +* tSNE: Add compute_value to tSNE variables ([#7066](../../pull/7066)) +* ScoringSheetViewer computing target class values from base values ([#7025](../../pull/7025)) +* ScoringSheetViewer slider style and tooltip ([#7024](../../pull/7024)) +* Disable dynamic signals where applicable ([#7045](../../pull/7045)) +* Make ReliefF and RReliefF results replicable ([#7026](../../pull/7026)) +* ScoringSheet deterministic results ([#7023](../../pull/7023)) +* io_util: Detect utf-8-sig when using `file` utility ([#7006](../../pull/7006)) +* Fix error bars for threshold averaged ROC curves ([#7014](../../pull/7014)) +* Save Data: fix when saving to different drives on Windows ([#7019](../../pull/7019)) +* owhierarchicalclustering: Fix IndexError ([#6989](../../pull/6989)) + + +[3.38.1] - 2024-12-23 +-------------------- +##### Enhancements +* Scatter Plot: Error Bars ([#6934](../../pull/6934)) + +##### Bugfixes +* 1 line headers: only allow single-letter types and flags ([#6959](../../pull/6959)) +* Group By: fix categorical aggregations ([#6958](../../pull/6958)) +* Orange can be imported without Qt (fixed importing localization) ([#6955](../../pull/6955)) +* Fix documentation in packages: fix sphinx build command ([#6950](../../pull/6950)) +* httpx 0.28 support: use mounts instead of proxies ([#6943](../../pull/6943)) +* owpredicttions: Remove special magic value 2 ([#6933](../../pull/6933)) + + +[3.38.0] - 2024-11-15 +-------------------- +##### Enhancements +* Parameter Fitter: Basic implementation ([#6921](../../pull/6921)) +* Datasets: Add domain field; respect "Unlisted" ([#6920](../../pull/6920)) +* Datasets: Let the filter override domain and language ([#6930](../../pull/6930)) +* ScoringSheet and ScoringSheetViewer widgets ([#6817](../../pull/6817)) +* Multilingual package and installation ([#6828](../../pull/6828)) + +##### Bugfixes +* Calibrated Learner: Prevent in place modification of base learner ([#6917](../../pull/6917)) +* ListView: Fix selection ([#6823](../../pull/6823)) +* plotutils: Fix scene layout tracking in AnchorItem ([#6859](../../pull/6859)) +* oweditdomain: Hide "categories mapping" warning on change ([#6865](../../pull/6865)) +* Logistic regression: fix penalty argument for no regularization ([#6886](../../pull/6886)) +* Avoid slowdowns by caching Domain.__eq__ ([#6764](../../pull/6764)) +* SVM: degree has to be an integer ([#6866](../../pull/6866)) +* Only deepcopy the .attributes for the outermost Table transformation ([#6849](../../pull/6849)) +* Line plot: compatibility with scipy 1.14 ([#6845](../../pull/6845)) +* PCA: ensure tests pass on sklearn 1.4 and 1.5, which can return different results ([#6821](../../pull/6821)) +* Remove Orange implementation of randomized PCA for sparse data ([#6815](../../pull/6815)) + + +[3.37.0] - 2024-05-27 +-------------------- +##### Enhancements +* Permutation Plot: Add widget ([#6762](../../pull/6762)) +* PLS: Move from Orange-spectroscopy ([#6734](../../pull/6734)) +* Open draged files on CSV Import, Load Model and Distance File widgets ([#6747](../../pull/6747)) +* Predictions: Output annotated table +* Update t-SNE widget ([#6345](../../pull/6345)) +* CSV Import: Skip multi rows edit ([#6691](../../pull/6691)) +* Remove use of pkg_resources ([#6655](../../pull/6655)) +* Data Info: Show statistics about missing values ([#6623](../../pull/6623)) + +##### Bugfixes +* Edit Domain: Partial restore state on categories mismatch ([#6776](../../pull/6776)) +* Distances: Spearman actually computes Spearman instead of Pearson ([#6804](../../pull/6804)) +* tableview: Remove toggle selection on corner widget click ([#6802](../../pull/6802)) +* _simple_tree.c: Fix compilation error with gcc 14 ([#6800](../../pull/6800)) +* OWSom: Fix crash for data without class variable ([#6763](../../pull/6763)) +* io_util: Fix memory usage when parsing columns ([#6757](../../pull/6757)) +* pythoneditor/editor: Change order in `terminate` ([#6756](../../pull/6756)) +* Minor fixes to reenable falling CI ([#6751](../../pull/6751)) +* Fix failing tests on Python 3.11.8 on Windows ([#6737](../../pull/6737)) +* Table.add_column - Do not try to insert data in locked empty array ([#6743](../../pull/6743)) +* test_owcsvimport: Workaround for segfault in tests on macos ([#6735](../../pull/6735)) +* Domain Editor - Adapt to dark mode ([#6727](../../pull/6727)) +* Predictions: Output annotated table +* CSV Import: Add explicit datetime conversion ([#6696](../../pull/6696)) +* setup.py: Replace use of imp module ([#6680](../../pull/6680)) +* Writers: Store nans in StringVariable as empty strings instead of 'nan' ([#6670](../../pull/6670)) +* Fix impute.Model for derived domains ([#6668](../../pull/6668)) +* CN2 Rule Induction: Fix model's effective name ([#6652](../../pull/6652)) + + +[3.36.2] - 2023-10-31 +-------------------- +##### Enhancements +* oweditdomain: Add variable filter ([#6603](../../pull/6603)) +* IO - Change origin attribute when not find on system ([#6555](../../pull/6555)) +* Predictions: Output errors ([#6577](../../pull/6577)) + +##### Bugfixes +* EmbedderCache - Handle cache persisting when no permissions ([#6611](../../pull/6611)) + + +[3.36.1] - 2023-09-22 +-------------------- +##### Bugfixes +* Distributions: Fix selection output ([#6578](../../pull/6578)) +* Datasets: save selected dataset platform independently ([#6575](../../pull/6575)) + + +[3.36.0] - 2023-09-08 +-------------------- +##### Enhancements +* Update widget.json +* feature-constructor2-stamped.png - resize and index +* Formula documentation: Minor changes +* index.rst - fix reference to formula +* move accidentally displaced formula.md +* SOM: output columns with coordinates and errors ([#6542](../../pull/6542)) +* Lazy signals for Hierarchical Clustering ([#6348](../../pull/6348)) +* PCA widget runs a separate thread ([#6528](../../pull/6528)) +* Edit Domain: Change types of multiple variables ([#6426](../../pull/6426)) +* owsavebase: Display a warning when auto save is disabled ([#6454](../../pull/6454)) +* Select Columns: Accept partially correct drops ([#6390](../../pull/6390)) + +##### Bugfixes +* Update widget.json +* feature-constructor2-stamped.png - resize and index +* Formula documentation: Minor changes +* index.rst - fix reference to formula +* move accidentally displaced formula.md +* Predictions: Fix column size hinting ([#6563](../../pull/6563)) +* Naive Bayes: fix predictions with unknown values ([#6564](../../pull/6564)) +* Scatter Plot - Handle input features that are hidden in data ([#6531](../../pull/6531)) +* Adapt tests to pandas 2.1 and fix deprecations ([#6560](../../pull/6560)) +* Adapt to newly released pandas 2.1 ([#6559](../../pull/6559)) +* CSV Import - Change datetime format parsing ([#6539](../../pull/6539)) +* owdiscretize: Fix formatting display string when user role is undefined ([#6498](../../pull/6498)) +* Neural Network: Default learner name propagation ([#6526](../../pull/6526)) +* PCA - Output instance of table subclass when instance of table subclass on input ([#6536](../../pull/6536)) +* owpredictions: Fix a type error in report when using NoopDelegate ([#6537](../../pull/6537)) +* Scatter Plot - Fix Vizrank for hidden attributes ([#6530](../../pull/6530)) +* config: Replace and fix use of pkg_resources.parse_version ([#6523](../../pull/6523)) +* Fix classification trees for data with repeated feature values ([#6488](../../pull/6488)) +* Group By - Fix error with hidden attributes ([#6473](../../pull/6473)) +* Sql - Fix comparison with values trailing spaces ([#6470](../../pull/6470)) +* ListView - empty variable when all values unselected ([#6441](../../pull/6441)) + + +[3.35.0] - 2023-05-05 +--------------------- +##### Enhancements +* Neighbours - Sort resulting instances in order according to distance ([#6425](../../pull/6425)) +* SOM - Warn user to restart optimization after parameter change ([#6438](../../pull/6438)) +* Update requirements-core.txt +* TimeVariable - add formats with UTC offset +* Skip pandas==2.0.0 +* Data Table: Subset input ([#6405](../../pull/6405)) +* Data Table: Restore applied sorting ([#6370](../../pull/6370)) +* widgets.data.utils.tableview: Disconnect specific slot +* Select Rows: Better label for purging unused values/features ([#6383](../../pull/6383)) +* Single input data table widget ([#6346](../../pull/6346)) +* Continuize: Specific options for variables ([#6181](../../pull/6181)) +* Datasets: Add language selection ([#6358](../../pull/6358)) +* MDS: Show Kruskal stress ([#6309](../../pull/6309)) +* Group By - add quantile aggregations ([#6304](../../pull/6304)) +* MCC: Add Matthews correlation coefficient score ([#6264](../../pull/6264)) +* Improve scorer selection in Test and Score and predictions ([#6282](../../pull/6282)) +* Feature Constructor: Enable comprehensions and lambdas in expressions ([#6272](../../pull/6272)) + +##### Bugfixes +* Edit Domain no longer forgets its settings ([#6415](../../pull/6415)) +* SOM: Store selection Settings in list instead of np.ndarray ([#6423](../../pull/6423)) +* pca: n_features_ attribute of decomposition.PCA is deprecated in favor of n_features_in_ ([#6249](../../pull/6249)) +* Update requirements-core.txt +* TimeVariable - add formats with UTC offset +* Skip pandas==2.0.0 +* tableview: Fix errors with sparse basket columns ([#6409](../../pull/6409)) +* owtable: Remove multiple connections to 'selectionFinished' ([#6404](../../pull/6404)) +* widgets.data.utils.tableview: Disconnect specific slot +* Drop table after testing ([#6351](../../pull/6351)) +* MDS: Remove repeated updates at the end of optimization ([#6337](../../pull/6337)) +* Select Rows - Fix checked groups not considered in PyQt6 ([#6336](../../pull/6336)) +* Edit Domain - set have_time and have_date to time variables ([#6324](../../pull/6324)) +* Rank - make sorting setting PyQt6 compatible ([#6301](../../pull/6301)) +* Concatenate - preserve table names when compute value ignored ([#6331](../../pull/6331)) +* Fix unpickling domains: do not pickle indices (which can cause problems) ([#6317](../../pull/6317)) +* stats.utils: Don't count zeros as nans ([#6314](../../pull/6314)) +* File Widget: Fix recent urls save/restore ([#6259](../../pull/6259)) +* Fix Color for python 3.10 ([#6293](../../pull/6293)) +* listfilter: Fix dragDropActionDidComplete signal type ([#6292](../../pull/6292)) +* Table.from_table works correctly with boolean indices ([#6278](../../pull/6278)) +* Pythagorean Forest: Fix report ([#6276](../../pull/6276)) +* Implement predict() in simple tree and simple RF models ([#6258](../../pull/6258)) +* Bar Plot: Fix annotation by enumeration ([#6270](../../pull/6270)) +* Color: fix coloring of computed (processed) variables ([#6261](../../pull/6261)) + + +[3.34.1] - 2022-12-13 +-------------------- +#### Bugfixes +* Pin scikit-learn requirement to <1.2.0 due to changes in PCA + + +[3.34.0] - 2022-12-05 +-------------------- +##### Enhancements +* Faster normalization ([#6202](../../pull/6202)) +* io.UrlReader: Add support for google drive share urls ([#6201](../../pull/6201)) +* Table: Add methods get_column and set_column ([#6058](../../pull/6058)) +* owfeatureconstructor: raise settings version +* owfeaturecontructor: move meta to the end of namedtuple +* Aggregate Columns: Add additional options for selection ([#6056](../../pull/6056)) + +##### Bugfixes +* conda-recipe: Remove explicit host numpy pinning ([#6235](../../pull/6235)) +* Data Sampler: Fix crash when requesting an empty sample ([#6208](../../pull/6208)) +* Remove pyqt5 install magic ([#6153](../../pull/6153)) +* stats: Handle empty array ([#6221](../../pull/6221)) +* Preprocess: Fix reporting for remove sparse and impute ([#6212](../../pull/6212)) +* Weighted mean computation in Orange.statistics.util.stats ([#6204](../../pull/6204)) +* owfeatureconstructor: raise settings version +* owfeaturecontructor: move meta to the end of namedtuple + + +[3.33.0] - 2022-09-30 +-------------------- +##### Enhancements +* drophandlers: Preserve defaults history ([#6069](../../pull/6069)) +* owlogisticregression: Add option for no regularization ([#6093](../../pull/6093)) +* Predictions: Show errors ([#6012](../../pull/6012)) +* Add support for PyQt6 ([#5884](../../pull/5884)) +* Feature Constructor: Make concurrent ([#5992](../../pull/5992)) +* Feature constructor optimization ([#5975](../../pull/5975)) +* Discretize: Simplify interface, add nicer binning ([#5919](../../pull/5919)) + +##### Bugfixes +* owboxplot: Fix an error in widget cleanup during tests ([#6136](../../pull/6136)) +* Group By - fix std and sum for TimeVariable ([#6133](../../pull/6133)) +* Handle timezones in Edit Domain ([#6123](../../pull/6123)) +* owfeatureconstructor: Cast FeatureFunc result to array ([#6115](../../pull/6115)) +* Server embedder - send bytes as content ([#6051](../../pull/6051)) +* plotutils - replace MidButton with MiddleButton ([#6124](../../pull/6124)) +* Feature Constructor cast function is not picklable ([#6002](../../pull/6002)) +* test_owfile: Fix unreliable `test_warning_from_another_thread` ([#6085](../../pull/6085)) +* Embedders - fix proxies, default on http, tests ([#6028](../../pull/6028)) +* TableModel: Define foreground color when providing background ([#6011](../../pull/6011)) +* owdistancemap: Fix an implicit float->int conversion error ([#5998](../../pull/5998)) +* Neighbours - support Table derived output types ([#5986](../../pull/5986)) +* Predictions: allow predicting probabilities for classless data ([#5972](../../pull/5972)) +* owcsvimport: Reduce sample size ([#5960](../../pull/5960)) +* make usable_scorers backwards compatible ([#5943](../../pull/5943)) +* owpythonscript: Fix implicit float to int cast error ([#5944](../../pull/5944)) + + +[3.32.0] - 2022-04-01 +-------------------- +##### Enhancements +* Enable multitarget problem types for OWTestAndScore and OWPredictions ([#5848](../../pull/5848)) +* Datetime format selection ([#5819](../../pull/5819)) +* Predictions: Allow choosing a target ([#5790](../../pull/5790)) +* Server embedder: use queue, handle unsuccessful requests at the end ([#5835](../../pull/5835)) +* orange-canvas: Add cmd parameter to clear all settings/caches ([#5844](../../pull/5844)) +* New icons and splash screen ([#5814](../../pull/5814)) +* Use palette colors in more places ([#5680](../../pull/5680)) +* File: explicit file format choice ([#5736](../../pull/5736)) +* Learner widgets: Inform about potential problems when overriding preprocessors ([#5710](../../pull/5710)) + +##### Bugfixes +* Orange.data.Table: deprecated is_view and is_copy ([#5913](../../pull/5913)) +* owrocanalysis: Fix an index error in custom tick calculation ([#5904](../../pull/5904)) +* SOM: Fix crash when color is constant ([#5860](../../pull/5860)) +* plotutils: Fix implicit conversion to int warning error ([#5874](../../pull/5874)) +* Calibration: Fix crash on empty folds ([#5859](../../pull/5859)) +* Scatter Plot: Replot when input features change ([#5837](../../pull/5837)) +* Nomogram: Purge class_var values ([#5847](../../pull/5847)) +* Scatter plot: Fix rotation of regression line label ([#5840](../../pull/5840)) +* widgets.evaluate.utils: call resizeColumnsToContents before setting hidden header sections. ([#5851](../../pull/5851)) +* tSNE, Freeviz: Set effective_data ([#5839](../../pull/5839)) +* Linear Projection: Fix LDA ([#5828](../../pull/5828)) +* Decorate overridden input data handler ([#5836](../../pull/5836)) +* Table: from_file/from_url remove type conversion ([#5812](../../pull/5812)) +* Group by: Restore aggregations if removed due to open context ([#5823](../../pull/5823)) +* Ensure unlockable sparse arrays after Table.copy ([#5807](../../pull/5807)) +* ExcelReader: Write roles ([#5810](../../pull/5810)) +* owfeatureconstructor: Always update models on data change ([#5804](../../pull/5804)) +* File: remove some resizing limits ([#5815](../../pull/5815)) +* owfeaturestatistics: Fix implicit int conversion error on resize ([#5799](../../pull/5799)) +* table_from_frame: replace nan with String.Unknown for string variable ([#5795](../../pull/5795)) +* Update (and test) minimum dependencies ([#5781](../../pull/5781)) +* test_owdistancematrix: Do not download data for testing ([#5773](../../pull/5773)) +* Silhouette plot text width ([#5756](../../pull/5756)) + + +[3.31.1] - 2022-01-07 +-------------------- +##### Bugfixes +* Group by: compute mode when all values in group nan ([#5763](../../pull/5763)) +* Support numpy 1.22 ([#5760](../../pull/5760)) +* Unpickling pre-3.28.0 Transformation ([#5759](../../pull/5759)) + + +[3.31.0] - 2021-12-17 +-------------------- +##### Enhancements +* oweditdomain: Indicate variables in error state ([#5732](../../pull/5732)) +* Scatterplot: Use opacity for contrast ([#5684](../../pull/5684)) +* Feature Constructor: Evaluate categorical variables to strings ([#5637](../../pull/5637)) +* New widget: Group By ([#5541](../../pull/5541)) +* Table lock: tests run with tables that are read-only by default ([#5381](../../pull/5381)) +* config: sort example workflows ([#5600](../../pull/5600)) + +##### Bugfixes +* Paint Data: Fix ClearTool's issued commands ([#5718](../../pull/5718)) +* pandas_compat: fix table_from_frames for "normal" dataframe ([#5652](../../pull/5652)) +* pandas_compat: do not parse column of numbers (object dtype) to datetime ([#5681](../../pull/5681)) +* HeatMap: Color gradient center value edit ([#5647](../../pull/5647)) +* Distance Matrix: Fix crash on numeric meta vars as labels ([#5664](../../pull/5664)) +* Fix running OWS with widgets with WebView in Orange.canvas.run ([#5657](../../pull/5657)) +* main: Fix `--clear-widget-settings` parameter declaration ([#5619](../../pull/5619)) + + +[3.30.2] - 2021-10-27 +-------------------- +##### Bugfixes +* Projections: Fix color density for continuous color palettes ([#5665](../../pull/5665)) +* Fixes for scikit-learn 1.0 ([#5608](../../pull/5608)) +* table_from_grames: fix indices parsing ([#5620](../../pull/5620)) +* Fix overflow in bin calculations for time variables. ([#5667](../../pull/5667)) +* Variable: fix timezone when parsing time variable ([#5617](../../pull/5617)) +* Require widget-base 4.15.1 and canvas-core 0.1.23 to fix some bugs/crashes + + +[3.30.1] - 2021-09-24 +-------------------- +##### Bugfixes +* OWTable: fix select whole rows regression ([#5605](../../pull/5605)) + + +[3.30.0] - 2021-09-22 +-------------------- +##### Enhancements +* OWPythonScript: Better text editor ([#5208](../../pull/5208)) +* PCA: Output variance of components ([#5513](../../pull/5513)) +* Curve Fit: New widget ([#5481](../../pull/5481)) +* Hierarchical Clustering: Annotate variables with clusters ([#5514](../../pull/5514)) +* Create nodes on canvas drag/drop ([#5031](../../pull/5031)) + +##### Bugfixes +* setup.py: do not overwrite conda's PyQt5 ([#5593](../../pull/5593)) +* Use explicit ordered multiple inputs ([#4860](../../pull/4860)) +* Prevent crash when saving in unsupported format ([#5560](../../pull/5560)) +* owrocanalysis: Fix test for non empty points array ([#5571](../../pull/5571)) +* pandas_compat: fix conversion of datetime series ([#5547](../../pull/5547)) +* Fix deepcopy and pickle for classes derived from `np.ndarray` ([#5536](../../pull/5536)) +* owheatmap: Fix assertion error when restoring selection ([#5517](../../pull/5517)) +* Pivot: Handle empty data, metas only ([#5527](../../pull/5527)) +* table_to_frame - handle numeric columns with dtype=object ([#5474](../../pull/5474)) +* listfilter: Bypass QListView.dropEvent ([#5477](../../pull/5477)) + + [3.29.3] - 2021-06-09 -------------------- ##### Bugfixes @@ -17,9 +404,11 @@ Change Log * owpca: fix component selection when dragging selection line ([#5469](../../pull/5469)) * Save File when workflow basedir is an empty string ([#5459](../../pull/5459)) + [3.29.1] - 2021-05-31 -------------------- + [3.29.0] - 2021-05-28 -------------------- ##### Enhancements @@ -44,6 +433,7 @@ Change Log * Nomogram: Retain original compute_value ([#5382](../../pull/5382)) * Radviz VizRank: Implement on_selection_changed ([#5338](../../pull/5338)) + [3.28.0] - 2021-03-05 -------------------- ##### Enhancements @@ -56,6 +446,7 @@ Change Log * Distribution: Show equal bar widths on unique-valued bins ([#5139](../../pull/5139)) * Implement proper Lift curve; keep Cumulative gains as an option ([#5075](../../pull/5075)) * Create Instance: New widget ([#5033](../../pull/5033)) +* Add add_column and concatenate methods to Table ([#5251](../../pull/5251)) ##### Bugfixes * Calibration model: Work with numpy data ([#5159](../../pull/5159)) @@ -1567,7 +1958,25 @@ Change Log * Initial version based on Python 1.5.2 and Qt 2.3 -[next]: https://github.com/biolab/orange3/compare/3.29.3...HEAD +[next]: https://github.com/biolab/orange3/compare/3.40.0..HEAD +[3.40.0]: https://github.com/biolab/orange3/compare/3.39.0...3.40.0 +[3.39.0]: https://github.com/biolab/orange3/compare/3.38.1...3.39.0 +[3.38.1]: https://github.com/biolab/orange3/compare/3.38.0...3.38.1 +[3.38.0]: https://github.com/biolab/orange3/compare/3.37.0...3.38.0 +[3.37.0]: https://github.com/biolab/orange3/compare/3.36.2...3.37.0 +[3.36.2]: https://github.com/biolab/orange3/compare/3.36.1...3.36.2 +[3.36.1]: https://github.com/biolab/orange3/compare/3.36.0...3.36.1 +[3.36.0]: https://github.com/biolab/orange3/compare/3.35.0...3.36.0 +[3.35.0]: https://github.com/biolab/orange3/compare/3.34.1...3.35.0 +[3.34.1]: https://github.com/biolab/orange3/compare/3.34.0...3.34.1 +[3.34.0]: https://github.com/biolab/orange3/compare/3.33.0...3.34.0 +[3.33.0]: https://github.com/biolab/orange3/compare/3.32.0...3.33.0 +[3.32.0]: https://github.com/biolab/orange3/compare/3.31.1...3.32.0 +[3.31.1]: https://github.com/biolab/orange3/compare/3.31.0...3.31.1 +[3.31.0]: https://github.com/biolab/orange3/compare/3.30.2...3.31.0 +[3.30.2]: https://github.com/biolab/orange3/compare/3.30.1...3.30.2 +[3.30.1]: https://github.com/biolab/orange3/compare/3.30.0...3.30.1 +[3.30.0]: https://github.com/biolab/orange3/compare/3.29.3...3.30.0 [3.29.3]: https://github.com/biolab/orange3/compare/3.29.2...3.29.3 [3.29.2]: https://github.com/biolab/orange3/compare/3.29.1...3.29.2 [3.29.1]: https://github.com/biolab/orange3/compare/3.29.0...3.29.1 diff --git a/MANIFEST.in b/MANIFEST.in index a8c98a5a74a..85fa13cf793 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,13 +9,17 @@ recursive-include Orange/canvas/workflows *.ows recursive-include Orange/widgets *.png *.svg *.js *.css *.html recursive-include Orange/widgets/tests *.tab -recursive-include Orange/widgets/data/tests *.tab *.txt +recursive-include Orange/widgets/data/tests *.tab *.txt *.foo *.xlsx recursive-include Orange/widgets/tests/workflows *.ows recursive-include distribute *.svg *.desktop include requirements*.txt +recursive-include i18n *.jaml *.yaml + +recursive-include doc/visual-programming/build/htmlhelp * + include README.md include README.pypi include CONTRIBUTING.md diff --git a/Orange/__init__.py b/Orange/__init__.py index 43a5bbf65a4..6cbf4ce7bbe 100644 --- a/Orange/__init__.py +++ b/Orange/__init__.py @@ -19,24 +19,6 @@ del mod_name -# If Qt is available (GUI) and Qt5, install backport for PyQt4 imports -try: - import AnyQt.importhooks -except ImportError: - pass -else: - if AnyQt.USED_API == "pyqt5": - # Make the chosen PyQt version pinned - from AnyQt.QtCore import QObject - del QObject - - import pyqtgraph # import pyqtgraph first so that it can detect Qt5 - del pyqtgraph - - AnyQt.importhooks.install_backport_hook('pyqt4') - del AnyQt - - # A hack that prevents segmentation fault with Nvidia drives on Linux if Qt's browser window # is shown (seen in https://github.com/spyder-ide/spyder/pull/7029/files) try: diff --git a/Orange/base.py b/Orange/base.py index f618a876f46..ff77b0ad721 100644 --- a/Orange/base.py +++ b/Orange/base.py @@ -3,12 +3,13 @@ from collections.abc import Iterable import re import warnings -from typing import Callable, Dict +from typing import Callable, Optional, NamedTuple, Type import numpy as np import scipy +from sklearn.preprocessing import normalize -from Orange.data import Table, Storage, Instance, Value +from Orange.data import Table, Storage, Instance, Value, Domain from Orange.data.filter import HasClass from Orange.data.table import DomainTransformationError from Orange.data.util import one_hot @@ -19,6 +20,7 @@ from Orange.util import Reprable, OrangeDeprecationWarning, wrap_callback, \ dummy_callback + __all__ = ["Learner", "Model", "SklLearner", "SklModel", "ReprableWithPreprocessors"] @@ -86,6 +88,16 @@ class Learner(ReprableWithPreprocessors): #: A sequence of data preprocessors to apply on data prior to #: fitting the model preprocessors = () + + class FittedParameter(NamedTuple): + name: str + label: str + type: Type + min: Optional[int] = None + max: Optional[int] = None + + # Note: Do not use this class attribute. + # It remains here for compatibility reasons. learner_adequacy_err_msg = '' def __init__(self, preprocessors=None): @@ -95,6 +107,7 @@ def __init__(self, preprocessors=None): elif preprocessors: self.preprocessors = (preprocessors,) + # pylint: disable=R0201 def fit(self, X, Y, W=None): raise RuntimeError( "Descendants of Learner must overload method fit or fit_storage") @@ -106,8 +119,9 @@ def fit_storage(self, data): return self.fit(X, Y, W) def __call__(self, data, progress_callback=None): - if not self.check_learner_adequacy(data.domain): - raise ValueError(self.learner_adequacy_err_msg) + reason = self.incompatibility_reason(data.domain) + if reason is not None: + raise ValueError(reason) origdomain = data.domain @@ -173,8 +187,14 @@ def active_preprocessors(self): self.preprocessors is not type(self).preprocessors): yield from type(self).preprocessors - def check_learner_adequacy(self, _): - return True + @property + def fitted_parameters(self) -> list: + return [] + + # pylint: disable=no-self-use + def incompatibility_reason(self, _: Domain) -> Optional[str]: + """Return None if a learner can fit domain or string explaining why it can not.""" + return None @property def name(self): @@ -436,7 +456,7 @@ def fix_dim(x): # Call the predictor backmappers = None n_values = [] - if isinstance(data, (np.ndarray, scipy.sparse.csr.csr_matrix)): + if isinstance(data, (np.ndarray, scipy.sparse.csr_matrix)): prediction = self.predict(data) elif isinstance(data, Table): backmappers, n_values = self.get_backmappers(data) @@ -458,10 +478,9 @@ def fix_dim(x): elif prediction.ndim == 2 + multitarget: value, probs = None, prediction else: - raise TypeError("model returned a %i-dimensional array", - prediction.ndim) + raise TypeError(f"model returned a {prediction.ndim}-dimensional array") - # Ensure that we have what we need to return; backmapp everything + # Ensure that we have what we need to return; backmap everything if probs is None and (ret != Model.Value or backmappers is not None): probs = one_hot_probs(value) if probs is not None: @@ -585,12 +604,6 @@ def fit(self, X, Y, W=None): return self.__returns__(clf.fit(X, Y)) return self.__returns__(clf.fit(X, Y, sample_weight=W.reshape(-1))) - @property - def supports_weights(self): - """Indicates whether this learner supports weighted instances. - """ - return 'sample_weight' in self.__wraps__.fit.__code__.co_varnames - def __getattr__(self, item): try: return self.params[item] @@ -633,10 +646,18 @@ def __init__(self, n_neighbors=5, metric="euclidean", weights="uniform", super().__init__(preprocessors=preprocessors) self.params = vars() + def _initialize_wrapped(self): + params = self.params.copy() + if params.get("metric") == "cosine": + params["metric"] = "euclidean" + return self.__wraps__(**params) + def fit(self, X, Y, W=None): if self.params["metric_params"] is None and \ self.params.get("metric") == "mahalanobis": self.params["metric_params"] = {"V": np.cov(X.T)} + if self.params["metric"] == "cosine": + X = normalize(X, norm="l2", axis=1, copy=True) return super().fit(X, Y, W) @@ -664,6 +685,13 @@ def __init__(self, cat_model, cat_features, domain): self.cat_model = cat_model self.cat_features = cat_features + def __call__(self, data, ret=Model.Value): + if isinstance(data, Table): + with data.force_unlocked(data.X): + return super().__call__(data, ret) + else: + return super().__call__(data, ret) + def predict(self, X): if self.cat_features: X = X.astype(str) @@ -824,17 +852,18 @@ def __call__(self, data, progress_callback=None): return m def fit_storage(self, data: Table): - domain, X, Y, W = data.domain, data.X, data.Y.reshape(-1), None - if self.supports_weights and data.has_weights(): - W = data.W.reshape(-1) - # pylint: disable=not-callable - clf = self.__wraps__(**self.params) - cat_features = [i for i, attr in enumerate(domain.attributes) - if attr.is_discrete] - if cat_features: - X = X.astype(str) - cat_model = clf.fit(X, Y, cat_features=cat_features, sample_weight=W) - return self.__returns__(cat_model, cat_features, domain) + with data.force_unlocked(data.X): + domain, X, Y, W = data.domain, data.X, data.Y.reshape(-1), None + if self.supports_weights and data.has_weights(): + W = data.W.reshape(-1) + # pylint: disable=not-callable + clf = self.__wraps__(**self.params) + cat_features = [i for i, attr in enumerate(domain.attributes) + if attr.is_discrete] + if cat_features: + X = X.astype(str) + cat_model = clf.fit(X, Y, cat_features=cat_features, sample_weight=W) + return self.__returns__(cat_model, cat_features, domain) def __getattr__(self, item): try: @@ -860,5 +889,5 @@ def __init__(self, preprocessors=None, **kwargs): self.params = kwargs @SklLearner.params.setter - def params(self, values: Dict): + def params(self, values: dict): self._params = values diff --git a/Orange/canvas/__main__.py b/Orange/canvas/__main__.py index f4c265918bc..3fc762c232b 100644 --- a/Orange/canvas/__main__.py +++ b/Orange/canvas/__main__.py @@ -2,49 +2,33 @@ Orange Canvas main entry point """ +import argparse import uuid -from collections import defaultdict -from contextlib import closing - import os import sys -from datetime import date - -import gc -import re import time import logging -from logging.handlers import RotatingFileHandler import signal -import optparse -import pickle -import shlex import shutil -from unittest.mock import patch + +from logging.handlers import RotatingFileHandler +from collections import defaultdict +from datetime import date from urllib.request import urlopen, Request +from packaging.version import Version -import pkg_resources import yaml -from AnyQt.QtGui import QFont, QColor, QPalette, QDesktopServices, QIcon -from AnyQt.QtCore import ( - Qt, QDir, QUrl, QSettings, QThread, pyqtSignal, QT_VERSION, QFile -) +from AnyQt.QtGui import QColor, QDesktopServices, QIcon, QPalette +from AnyQt.QtCore import QUrl, QSettings, QThread, pyqtSignal + import pyqtgraph -import orangecanvas from orangecanvas import config as canvasconfig -from orangecanvas.registry import qt, WidgetRegistry, set_global_registry, cache -from orangecanvas.application.application import CanvasApplication -from orangecanvas.application.outputview import TextStream, ExceptHook +from orangecanvas.application.outputview import ExceptHook from orangecanvas.document.usagestatistics import UsageStatistics -from orangecanvas.gui.splashscreen import SplashScreen -from orangecanvas.utils.after_exit import run_after_exit from orangecanvas.utils.overlay import Notification, NotificationServer -from orangecanvas.main import ( - fix_win_pythonw_std_stream, fix_set_proxy_env, fix_macos_nswindow_tabbing, - breeze_dark, -) +from orangecanvas.main import Main from orangewidget.workflow.errorreporting import handle_exception @@ -106,7 +90,7 @@ def run(self): request = Request('https://orange.biolab.si/version/', headers={ 'Accept': 'text/plain', - 'Accept-Encoding': 'gzip, deflate', + 'Accept-Encoding': 'identity', 'Connection': 'close', 'User-Agent': ua_string()}) contents = urlopen(request, timeout=10).read().decode() @@ -117,9 +101,8 @@ def run(self): self.resultReady.emit(contents) def compare_versions(latest): - version = pkg_resources.parse_version skipped = settings.value('startup/latest-skipped-version', "", type=str) - if version(latest) <= version(current) or \ + if Version(latest) <= Version(current) or \ latest == skipped: return @@ -203,8 +186,6 @@ def pull_notifications(): if not check_notifs: return None - Version = pkg_resources.parse_version - # create settings_dict for notif requirements purposes (read-only) spec = canvasconfig.spec + config.spec settings_dict = canvasconfig.Settings(defaults=spec, store=settings) @@ -214,7 +195,7 @@ def pull_notifications(): if ep.dist is not None] installed = defaultdict(lambda: "-1") for addon in installed_list: - installed[addon.project_name] = addon.version + installed[addon.name] = addon.version # get set of already displayed notification IDs, stored in settings["notifications/displayed"] displayedIDs = literal_eval(settings.value("notifications/displayed", "set()", str)) @@ -228,6 +209,7 @@ def run(self): request = Request('https://orange.biolab.si/notification-feed', headers={ 'Accept': 'text/plain', + 'Accept-Encoding': 'identity', 'Connection': 'close', 'User-Agent': ua_string(), 'Cache-Control': 'no-cache', @@ -337,7 +319,7 @@ def send_statistics(url): r = requests.post(url, files={'file': json.dumps(data)}) if r.status_code != 200: log.warning("Error communicating with server while attempting to send " - "usage statistics. Status code " + str(r.status_code)) + "usage statistics. Status code %d", r.status_code) return # success - wipe statistics file log.info("Usage statistics sent.") @@ -361,352 +343,125 @@ def run(self): return thread -# pylint: disable=too-many-locals,too-many-branches -def main(argv=None): - # Allow termination with CTRL + C - signal.signal(signal.SIGINT, signal.SIG_DFL) - # Disable pyqtgraph's atexit and QApplication.aboutToQuit cleanup handlers. - pyqtgraph.setConfigOption("exitCleanup", False) - - if argv is None: - argv = sys.argv - - usage = "usage: %prog [options] [workflow_file]" - parser = optparse.OptionParser(usage=usage) - - parser.add_option("--no-discovery", - action="store_true", - help="Don't run widget discovery " - "(use full cache instead)") - parser.add_option("--force-discovery", - action="store_true", - help="Force full widget discovery " - "(invalidate cache)") - parser.add_option("--clear-widget-settings", - action="store_true", - help="Remove stored widget setting") - parser.add_option("--no-welcome", - action="store_true", - help="Don't show welcome dialog.") - parser.add_option("--no-splash", - action="store_true", - help="Don't show splash screen.") - parser.add_option("-l", "--log-level", - help="Logging level (0, 1, 2, 3, 4)", - type="int", default=1) - parser.add_option("--style", - help="QStyle to use", - type="str", default=None) - parser.add_option("--stylesheet", - help="Application level CSS style sheet to use", - type="str", default=None) - parser.add_option("--qt", - help="Additional arguments for QApplication", - type="str", default=None) - - (options, args) = parser.parse_args(argv[1:]) - - levels = [logging.CRITICAL, - logging.ERROR, - logging.WARN, - logging.INFO, - logging.DEBUG] - - # Fix streams before configuring logging (otherwise it will store - # and write to the old file descriptors) - fix_win_pythonw_std_stream() - - # Try to fix macOS automatic window tabbing (Sierra and later) - fix_macos_nswindow_tabbing() - - logging.basicConfig( - level=levels[options.log_level], - handlers=[make_stdout_handler(levels[options.log_level])] - ) - # set default application configuration - config_ = config.Config() - canvasconfig.set_default(config_) - log.info("Starting 'Orange Canvas' application.") - - qt_argv = argv[:1] - - style = options.style - defaultstylesheet = "orange.qss" - fusiontheme = None - - if style is not None: - if style.startswith("fusion:"): - qt_argv += ["-style", "fusion"] - _, _, fusiontheme = style.partition(":") - else: - qt_argv += ["-style", style] - - if options.qt is not None: - qt_argv += shlex.split(options.qt) - - qt_argv += args - - if QT_VERSION >= 0x50600: - CanvasApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) - - log.debug("Starting CanvasApplicaiton with argv = %r.", qt_argv) - app = CanvasApplication(qt_argv) - config_.init() - if app.style().metaObject().className() == "QFusionStyle": - if fusiontheme == "breeze-dark": - app.setPalette(breeze_dark()) - defaultstylesheet = "darkorange.qss" - - palette = app.palette() - if style is None and palette.color(QPalette.Window).value() < 127: - log.info("Switching default stylesheet to darkorange") - defaultstylesheet = "darkorange.qss" - - # Initialize SQL query and execution time logger (in SqlTable) - sql_level = min(levels[options.log_level], logging.INFO) - make_sql_logger(sql_level) +class OMain(Main): + DefaultConfig = "Orange.canvas.config.Config" + + def run(self, argv): + # Allow termination with CTRL + C + signal.signal(signal.SIGINT, signal.SIG_DFL) + # Disable pyqtgraph's atexit and QApplication.aboutToQuit cleanup handlers. + pyqtgraph.setConfigOption("exitCleanup", False) + super().run(argv) + + def argument_parser(self) -> argparse.ArgumentParser: + parser = super().argument_parser() + parser.add_argument( + "--clear-widget-settings", action="store_true", + help="Clear stored widget setting/defaults", + ) + parser.add_argument( + "--clear-all", action="store_true", + help="Clear all settings and caches" + ) + return parser + + def setup_logging(self): + super().setup_logging() + make_sql_logger(self.options.log_level) - clear_settings_flag = os.path.join(widget_settings_dir(), "DELETE_ON_START") + @staticmethod + def _rm_tree(path): + log.debug("rmtree '%s'", path) + shutil.rmtree(path, ignore_errors=True) - if options.clear_widget_settings or \ - os.path.isfile(clear_settings_flag): + def clear_widget_settings(self): log.info("Clearing widget settings") - shutil.rmtree(widget_settings_dir(), ignore_errors=True) - - # Set http_proxy environment variables, after (potentially) clearing settings - fix_set_proxy_env() - - # Setup file log handler for the select logger list - this is always - # at least INFO - level = min(levels[options.log_level], logging.INFO) - file_handler = logging.FileHandler( - filename=os.path.join(config.log_dir(), "canvas.log"), - mode="w" - ) - formatter = logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s") - file_handler.setFormatter(formatter) - file_handler.setLevel(level) - - stream = TextStream() - stream_handler = logging.StreamHandler(stream) - stream_handler.setFormatter(formatter) - stream_handler.setLevel(level) - - for namespace in ["orangecanvas", "orangewidget", "Orange"]: - logger = logging.getLogger(namespace) - logger.setLevel(level) - logger.addHandler(file_handler) - logger.addHandler(stream_handler) - - # intercept any QFileOpenEvent requests until the main window is - # fully initialized. - # NOTE: The QApplication must have the executable ($0) and filename - # arguments passed in argv otherwise the FileOpen events are - # triggered for them (this is done by Cocoa, but QApplicaiton filters - # them out if passed in argv) - - open_requests = [] - - def onrequest(url): - log.info("Received an file open request %s", url) - path = url.path() - exists = QFile(path).exists() - if exists and \ - ('pydevd.py' not in url.path() and # PyCharm debugger - 'run_profiler.py' not in url.path()): # PyCharm profiler - open_requests.append(url) - - app.fileOpenRequest.connect(onrequest) - - settings = QSettings() - settings.setValue('startup/launch-count', settings.value('startup/launch-count', 0, int) + 1) - - if settings.value("reporting/send-statistics", False, type=bool) \ - and is_release: - UsageStatistics.set_enabled(True) - - stylesheet = options.stylesheet or defaultstylesheet - stylesheet_string = None - - if stylesheet != "none": - if os.path.isfile(stylesheet): - with open(stylesheet, "r") as f: - stylesheet_string = f.read() - else: - if not os.path.splitext(stylesheet)[1]: - # no extension - stylesheet = os.path.extsep.join([stylesheet, "qss"]) - - pkg_name = orangecanvas.__name__ - resource = "styles/" + stylesheet - - if pkg_resources.resource_exists(pkg_name, resource): - stylesheet_string = \ - pkg_resources.resource_string(pkg_name, resource).decode() - - base = pkg_resources.resource_filename(pkg_name, "styles") + self._rm_tree(widget_settings_dir(versioned=True)) + self._rm_tree(widget_settings_dir(versioned=False)) + + def clear_caches(self): # pylint: disable=import-outside-toplevel + from Orange.misc import environ + log.info("Clearing caches") + self._rm_tree(environ.cache_dir()) + log.info("Clearing data") + self._rm_tree(environ.data_dir(versioned=True)) + self._rm_tree(environ.data_dir(versioned=False)) + + def clear_application_settings(self): # pylint: disable=no-self-use + s = QSettings() + log.info("Clearing application settings") + log.debug("clear '%s'", s.fileName()) + s.clear() + s.sync() + + def setup_application(self): + super().setup_application() + clear_settings_flag = os.path.join(widget_settings_dir(), + "DELETE_ON_START") + # NOTE: No OWWidgetBase subclass should be imported before this + options = self.options + if options.clear_widget_settings or \ + os.path.isfile(clear_settings_flag): + self.clear_widget_settings() + + if options.clear_all: + self.clear_widget_settings() + self.clear_caches() + self.clear_application_settings() + + notif_server = NotificationServer() + canvas.notification_server_instance = notif_server + + self._update_check = check_for_updates() + self._send_stat = send_usage_statistics() + self._pull_notifs = pull_notifications() - pattern = re.compile( - r"^\s@([a-zA-Z0-9_]+?)\s*:\s*([a-zA-Z0-9_/]+?);\s*$", - flags=re.MULTILINE - ) - - matches = pattern.findall(stylesheet_string) - - for prefix, search_path in matches: - QDir.addSearchPath(prefix, os.path.join(base, search_path)) - log.info("Adding search path %r for prefix, %r", - search_path, prefix) - - stylesheet_string = pattern.sub("", stylesheet_string) - - else: - log.info("%r style sheet not found.", stylesheet) - - # Add the default canvas_icons search path - dirpath = os.path.abspath(os.path.dirname(orangecanvas.__file__)) - QDir.addSearchPath("canvas_icons", os.path.join(dirpath, "icons")) + settings = QSettings() + settings.setValue('startup/launch-count', + settings.value('startup/launch-count', 0, int) + 1) - canvas_window = MainWindow() - canvas_window.setAttribute(Qt.WA_DeleteOnClose) - canvas_window.setWindowIcon(config.application_icon()) - canvas_window.connect_output_stream(stream) + if settings.value("reporting/send-statistics", False, type=bool) \ + and is_release: + UsageStatistics.set_enabled(True) - # initialize notification server, set to initial canvas - notif_server = NotificationServer() - canvas.notification_server_instance = notif_server - canvas_window.set_notification_server(notif_server) + app = self.application - if stylesheet_string is not None: - canvas_window.setStyleSheet(stylesheet_string) + # set pyqtgraph colors + def onPaletteChange(): + p = app.palette() + bg = p.base().color().name() + fg = p.windowText().color().name() - if not options.force_discovery: - reg_cache = cache.registry_cache() - else: - reg_cache = None + log.info('Setting pyqtgraph background to %s', bg) + pyqtgraph.setConfigOption('background', bg) + log.info('Setting pyqtgraph foreground to %s', fg) + pyqtgraph.setConfigOption('foreground', fg) + app.setProperty('darkMode', p.color(QPalette.Base).value() < 127) - widget_registry = qt.QtWidgetRegistry() - widget_discovery = config_.widget_discovery( - widget_registry, cached_descriptions=reg_cache) + app.paletteChanged.connect(onPaletteChange) + onPaletteChange() - want_splash = \ - settings.value("startup/show-splash-screen", True, type=bool) and \ - not options.no_splash + def show_splash_message(self, message: str, color=QColor("#FFFFFF")): + super().show_splash_message(message, color) - if want_splash: - pm, rect = config.splash_screen() - splash_screen = SplashScreen(pixmap=pm, textRect=rect) - splash_screen.setFont(QFont("Helvetica", 12)) - color = QColor("#FFD39F") + def create_main_window(self): + window = MainWindow() + window.set_notification_server(canvas.notification_server_instance) + return window - def show_message(message): - splash_screen.showMessage(message, color=color) + def setup_sys_redirections(self): + super().setup_sys_redirections() + if isinstance(sys.excepthook, ExceptHook): + sys.excepthook.handledException.connect(handle_exception) - widget_registry.category_added.connect(show_message) + def tear_down_sys_redirections(self): + if isinstance(sys.excepthook, ExceptHook): + sys.excepthook.handledException.disconnect(handle_exception) + super().tear_down_sys_redirections() - log.info("Running widget discovery process.") - cache_filename = os.path.join(config.cache_dir(), "widget-registry.pck") - if options.no_discovery: - with open(cache_filename, "rb") as f: - widget_registry = pickle.load(f) - widget_registry = qt.QtWidgetRegistry(widget_registry) - else: - if want_splash: - splash_screen.show() - widget_discovery.run(config.widgets_entry_points()) - if want_splash: - splash_screen.hide() - splash_screen.deleteLater() - - # Store cached descriptions - cache.save_registry_cache(widget_discovery.cached_descriptions) - with open(cache_filename, "wb") as f: - pickle.dump(WidgetRegistry(widget_registry), f) - - set_global_registry(widget_registry) - canvas_window.set_widget_registry(widget_registry) - canvas_window.show() - canvas_window.raise_() - - want_welcome = \ - settings.value("startup/show-welcome-screen", True, type=bool) \ - and not options.no_welcome - - # Process events to make sure the canvas_window layout has - # a chance to activate (the welcome dialog is modal and will - # block the event queue, plus we need a chance to receive open file - # signals when running without a splash screen) - app.processEvents() - - app.fileOpenRequest.connect(canvas_window.open_scheme_file) - - if args: - log.info("Loading a scheme from the command line argument %r", - args[0]) - canvas_window.load_scheme(args[0]) - elif open_requests: - log.info("Loading a scheme from an `QFileOpenEvent` for %r", - open_requests[-1]) - canvas_window.load_scheme(open_requests[-1].toLocalFile()) - else: - swp_loaded = canvas_window.ask_load_swp_if_exists() - if not swp_loaded and want_welcome: - canvas_window.welcome_dialog() - - # local references prevent destruction - update_check = check_for_updates() - send_stat = send_usage_statistics() - pull_notifs = pull_notifications() - - # Tee stdout and stderr into Output dock - log_view = canvas_window.output_view() - - stdout = TextStream() - stdout.stream.connect(log_view.write) - if sys.stdout: - stdout.stream.connect(sys.stdout.write) - stdout.flushed.connect(sys.stdout.flush) - - stderr = TextStream() - error_writer = log_view.formatted(color=Qt.red) - stderr.stream.connect(error_writer.write) - if sys.stderr: - stderr.stream.connect(sys.stderr.write) - stderr.flushed.connect(sys.stderr.flush) - - log.info("Entering main event loop.") - excepthook = ExceptHook(stream=stderr) - excepthook.handledException.connect(handle_exception) - try: - with closing(stdout),\ - closing(stderr),\ - closing(stream), \ - patch('sys.excepthook', excepthook),\ - patch('sys.stderr', stderr),\ - patch('sys.stdout', stdout): - status = app.exec() - except BaseException: - log.error("Error in main event loop.", exc_info=True) - status = 42 - - del canvas_window - del update_check - del send_stat - del pull_notifs - - app.processEvents() - app.flush() - # Collect any cycles before deleting the QApplication instance - gc.collect() - - del app - - if status == 96: - log.info('Restarting via exit code 96.') - run_after_exit([sys.executable, sys.argv[0]]) - - return status +def main(argv=None): + return OMain().run(argv) if __name__ == "__main__": diff --git a/Orange/canvas/config.py b/Orange/canvas/config.py index 185172a2129..d64c066e62c 100644 --- a/Orange/canvas/config.py +++ b/Orange/canvas/config.py @@ -2,23 +2,27 @@ Orange Canvas Configuration """ +import pkgutil + +import random import uuid import warnings - import os import sys -import itertools -from distutils.version import LooseVersion from typing import Dict, Any, Optional, Iterable, List -import pkg_resources +import packaging.version import requests -from AnyQt.QtGui import QPainter, QFont, QFontMetrics, QColor, QPixmap, QIcon +from AnyQt.QtGui import ( + QPainter, QFont, QFontMetrics, QColor, QImage, QPixmap, QIcon, + QGuiApplication +) from AnyQt.QtCore import Qt, QPoint, QRect, QSettings from orangecanvas import config as occonfig +from orangecanvas.config import entry_points, EntryPoint from orangecanvas.utils.settings import config_slot from orangewidget.workflow import config from orangewidget.settings import set_widget_settings_dir_components @@ -60,6 +64,11 @@ spec = [config_slot(*t) for t in spec] +def _pixmap_from_pkg_data(package, path, format): + contents = pkgutil.get_data(package, path) + return QPixmap.fromImage(QImage.fromData(contents, format)) + + class Config(config.Config): """ Orange application configuration @@ -67,9 +76,11 @@ class Config(config.Config): OrganizationDomain = "biolab.si" ApplicationName = "Orange" ApplicationVersion = Orange.__version__ + AppUserModelID = "Biolab.Orange" # AppUserModelID for windows task bar def init(self): super().init() + QGuiApplication.setApplicationDisplayName(self.ApplicationName) widget_settings_dir_cfg = environ.get_path("widget_settings_dir", "") if widget_settings_dir_cfg: # widget_settings_dir is configured via config file @@ -93,40 +104,37 @@ def application_icon(): """ Return the main application icon. """ - path = pkg_resources.resource_filename( - __name__, "icons/orange-canvas.svg" + return QIcon( + _pixmap_from_pkg_data(__package__, "icons/orange-256.png", "png") ) - return QIcon(path) @staticmethod def splash_screen(): - path = pkg_resources.resource_filename( - __name__, "icons/orange-splash-screen.png") - pm = QPixmap(path) + splash_n = random.randint(1, 3) + pm = _pixmap_from_pkg_data( + __name__, f"icons/orange-splash-screen-{splash_n:02}.png", "png" + ) version = Config.ApplicationVersion if version: - version_parsed = LooseVersion(version) - version_comp = version_parsed.version + version_parsed = packaging.version.Version(version) + version_comp = version_parsed.release version = ".".join(map(str, version_comp[:2])) - size = 21 if len(version) < 5 else 16 + size = 13 font = QFont("Helvetica") font.setPixelSize(size) - font.setBold(True) - font.setItalic(True) - font.setLetterSpacing(QFont.AbsoluteSpacing, 2) metrics = QFontMetrics(font) - br = metrics.boundingRect(version).adjusted(-5, 0, 5, 0) - br.moveCenter(QPoint(436, 224)) + br = metrics.boundingRect(version) + br.moveTopLeft(QPoint(171, 438)) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.TextAntialiasing) p.setFont(font) - p.setPen(QColor("#231F20")) - p.drawText(br, Qt.AlignCenter, version) + p.setPen(QColor("#000000")) + p.drawText(br, Qt.AlignLeft, version) p.end() - return pm, QRect(88, 193, 200, 20) + return pm, QRect(23, 24, 200, 20) @staticmethod def widgets_entry_points(): @@ -137,9 +145,9 @@ def widgets_entry_points(): # Ensure the 'this' distribution's ep is the first. iter_entry_points # yields them in unspecified order. all_eps = sorted( - pkg_resources.iter_entry_points(WIDGETS_ENTRY), + entry_points(group=WIDGETS_ENTRY), key=lambda ep: - 0 if ep.dist.project_name.lower() == "orange3" else 1 + 0 if ep.dist.name.lower() == "orange3" else 1 ) return iter(all_eps) @@ -172,21 +180,23 @@ def core_packages(): @staticmethod def examples_entry_points(): - # type: () -> Iterable[pkg_resources.EntryPoint] + # type: () -> Iterable[EntryPoint] """ Return an iterator over the entry points yielding 'Example Workflows' """ - # `iter_entry_points` yields them in unspecified order, so we insert - # our first - default_ep = pkg_resources.EntryPoint( - "Orange3", "Orange.canvas.workflows", - dist=pkg_resources.get_distribution("Orange3")) - - return itertools.chain( - (default_ep,), - pkg_resources.iter_entry_points("orange.widgets.tutorials") + # `iter_entry_points` yields them in unspecified order, so we order + # them by name. The default is at the beginning, unless another + # entrypoint precedes it alphabetically (e.g. starting with '!'). + default_ep = EntryPoint( + "000-Orange3", "Orange.canvas.workflows", "orange.widgets.tutorials", ) + all_ep = list(entry_points(group="orange.widgets.tutorials")) + all_ep.append(default_ep) + all_ep.sort(key=lambda x: x.name) + return iter(all_ep) + + # pylint: disable=line-too-long APPLICATION_URLS = { #: Submit a bug report action in the Help menu "Bug Report": "https://github.com/biolab/orange3/issues", @@ -200,7 +210,8 @@ def examples_entry_points(): "https://www.youtube.com/watch" "?v=HXjnDIgGDuI&list=PLmNPvQr9Tf-ZSDLwOzxpvY-HrE0yv-8Fy&index=1", #: Used for 'Submit Feedback' action in the help menu - "Feedback": "https://orange.biolab.si/survey/long.html", + "Donate": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=A76TAX87ZVR3J", + "FAQ": "https://orangedatamining.com/faq/", } diff --git a/Orange/canvas/icons/orange-256.png b/Orange/canvas/icons/orange-256.png new file mode 100644 index 00000000000..d2cf9b27deb Binary files /dev/null and b/Orange/canvas/icons/orange-256.png differ diff --git a/Orange/canvas/icons/orange-canvas.svg b/Orange/canvas/icons/orange-canvas.svg deleted file mode 100644 index 539a8f20492..00000000000 --- a/Orange/canvas/icons/orange-canvas.svg +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Orange/canvas/icons/orange-splash-screen-01.png b/Orange/canvas/icons/orange-splash-screen-01.png new file mode 100644 index 00000000000..979f79cc198 Binary files /dev/null and b/Orange/canvas/icons/orange-splash-screen-01.png differ diff --git a/Orange/canvas/icons/orange-splash-screen-02.png b/Orange/canvas/icons/orange-splash-screen-02.png new file mode 100644 index 00000000000..8695b4e071f Binary files /dev/null and b/Orange/canvas/icons/orange-splash-screen-02.png differ diff --git a/Orange/canvas/icons/orange-splash-screen-03.png b/Orange/canvas/icons/orange-splash-screen-03.png new file mode 100644 index 00000000000..d2ff3cfbd52 Binary files /dev/null and b/Orange/canvas/icons/orange-splash-screen-03.png differ diff --git a/Orange/canvas/icons/orange-splash-screen.png b/Orange/canvas/icons/orange-splash-screen.png deleted file mode 100644 index a1e49fe9f3a..00000000000 Binary files a/Orange/canvas/icons/orange-splash-screen.png and /dev/null differ diff --git a/Orange/canvas/icons/orange.ico b/Orange/canvas/icons/orange.ico index 32735f30466..88d24c6413e 100644 Binary files a/Orange/canvas/icons/orange.ico and b/Orange/canvas/icons/orange.ico differ diff --git a/Orange/canvas/mainwindow.py b/Orange/canvas/mainwindow.py index 7a8fc67a95f..bd44137b64e 100644 --- a/Orange/canvas/mainwindow.py +++ b/Orange/canvas/mainwindow.py @@ -3,6 +3,7 @@ QFormLayout, QCheckBox, QLineEdit, QWidget, QVBoxLayout, QLabel ) from orangecanvas.application.settings import UserSettingsDialog, FormLayout +from orangecanvas.document.interactions import PluginDropHandler from orangecanvas.document.usagestatistics import UsageStatistics from orangecanvas.utils.overlay import NotificationOverlay @@ -107,6 +108,9 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.notification_overlay = NotificationOverlay(self.scheme_widget) self.notification_server = None + self.scheme_widget.setDropHandlers([ + PluginDropHandler("orange.canvas.drophandler") + ]) def open_canvas_settings(self): # type: () -> None diff --git a/Orange/canvas/run.py b/Orange/canvas/run.py index dacda3d7b02..b807240198f 100644 --- a/Orange/canvas/run.py +++ b/Orange/canvas/run.py @@ -11,6 +11,17 @@ from orangecanvas.scheme.node import UserMessage from orangecanvas.scheme import signalmanager +# Imported to make webwidget addons work +from Orange.widgets.utils.webview import WebviewWidget # pylint: disable=unused-import + +LOG_LEVELS = [ + logging.CRITICAL + 10, + logging.CRITICAL, + logging.ERROR, + logging.WARN, + logging.INFO, + logging.DEBUG +] def main(argv=None): app = QApplication(list(argv) if argv else []) @@ -25,13 +36,13 @@ def main(argv=None): ) ) parser.add_argument("--log-level", "-l", metavar="LEVEL", type=int, - default=logging.CRITICAL, dest="log_level") + default=3, dest="log_level") parser.add_argument("--config", default="Orange.canvas.config.Config", type=str) parser.add_argument("file") args = parser.parse_args(argv[1:]) - log_level = args.log_level + log_level = LOG_LEVELS[args.log_level] filename = args.file logging.basicConfig(level=log_level) @@ -68,6 +79,9 @@ def on_finished(): if msg.contents and msg.severity == msg.Error: print(msg.contents, msg.message_id, file=sys.stderr) severity = msg.Error + elif msg.contents and msg.severity == msg.Warning and \ + log_level <= logging.WARNING: + print(msg.contents, file=sys.stderr) if severity == UserMessage.Error: app.exit(1) else: diff --git a/Orange/canvas/tests/__init__.py b/Orange/canvas/tests/__init__.py index e69de29bb2d..132be49d7a1 100644 --- a/Orange/canvas/tests/__init__.py +++ b/Orange/canvas/tests/__init__.py @@ -0,0 +1,4 @@ +from Orange.data import Table + +if Table.LOCKING is None: + Table.LOCKING = True diff --git a/Orange/canvas/workflows/110-file-and-data-table-widget.ows b/Orange/canvas/workflows/110-file-and-data-table-widget.ows index c09288dae38..d467ceb890b 100644 --- a/Orange/canvas/workflows/110-file-and-data-table-widget.ows +++ b/Orange/canvas/workflows/110-file-and-data-table-widget.ows @@ -1,64 +1,51 @@ - + - - + + - + - A File widget. Double click to open it and select the dataset file. - The output of the File widget. - The input of the Data Table widget. - The communication channel. It passes the dataset from the File widget to the Data Table. - A Data Table widget. Double click the icon to see the data in a spreadsheet. - The output of the Data Table to send out any data (rows) that are selected to the widget. - This output is not used, hence dashed line. You can add another Data Table by clicking on its icon from the toolbox on the left, connect the ouput of Data Table to the input of new Data Table (1) and check if the selected data from Data Table is indeed sent to the downstream widget. This demo works best if both widgets are open, that is, their windows displayed. - - - - - - - + A File widget. Double click to open it and select the dataset file. + The output of the File widget. + The input of the Data Table widget. + The communication channel. It passes the dataset from the File widget to the Data Table. + A Data Table widget. Double click the icon to see the data in a spreadsheet. + The output of the Data Table to send out any data (rows) that are selected to the widget. + This output is not used, hence dashed line. You can add another Data Table by clicking on its icon from the toolbox on the left, connect the ouput of Data Table to the input of new Data Table (1) and check if the selected data from Data Table is indeed sent to the downstream widget. This demo works best if both widgets are open, that is, their windows displayed. + + + + + + + - gAN9cQAoWBIAAABjb250cm9sQXJlYVZpc2libGVxAYhYDAAAAHJlY2VudF9wYXRoc3ECXXEDY09y -YW5nZS53aWRnZXRzLnV0aWxzLmZpbGVkaWFsb2dzClJlY2VudFBhdGgKcQQpgXEFfXEGKFgHAAAA -YWJzcGF0aHEHWDAAAAAvVXNlcnMvYW56ZS9kZXYvb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJp -cy50YWJxCFgGAAAAcHJlZml4cQlYDwAAAHNhbXBsZS1kYXRhc2V0c3EKWAcAAAByZWxwYXRocQtY -CAAAAGlyaXMudGFicQxYBQAAAHRpdGxlcQ1YAAAAAHEOWAUAAABzaGVldHEPaA5YCwAAAGZpbGVf -Zm9ybWF0cRBOdWJhWAsAAAByZWNlbnRfdXJsc3ERXXESWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5 -cRNDLgHZ0MsAAQAAAAACEwAAATwAAAOqAAACNQAAAhMAAAFSAAADqgAAAjUAAAAAAABxFFgLAAAA -c2hlZXRfbmFtZXNxFX1xFlgGAAAAc291cmNlcRdLAFgDAAAAdXJscRhoDlgNAAAAZG9tYWluX2Vk -aXRvcnEZfXEaWAsAAABfX3ZlcnNpb25fX3EbSwFYEAAAAGNvbnRleHRfc2V0dGluZ3NxHF1xHWNP -cmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0CnEeKYFxH31xIChYBAAAAHRpbWVxIUdB1qcW -DLpbX1gGAAAAdmFsdWVzcSJ9cSMoWAkAAAB2YXJpYWJsZXNxJF1xJVgJAAAAeGxzX3NoZWV0cSZo -Dkr/////hnEnaBl9cShoJF1xKShdcSooWAwAAABzZXBhbCBsZW5ndGhxK2NPcmFuZ2UuZGF0YS52 -YXJpYWJsZQpDb250aW51b3VzVmFyaWFibGUKcSxLAGgOiGVdcS0oWAsAAABzZXBhbCB3aWR0aHEu -aCxLAGgOiGVdcS8oWAwAAABwZXRhbCBsZW5ndGhxMGgsSwBoDohlXXExKFgLAAAAcGV0YWwgd2lk -dGhxMmgsSwBoDohlXXEzKFgEAAAAaXJpc3E0Y09yYW5nZS5kYXRhLnZhcmlhYmxlCkRpc2NyZXRl -VmFyaWFibGUKcTVLAVgsAAAASXJpcy1zZXRvc2EsIElyaXMtdmVyc2ljb2xvciwgSXJpcy12aXJn -aW5pY2FxNollZXNoG0sBdVgKAAAAYXR0cmlidXRlc3E3KFgMAAAAc2VwYWwgbGVuZ3RocThLAoZx -OVgLAAAAc2VwYWwgd2lkdGhxOksChnE7WAwAAABwZXRhbCBsZW5ndGhxPEsChnE9WAsAAABwZXRh -bCB3aWR0aHE+SwKGcT90cUBYBQAAAG1ldGFzcUEpWAoAAABjbGFzc192YXJzcUJYBAAAAGlyaXNx -Q11xRChYCwAAAElyaXMtc2V0b3NhcUVYDwAAAElyaXMtdmVyc2ljb2xvcnFGWA4AAABJcmlzLXZp -cmdpbmljYXFHZYZxSIVxSVgSAAAAbW9kaWZpZWRfdmFyaWFibGVzcUpdcUt1YmF1Lg== - - gAN9cQAoWAsAAABhdXRvX2NvbW1pdHEBiFgOAAAAY29sb3JfYnlfY2xhc3NxAohYEgAAAGNvbnRy -b2xBcmVhVmlzaWJsZXEDiFgOAAAAZGlzdF9jb2xvcl9SR0JxBChL3EvcS9xL/3RxBVgTAAAAc2F2 -ZWRXaWRnZXRHZW9tZXRyeXEGTlgLAAAAc2VsZWN0X3Jvd3NxB4hYFQAAAHNob3dfYXR0cmlidXRl -X2xhYmVsc3EIiFgSAAAAc2hvd19kaXN0cmlidXRpb25zcQmJWAsAAABfX3ZlcnNpb25fX3EKSwFY -EAAAAGNvbnRleHRfc2V0dGluZ3NxC11xDGNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0 -CnENKYFxDn1xDyhYBgAAAHZhbHVlc3EQfXERKFgNAAAAc2VsZWN0ZWRfY29sc3ESXXETWA0AAABz -ZWxlY3RlZF9yb3dzcRRdcRVoCksBdVgFAAAAbWV0YXNxFn1xF1gOAAAAb3JkZXJlZF9kb21haW5x -GF1xGShYDAAAAHNlcGFsIGxlbmd0aHEaSwKGcRtYCwAAAHNlcGFsIHdpZHRocRxLAoZxHVgMAAAA -cGV0YWwgbGVuZ3RocR5LAoZxH1gLAAAAcGV0YWwgd2lkdGhxIEsChnEhWAQAAABpcmlzcSJLAYZx -I2VYCgAAAGF0dHJpYnV0ZXNxJH1xJShoIEsCaBpLAmgeSwJoIl1xJihYCwAAAElyaXMtc2V0b3Nh -cSdYDwAAAElyaXMtdmVyc2ljb2xvcnEoWA4AAABJcmlzLXZpcmdpbmljYXEpZWgcSwJ1WAQAAAB0 -aW1lcSpHQdanFgy9pBZ1YmF1Lg== + gASVuAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDLgHZ0MsAAQAAAAACEwAAATwAAAOqAAACNQAAAhMAAAFSAAADqgAAAjUAAAAAAACUjAtz +aGVldF9uYW1lc5R9lIwGc291cmNllEsAjAN1cmyUaBCMDWRvbWFpbl9lZGl0b3KUfZSMC19fdmVy +c2lvbl9flEsBjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdD +b250ZXh0lJOUKYGUfZQojAR0aW1llEdB1qcWDLpbX4wGdmFsdWVzlH2UKIwJdmFyaWFibGVzlF2U +aBt9lGgoXZQoXZQojAxzZXBhbCBsZW5ndGiUjBRPcmFuZ2UuZGF0YS52YXJpYWJsZZSMEkNvbnRp +bnVvdXNWYXJpYWJsZZSTlEsAaBCIZV2UKIwLc2VwYWwgd2lkdGiUaDBLAGgQiGVdlCiMDHBldGFs +IGxlbmd0aJRoMEsAaBCIZV2UKIwLcGV0YWwgd2lkdGiUaDBLAGgQiGVdlCiMBGlyaXOUaC6MEERp +c2NyZXRlVmFyaWFibGWUk5RLAYwsSXJpcy1zZXRvc2EsIElyaXMtdmVyc2ljb2xvciwgSXJpcy12 +aXJnaW5pY2GUiWVlc2gdSwF1jAphdHRyaWJ1dGVzlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBh +bCB3aWR0aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlHSUjAVtZXRh +c5QpjApjbGFzc192YXJzlIwEaXJpc5RdlCiMC0lyaXMtc2V0b3NhlIwPSXJpcy12ZXJzaWNvbG9y +lIwOSXJpcy12aXJnaW5pY2GUZYaUhZSMEm1vZGlmaWVkX3ZhcmlhYmxlc5RdlHViYXUu + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, '__version__': 1} + + + diff --git a/Orange/canvas/workflows/120-scatterplot-data-table.ows b/Orange/canvas/workflows/120-scatterplot-data-table.ows index 780554dcc49..308c9dafdd1 100644 --- a/Orange/canvas/workflows/120-scatterplot-data-table.ows +++ b/Orange/canvas/workflows/120-scatterplot-data-table.ows @@ -1,82 +1,62 @@ - + - - - + + + - - + + - This File widget is set to read the Iris dataset. Double click on the icon to change the input data file and observe how this workflow works for some other datasets such as housing or auto-mpg. - Double click on the Scatter Plot icon to visualize the data. Then select the data subset by selecting the points from the scatter plot. - Data Table widget shows the data subset selected in the Scatter Plot. - - - - Try to connect some other widget to the output of the Scatter Plot. Say, a Box Plot widget (toolbox, Visualize pane). Box Plot will display distributions of the data subset selected in the Scatter Plot. - + This File widget is set to read the Iris dataset. Double click on the icon to change the input data file and observe how this workflow works for some other datasets such as housing or auto-mpg. + Double click on the Scatter Plot icon to visualize the data. Then select the data subset by selecting the points from the scatter plot. + Data Table widget shows the data subset selected in the Scatter Plot. + + + + Try to connect some other widget to the output of the Scatter Plot. Say, a Box Plot widget (toolbox, Visualize pane). Box Plot will display distributions of the data subset selected in the Scatter Plot. + - gAN9cQAoWBIAAABjb250cm9sQXJlYVZpc2libGVxAYhYDAAAAHJlY2VudF9wYXRoc3ECXXEDY09y -YW5nZS53aWRnZXRzLnV0aWxzLmZpbGVkaWFsb2dzClJlY2VudFBhdGgKcQQpgXEFfXEGKFgHAAAA -YWJzcGF0aHEHWDAAAAAvVXNlcnMvYW56ZS9kZXYvb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJp -cy50YWJxCFgGAAAAcHJlZml4cQlYDwAAAHNhbXBsZS1kYXRhc2V0c3EKWAcAAAByZWxwYXRocQtY -CAAAAGlyaXMudGFicQxYBQAAAHRpdGxlcQ1YAAAAAHEOWAUAAABzaGVldHEPaA5YCwAAAGZpbGVf -Zm9ybWF0cRBOdWJhWAsAAAByZWNlbnRfdXJsc3ERXXESWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5 -cRNDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWgcRRY -CwAAAHNoZWV0X25hbWVzcRV9cRZYBgAAAHNvdXJjZXEXSwBYAwAAAHVybHEYaA5YDQAAAGRvbWFp -bl9lZGl0b3JxGX1xGlgLAAAAX192ZXJzaW9uX19xG0sBWBAAAABjb250ZXh0X3NldHRpbmdzcRxd -cR1jT3JhbmdlLndpZGdldHMuc2V0dGluZ3MKQ29udGV4dApxHimBcR99cSAoWAQAAAB0aW1lcSFH -QdanFiOv2ZBYBgAAAHZhbHVlc3EifXEjKFgJAAAAdmFyaWFibGVzcSRdcSVYCQAAAHhsc19zaGVl -dHEmaA5K/////4ZxJ2gZfXEoaCRdcSkoXXEqKFgMAAAAc2VwYWwgbGVuZ3RocStjT3JhbmdlLmRh -dGEudmFyaWFibGUKQ29udGludW91c1ZhcmlhYmxlCnEsSwBoDohlXXEtKFgLAAAAc2VwYWwgd2lk -dGhxLmgsSwBoDohlXXEvKFgMAAAAcGV0YWwgbGVuZ3RocTBoLEsAaA6IZV1xMShYCwAAAHBldGFs -IHdpZHRocTJoLEsAaA6IZV1xMyhYBAAAAGlyaXNxNGNPcmFuZ2UuZGF0YS52YXJpYWJsZQpEaXNj -cmV0ZVZhcmlhYmxlCnE1SwFYLAAAAElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIElyaXMt -dmlyZ2luaWNhcTaJZWVzaBtLAXVYCgAAAGF0dHJpYnV0ZXNxNyhYDAAAAHNlcGFsIGxlbmd0aHE4 -SwKGcTlYCwAAAHNlcGFsIHdpZHRocTpLAoZxO1gMAAAAcGV0YWwgbGVuZ3RocTxLAoZxPVgLAAAA -cGV0YWwgd2lkdGhxPksChnE/dHFAWAUAAABtZXRhc3FBKVgKAAAAY2xhc3NfdmFyc3FCWAQAAABp -cmlzcUNdcUQoWAsAAABJcmlzLXNldG9zYXFFWA8AAABJcmlzLXZlcnNpY29sb3JxRlgOAAAASXJp -cy12aXJnaW5pY2FxR2WGcUiFcUlYEgAAAG1vZGlmaWVkX3ZhcmlhYmxlc3FKXXFLdWJhdS4= + gASVvAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWg +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwEdGltZZRHQdanFiOv2ZCMBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoKF2UKF2UKIwMc2VwYWwgbGVuZ3RolIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJD +b250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGgwSwBoEIhlXZQojAxw +ZXRhbCBsZW5ndGiUaDBLAGgQiGVdlCiMC3BldGFsIHdpZHRolGgwSwBoEIhlXZQojARpcmlzlGgu +jBBEaXNjcmV0ZVZhcmlhYmxllJOUSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIEly +aXMtdmlyZ2luaWNhlIllZXNoHUsBdYwKYXR0cmlidXRlc5QojAxzZXBhbCBsZW5ndGiUSwKGlIwL +c2VwYWwgd2lkdGiUSwKGlIwMcGV0YWwgbGVuZ3RolEsChpSMC3BldGFsIHdpZHRolEsChpR0lIwF +bWV0YXOUKYwKY2xhc3NfdmFyc5SMBGlyaXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMtdmVyc2lj +b2xvcpSMDklyaXMtdmlyZ2luaWNhlGWGlIWUjBJtb2RpZmllZF92YXJpYWJsZXOUXZR1YmF1Lg== - gAN9cQAoWAsAAABhdXRvX3NhbXBsZXEBiFgTAAAAYXV0b19zZW5kX3NlbGVjdGlvbnECiFgSAAAA -Y29udHJvbEFyZWFWaXNpYmxlcQOIWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cQRDLgHZ0MsAAQAA -AAADrQAAAawAAAZsAAAEewAAA60AAAHCAAAGbAAABHsAAAAAAABxBVgPAAAAc2VsZWN0aW9uX2dy -b3VwcQZOWBEAAAB0b29sYmFyX3NlbGVjdGlvbnEHSwBYBQAAAGdyYXBocQh9cQkoWAsAAABhbHBo -YV92YWx1ZXEKS4BYDQAAAGNsYXNzX2RlbnNpdHlxC4lYEQAAAGppdHRlcl9jb250aW51b3VzcQyJ -WAsAAABqaXR0ZXJfc2l6ZXENSwpYEwAAAGxhYmVsX29ubHlfc2VsZWN0ZWRxDolYCwAAAHBvaW50 -X3dpZHRocQ9LClgJAAAAc2hvd19ncmlkcRCJWAsAAABzaG93X2xlZ2VuZHERiFgNAAAAc2hvd19y -ZWdfbGluZXESiVgRAAAAdG9vbHRpcF9zaG93c19hbGxxE4l1WAsAAABfX3ZlcnNpb25fX3EUSwJY -EAAAAGNvbnRleHRfc2V0dGluZ3NxFV1xFmNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0 -CnEXKYFxGH1xGShYCgAAAGF0dHJpYnV0ZXNxGn1xGyhYCwAAAHNlcGFsIHdpZHRocRxLAlgEAAAA -aXJpc3EdSwFYCwAAAHBldGFsIHdpZHRocR5LAlgMAAAAc2VwYWwgbGVuZ3RocR9LAlgMAAAAcGV0 -YWwgbGVuZ3RocSBLAnVYBAAAAHRpbWVxIUdB1qcWI7zvG1gFAAAAbWV0YXNxIn1xI1gGAAAAdmFs -dWVzcSR9cSUoWAYAAABhdHRyX3hxJlgMAAAAc2VwYWwgbGVuZ3RocSdLZoZxKFgGAAAAYXR0cl95 -cSlYCwAAAHNlcGFsIHdpZHRocSpLZoZxK2gIfXEsKFgKAAAAYXR0cl9jb2xvcnEtWAQAAABpcmlz -cS5LZYZxL1gKAAAAYXR0cl9sYWJlbHEwTkr+////hnExWAoAAABhdHRyX3NoYXBlcTJOSv7///+G -cTNYCQAAAGF0dHJfc2l6ZXE0Tkr+////hnE1dWgUSwJ1WA4AAABvcmRlcmVkX2RvbWFpbnE2XXE3 -KGgfSwKGcThoHEsChnE5aCBLAoZxOmgeSwKGcTtoHUsBhnE8ZXViYXUu - - gAN9cQAoWAsAAABhdXRvX2NvbW1pdHEBiFgOAAAAY29sb3JfYnlfY2xhc3NxAohYEgAAAGNvbnRy -b2xBcmVhVmlzaWJsZXEDiFgOAAAAZGlzdF9jb2xvcl9SR0JxBChL3EvcS9xL/3RxBVgTAAAAc2F2 -ZWRXaWRnZXRHZW9tZXRyeXEGTlgLAAAAc2VsZWN0X3Jvd3NxB4hYFQAAAHNob3dfYXR0cmlidXRl -X2xhYmVsc3EIiFgSAAAAc2hvd19kaXN0cmlidXRpb25zcQmJWAsAAABfX3ZlcnNpb25fX3EKSwFY -EAAAAGNvbnRleHRfc2V0dGluZ3NxC11xDGNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0 -CnENKYFxDn1xDyhYCgAAAGF0dHJpYnV0ZXNxEH1xEShYCwAAAHNlcGFsIHdpZHRocRJLAlgEAAAA -aXJpc3ETXXEUKFgLAAAASXJpcy1zZXRvc2FxFVgPAAAASXJpcy12ZXJzaWNvbG9ycRZYDgAAAEly -aXMtdmlyZ2luaWNhcRdlWAsAAABwZXRhbCB3aWR0aHEYSwJYDAAAAHNlcGFsIGxlbmd0aHEZSwJY -DAAAAHBldGFsIGxlbmd0aHEaSwJ1WAQAAAB0aW1lcRtHQdXgmRCzIppYBQAAAG1ldGFzcRx9cR1Y -BgAAAHZhbHVlc3EefXEfKFgOAAAAZGlzdF9jb2xvcl9SR0JxIGgFSv7///+GcSFYEwAAAHNhdmVk -V2lkZ2V0R2VvbWV0cnlxIk5K/v///4ZxI1gLAAAAc2VsZWN0X3Jvd3NxJIhK/v///4ZxJVgLAAAA -YXV0b19jb21taXRxJohK/v///4ZxJ1gNAAAAc2VsZWN0ZWRfcm93c3EoXXEpWA4AAABjb2xvcl9i -eV9jbGFzc3EqiEr+////hnErWA0AAABzZWxlY3RlZF9jb2xzcSxdcS1YEgAAAHNob3dfZGlzdHJp -YnV0aW9uc3EuiUr+////hnEvWBUAAABzaG93X2F0dHJpYnV0ZV9sYWJlbHNxMIhK/v///4ZxMWgK -SwF1WA4AAABvcmRlcmVkX2RvbWFpbnEyXXEzKGgZSwKGcTRoEksChnE1aBpLAoZxNmgYSwKGcTdo -E0sBhnE4ZXViYXUu + gASVEwMAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAOtAAABrAAABmwAAAR7 +AAADrQAAAcIAAAZsAAAEewAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiYwRaml0dGVyX2NvbnRpbnVvdXOUiYwLaml0dGVyX3NpemWUSwqME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOU +KYGUfZQojAphdHRyaWJ1dGVzlH2UKIwLc2VwYWwgd2lkdGiUSwKMBGlyaXOUSwGMC3BldGFsIHdp +ZHRolEsCjAxzZXBhbCBsZW5ndGiUSwKMDHBldGFsIGxlbmd0aJRLAnWMBHRpbWWUR0HWpxYjvO8b +jAVtZXRhc5R9lIwGdmFsdWVzlH2UKIwKYXR0cl9jb2xvcpSMBGlyaXOUS2WGlIwKYXR0cl9sYWJl +bJROSv7///+GlIwKYXR0cl9zaGFwZZROSv7///+GlIwJYXR0cl9zaXpllE5K/v///4aUjAZhdHRy +X3iUjAxzZXBhbCBsZW5ndGiUS2aGlIwGYXR0cl95lIwLc2VwYWwgd2lkdGiUS2aGlGgKfZRoFksF +dYwOb3JkZXJlZF9kb21haW6UXZQoaCNLAoaUaCBLAoaUaCRLAoaUaCJLAoaUaCFLAYaUZXViYXUu + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, '__version__': 1} + + + diff --git a/Orange/canvas/workflows/130-scatterplot-visualize-subset.ows b/Orange/canvas/workflows/130-scatterplot-visualize-subset.ows index 9b9fe639928..80af39cb2bf 100644 --- a/Orange/canvas/workflows/130-scatterplot-visualize-subset.ows +++ b/Orange/canvas/workflows/130-scatterplot-visualize-subset.ows @@ -1,77 +1,61 @@ - + - - - + + + - - - + + + - (1) Open the Data Table and select a data instance or a subset of instances (use shift key). - (2) Open the Scatter Plot to observe the selected subset from Data Table. - Double click on this channel to check that data from Data Table is indeed fed as a data subset. - - - + (1) Open the Data Table and select a data instance or a subset of instances (use shift key). + (2) Open the Scatter Plot to observe the selected subset from Data Table. + Double click on this channel to check that data from Data Table is indeed fed as a data subset. + + + - gAN9cQAoWBIAAABjb250cm9sQXJlYVZpc2libGVxAYhYDAAAAHJlY2VudF9wYXRoc3ECXXEDY09y -YW5nZS53aWRnZXRzLnV0aWxzLmZpbGVkaWFsb2dzClJlY2VudFBhdGgKcQQpgXEFfXEGKFgHAAAA -YWJzcGF0aHEHWDAAAAAvVXNlcnMvYW56ZS9kZXYvb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJp -cy50YWJxCFgGAAAAcHJlZml4cQlYDwAAAHNhbXBsZS1kYXRhc2V0c3EKWAcAAAByZWxwYXRocQtY -CAAAAGlyaXMudGFicQxYBQAAAHRpdGxlcQ1YAAAAAHEOWAUAAABzaGVldHEPaA5YCwAAAGZpbGVf -Zm9ybWF0cRBOdWJhWAsAAAByZWNlbnRfdXJsc3ERXXESWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5 -cRNDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWgcRRY -CwAAAHNoZWV0X25hbWVzcRV9cRZYBgAAAHNvdXJjZXEXSwBYAwAAAHVybHEYaA5YDQAAAGRvbWFp -bl9lZGl0b3JxGX1xGlgLAAAAX192ZXJzaW9uX19xG0sBWBAAAABjb250ZXh0X3NldHRpbmdzcRxd -cR1jT3JhbmdlLndpZGdldHMuc2V0dGluZ3MKQ29udGV4dApxHimBcR99cSAoWAQAAAB0aW1lcSFH -QdanFiuwZGJYBgAAAHZhbHVlc3EifXEjKFgJAAAAdmFyaWFibGVzcSRdcSVYCQAAAHhsc19zaGVl -dHEmaA5K/////4ZxJ2gZfXEoaCRdcSkoXXEqKFgMAAAAc2VwYWwgbGVuZ3RocStjT3JhbmdlLmRh -dGEudmFyaWFibGUKQ29udGludW91c1ZhcmlhYmxlCnEsSwBoDohlXXEtKFgLAAAAc2VwYWwgd2lk -dGhxLmgsSwBoDohlXXEvKFgMAAAAcGV0YWwgbGVuZ3RocTBoLEsAaA6IZV1xMShYCwAAAHBldGFs -IHdpZHRocTJoLEsAaA6IZV1xMyhYBAAAAGlyaXNxNGNPcmFuZ2UuZGF0YS52YXJpYWJsZQpEaXNj -cmV0ZVZhcmlhYmxlCnE1SwFYLAAAAElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIElyaXMt -dmlyZ2luaWNhcTaJZWVzaBtLAXVYCgAAAGF0dHJpYnV0ZXNxNyhYDAAAAHNlcGFsIGxlbmd0aHE4 -SwKGcTlYCwAAAHNlcGFsIHdpZHRocTpLAoZxO1gMAAAAcGV0YWwgbGVuZ3RocTxLAoZxPVgLAAAA -cGV0YWwgd2lkdGhxPksChnE/dHFAWAUAAABtZXRhc3FBKVgKAAAAY2xhc3NfdmFyc3FCWAQAAABp -cmlzcUNdcUQoWAsAAABJcmlzLXNldG9zYXFFWA8AAABJcmlzLXZlcnNpY29sb3JxRlgOAAAASXJp -cy12aXJnaW5pY2FxR2WGcUiFcUlYEgAAAG1vZGlmaWVkX3ZhcmlhYmxlc3FKXXFLdWJhdS4= + gASVvAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWg +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwEdGltZZRHQdanFiuwZGKMBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoKF2UKF2UKIwMc2VwYWwgbGVuZ3RolIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJD +b250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGgwSwBoEIhlXZQojAxw +ZXRhbCBsZW5ndGiUaDBLAGgQiGVdlCiMC3BldGFsIHdpZHRolGgwSwBoEIhlXZQojARpcmlzlGgu +jBBEaXNjcmV0ZVZhcmlhYmxllJOUSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIEly +aXMtdmlyZ2luaWNhlIllZXNoHUsBdYwKYXR0cmlidXRlc5QojAxzZXBhbCBsZW5ndGiUSwKGlIwL +c2VwYWwgd2lkdGiUSwKGlIwMcGV0YWwgbGVuZ3RolEsChpSMC3BldGFsIHdpZHRolEsChpR0lIwF +bWV0YXOUKYwKY2xhc3NfdmFyc5SMBGlyaXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMtdmVyc2lj +b2xvcpSMDklyaXMtdmlyZ2luaWNhlGWGlIWUjBJtb2RpZmllZF92YXJpYWJsZXOUXZR1YmF1Lg== - gAN9cQAoWAsAAABhdXRvX3NhbXBsZXEBiFgTAAAAYXV0b19zZW5kX3NlbGVjdGlvbnECiFgSAAAA -Y29udHJvbEFyZWFWaXNpYmxlcQOIWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cQRDLgHZ0MsAAQAA -AAADrQAAAawAAAZsAAAEewAAA60AAAHCAAAGbAAABHsAAAAAAABxBVgPAAAAc2VsZWN0aW9uX2dy -b3VwcQZOWBEAAAB0b29sYmFyX3NlbGVjdGlvbnEHSwBYBQAAAGdyYXBocQh9cQkoWAsAAABhbHBo -YV92YWx1ZXEKS4BYDQAAAGNsYXNzX2RlbnNpdHlxC4lYEQAAAGppdHRlcl9jb250aW51b3VzcQyJ -WAsAAABqaXR0ZXJfc2l6ZXENSwpYEwAAAGxhYmVsX29ubHlfc2VsZWN0ZWRxDolYCwAAAHBvaW50 -X3dpZHRocQ9LClgJAAAAc2hvd19ncmlkcRCJWAsAAABzaG93X2xlZ2VuZHERiFgNAAAAc2hvd19y -ZWdfbGluZXESiVgRAAAAdG9vbHRpcF9zaG93c19hbGxxE4l1WAsAAABfX3ZlcnNpb25fX3EUSwJY -EAAAAGNvbnRleHRfc2V0dGluZ3NxFV1xFmNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0 -CnEXKYFxGH1xGShYDgAAAG9yZGVyZWRfZG9tYWlucRpdcRsoWAwAAABzZXBhbCBsZW5ndGhxHEsC -hnEdWAsAAABzZXBhbCB3aWR0aHEeSwKGcR9YDAAAAHBldGFsIGxlbmd0aHEgSwKGcSFYCwAAAHBl -dGFsIHdpZHRocSJLAoZxI1gEAAAAaXJpc3EkSwGGcSVlWAUAAABtZXRhc3EmfXEnWAoAAABhdHRy -aWJ1dGVzcSh9cSkoaCJLAmgcSwJoJEsBaCBLAmgeSwJ1WAYAAAB2YWx1ZXNxKn1xKyhYBgAAAGF0 -dHJfeHEsWAwAAABzZXBhbCBsZW5ndGhxLUtmhnEuWAYAAABhdHRyX3lxL1gLAAAAc2VwYWwgd2lk -dGhxMEtmhnExaAh9cTIoWAoAAABhdHRyX2NvbG9ycTNYBAAAAGlyaXNxNEtlhnE1WAoAAABhdHRy -X2xhYmVscTZOSv7///+GcTdYCgAAAGF0dHJfc2hhcGVxOE5K/v///4ZxOVgJAAAAYXR0cl9zaXpl -cTpOSv7///+GcTt1aBRLAnVYBAAAAHRpbWVxPEdB1qcWK8W1+nViYXUu - - gAN9cQAoWAsAAABhdXRvX2NvbW1pdHEBiFgOAAAAY29sb3JfYnlfY2xhc3NxAohYEgAAAGNvbnRy -b2xBcmVhVmlzaWJsZXEDiFgOAAAAZGlzdF9jb2xvcl9SR0JxBChL3EvcS9xL/3RxBVgTAAAAc2F2 -ZWRXaWRnZXRHZW9tZXRyeXEGTlgLAAAAc2VsZWN0X3Jvd3NxB4hYFQAAAHNob3dfYXR0cmlidXRl -X2xhYmVsc3EIiFgSAAAAc2hvd19kaXN0cmlidXRpb25zcQmJWAsAAABfX3ZlcnNpb25fX3EKSwFY -EAAAAGNvbnRleHRfc2V0dGluZ3NxC11xDGNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0 -CnENKYFxDn1xDyhYDgAAAG9yZGVyZWRfZG9tYWlucRBdcREoWAwAAABzZXBhbCBsZW5ndGhxEksC -hnETWAsAAABzZXBhbCB3aWR0aHEUSwKGcRVYDAAAAHBldGFsIGxlbmd0aHEWSwKGcRdYCwAAAHBl -dGFsIHdpZHRocRhLAoZxGVgEAAAAaXJpc3EaSwGGcRtlWAUAAABtZXRhc3EcfXEdWAoAAABhdHRy -aWJ1dGVzcR59cR8oaBhLAmgSSwJoGl1xIChYCwAAAElyaXMtc2V0b3NhcSFYDwAAAElyaXMtdmVy -c2ljb2xvcnEiWA4AAABJcmlzLXZpcmdpbmljYXEjZWgWSwJoFEsCdVgGAAAAdmFsdWVzcSR9cSUo -WA0AAABzZWxlY3RlZF9jb2xzcSZdcSdYDQAAAHNlbGVjdGVkX3Jvd3NxKF1xKWgKSwF1WAQAAAB0 -aW1lcSpHQdanFiu+nL91YmF1Lg== + gASVEwMAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAOtAAABrAAABmwAAAR7 +AAADrQAAAcIAAAZsAAAEewAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiYwRaml0dGVyX2NvbnRpbnVvdXOUiYwLaml0dGVyX3NpemWUSwqME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOU +KYGUfZQojA5vcmRlcmVkX2RvbWFpbpRdlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBhbCB3aWR0 +aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlIwEaXJpc5RLAYaUZYwF +bWV0YXOUfZSMCmF0dHJpYnV0ZXOUfZQoaCZLAmggSwJoKEsBaCRLAmgiSwJ1jAZ2YWx1ZXOUfZQo +jAphdHRyX2NvbG9ylIwEaXJpc5RLZYaUjAphdHRyX2xhYmVslE5K/v///4aUjAphdHRyX3NoYXBl +lE5K/v///4aUjAlhdHRyX3NpemWUTkr+////hpSMBmF0dHJfeJSMDHNlcGFsIGxlbmd0aJRLZoaU +jAZhdHRyX3mUjAtzZXBhbCB3aWR0aJRLZoaUaAp9lGgWSwV1jAR0aW1llEdB1qcWK8W1+nViYXUu + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, '__version__': 1} + + + diff --git a/Orange/canvas/workflows/250-tree-scatterplot.ows b/Orange/canvas/workflows/250-tree-scatterplot.ows index 59838757e94..5cb98b202ee 100644 --- a/Orange/canvas/workflows/250-tree-scatterplot.ows +++ b/Orange/canvas/workflows/250-tree-scatterplot.ows @@ -1,94 +1,100 @@ - + - - - - - + + + + + - - - - - + + + + + - - Load data on Iris ("iris.tab") from preloaded documentation datasets. - - - Any change in selection of the tree node changes the rendering in the scatter plot. - - Double-click on this widget and select any node in the tree. - - The data selected in the tree viewer propagates to all the downstream widgets in the workflow. - This workflow works best if you have Tree Viewer, Scatter Plot and Box Plot all open at the same time. + + Load data on Iris ("iris.tab") from preloaded documentation datasets. + + + Any change in selection of the tree node changes the rendering in the scatter plot. + + Double-click on this widget and select any node in the tree. + + The data selected in the tree viewer propagates to all the downstream widgets in the workflow. + This workflow works best if you have Tree Viewer, Scatter Plot and Box Plot all open at the same time. - gAN9cQAoWAsAAABfX3ZlcnNpb25fX3EBSwFYBgAAAHNvdXJjZXECSwBYDAAAAHJlY2VudF9wYXRo -c3EDXXEEKGNPcmFuZ2Uud2lkZ2V0cy51dGlscy5maWxlZGlhbG9ncwpSZWNlbnRQYXRoCnEFKYFx -Bn1xByhYBgAAAHByZWZpeHEIWA8AAABzYW1wbGUtZGF0YXNldHNxCVgFAAAAc2hlZXRxClgAAAAA -cQtYBwAAAGFic3BhdGhxDFgzAAAAL1VzZXJzL2FqZGEvb3JhbmdlL29yYW5nZTMvT3JhbmdlL2Rh -dGFzZXRzL2lyaXMudGFicQ1YBwAAAHJlbHBhdGhxDlgIAAAAaXJpcy50YWJxD1gFAAAAdGl0bGVx -EGgLdWJoBSmBcRF9cRIoaAhoCWgKaAtoDFg2AAAAL1VzZXJzL2FqZGEvb3JhbmdlL29yYW5nZTMv -T3JhbmdlL2RhdGFzZXRzL3RpdGFuaWMudGFicRNoDlgLAAAAdGl0YW5pYy50YWJxFGgQaAt1YmgF -KYFxFX1xFihoCGgJaApoC2gMWDYAAAAvVXNlcnMvYWpkYS9vcmFuZ2Uvb3JhbmdlMy9PcmFuZ2Uv -ZGF0YXNldHMvaG91c2luZy50YWJxF2gOWAsAAABob3VzaW5nLnRhYnEYaBBoC3ViaAUpgXEZfXEa -KGgIaAloCmgLaAxYPAAAAC9Vc2Vycy9hamRhL29yYW5nZS9vcmFuZ2UzL09yYW5nZS9kYXRhc2V0 -cy9oZWFydF9kaXNlYXNlLnRhYnEbaA5YEQAAAGhlYXJ0X2Rpc2Vhc2UudGFicRxoEGgLdWJlWBAA -AABjb250ZXh0X3NldHRpbmdzcR1dcR5jT3JhbmdlLndpZGdldHMuc2V0dGluZ3MKQ29udGV4dApx -HymBcSB9cSEoWAUAAABtZXRhc3EiKVgGAAAAdmFsdWVzcSN9cSQoWAkAAAB4bHNfc2hlZXRxJWgL -Sv////+GcSZYDQAAAGRvbWFpbl9lZGl0b3JxJ31xKFgJAAAAdmFyaWFibGVzcSldcSooXXErKFgM -AAAAc2VwYWwgbGVuZ3RocSxjT3JhbmdlLmRhdGEudmFyaWFibGUKQ29udGludW91c1ZhcmlhYmxl -CnEtSwBoC4hlXXEuKFgLAAAAc2VwYWwgd2lkdGhxL2gtSwBoC4hlXXEwKFgMAAAAcGV0YWwgbGVu -Z3RocTFoLUsAaAuIZV1xMihYCwAAAHBldGFsIHdpZHRocTNoLUsAaAuIZV1xNChYBAAAAGlyaXNx -NWNPcmFuZ2UuZGF0YS52YXJpYWJsZQpEaXNjcmV0ZVZhcmlhYmxlCnE2SwFYLAAAAElyaXMtc2V0 -b3NhLCBJcmlzLXZlcnNpY29sb3IsIElyaXMtdmlyZ2luaWNhcTeJZWVzaAFLAWgpXXE4dVgKAAAA -YXR0cmlidXRlc3E5KGgsSwKGcTpoL0sChnE7aDFLAoZxPGgzSwKGcT10cT5YDgAAAG9yZGVyZWRf -ZG9tYWlucT9dcUAoaCxLAoZxQWgvSwKGcUJoMUsChnFDaDNLAoZxRGg1SwGGcUVlWAQAAAB0aW1l -cUZHQdYqsOiB0RhYEgAAAG1vZGlmaWVkX3ZhcmlhYmxlc3FHXXFIWAoAAABjbGFzc192YXJzcUlo -NUsBhnFKhXFLdWJhWAsAAABzaGVldF9uYW1lc3FMfXFNaCd9cU5YCwAAAHJlY2VudF91cmxzcU9d -cVBYEwAAAHNhdmVkV2lkZ2V0R2VvbWV0cnlxUUMyAdnQywACAAAAAAISAAAAtQAABGkAAALwAAAC -EgAAAMsAAARpAAAC8AAAAAAAAAAABpBxUlgDAAAAdXJscVNoC3Uu + gASVOgYAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZQojB5v +cmFuZ2V3aWRnZXQudXRpbHMuZmlsZWRpYWxvZ3OUjApSZWNlbnRQYXRolJOUKYGUfZQojAdhYnNw +YXRolIwtL1VzZXJzL2phbmV6L29yYW5nZTMvT3JhbmdlL2RhdGFzZXRzL2lyaXMudGFilIwGcHJl +Zml4lIwPc2FtcGxlLWRhdGFzZXRzlIwHcmVscGF0aJSMCGlyaXMudGFilIwFdGl0bGWUjACUjAVz +aGVldJRoEIwLZmlsZV9mb3JtYXSUTnViaAYpgZR9lChoCYwwL1VzZXJzL2phbmV6L29yYW5nZTMv +T3JhbmdlL2RhdGFzZXRzL3RpdGFuaWMudGFilGgLaAxoDYwLdGl0YW5pYy50YWKUaA9oEGgRaBBo +Ek51YmgGKYGUfZQoaAmMMC9Vc2Vycy9qYW5lei9vcmFuZ2UzL09yYW5nZS9kYXRhc2V0cy9ob3Vz +aW5nLnRhYpRoC2gMaA2MC2hvdXNpbmcudGFilGgPaBBoEWgQaBJOdWJoBimBlH2UKGgJjDYvVXNl +cnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaGVhcnRfZGlzZWFzZS50YWKUaAtoDGgN +jBFoZWFydF9kaXNlYXNlLnRhYpRoD2gQaBFoEGgSTnViZYwLcmVjZW50X3VybHOUXZSME3NhdmVk +V2lkZ2V0R2VvbWV0cnmUQzIB2dDLAAIAAAAAAhIAAAC1AAAEaQAAAvAAAAISAAAAywAABGkAAALw +AAAAAAAAAAAGkJSMC3NoZWV0X25hbWVzlH2UjAZzb3VyY2WUSwCMA3VybJRoEIwNZG9tYWluX2Vk +aXRvcpR9lIwLX192ZXJzaW9uX1+USwGMEGNvbnRleHRfc2V0dGluZ3OUXZQojBVvcmFuZ2V3aWRn +ZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOUKYGUfZQojAZ2YWx1ZXOUfZQojAl2YXJpYWJsZXOUXZRo +J32UaDNdlChdlCiMDHNlcGFsIGxlbmd0aJSMFE9yYW5nZS5kYXRhLnZhcmlhYmxllIwSQ29udGlu +dW91c1ZhcmlhYmxllJOUSwBoEIhlXZQojAtzZXBhbCB3aWR0aJRoO0sAaBCIZV2UKIwMcGV0YWwg +bGVuZ3RolGg7SwBoEIhlXZQojAtwZXRhbCB3aWR0aJRoO0sAaBCIZV2UKIwEaXJpc5RoOYwQRGlz +Y3JldGVWYXJpYWJsZZSTlEsBjCxJcmlzLXNldG9zYSwgSXJpcy12ZXJzaWNvbG9yLCBJcmlzLXZp +cmdpbmljYZSJZWVzaClLAXWMCmF0dHJpYnV0ZXOUKIwMc2VwYWwgbGVuZ3RolEsChpSMC3NlcGFs +IHdpZHRolEsChpSMDHBldGFsIGxlbmd0aJRLAoaUjAtwZXRhbCB3aWR0aJRLAoaUdJSMBW1ldGFz +lCmMCmNsYXNzX3ZhcnOUjARpcmlzlF2UKIwLSXJpcy1zZXRvc2GUjA9JcmlzLXZlcnNpY29sb3KU +jA5JcmlzLXZpcmdpbmljYZRlhpSFlIwSbW9kaWZpZWRfdmFyaWFibGVzlF2UdWJoLimBlH2UKGhR +KWgxfZQojAl4bHNfc2hlZXSUaBBK/////4aUjA1kb21haW5fZWRpdG9ylH2UjAl2YXJpYWJsZXOU +XZQoXZQojAxzZXBhbCBsZW5ndGiUaDtLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGg7SwBoEIhlXZQo +jAxwZXRhbCBsZW5ndGiUaDtLAGgQiGVdlCiMC3BldGFsIHdpZHRolGg7SwBoEIhlXZQojARpcmlz +lGhFSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIElyaXMtdmlyZ2luaWNhlIllZXNo +Y12UaClLAXVoRyhoZksChpRoaEsChpRoaksChpRobEsChpR0lIwOb3JkZXJlZF9kb21haW6UXZQo +aGZLAoaUaGhLAoaUaGpLAoaUaGxLAoaUaG5LAYaUZYwEdGltZZRHQdYqsOiB0RhoWl2UaFJobksB +hpSFlHViZXUu - gAN9cQAoWA4AAABtYXhfbm9kZV93aWR0aHEBS5ZYEQAAAGxpbmVfd2lkdGhfbWV0aG9kcQJLAlgO -AAAAbWF4X3RyZWVfZGVwdGhxA0sAWBEAAAByZWdyZXNzaW9uX2NvbG9yc3EESwBYCwAAAF9fdmVy -c2lvbl9fcQVLAVgQAAAAY29udGV4dF9zZXR0aW5nc3EGXXEHY09yYW5nZS53aWRnZXRzLnNldHRp -bmdzCkNvbnRleHQKcQgpgXEJfXEKKFgGAAAAdmFsdWVzcQt9cQwoWBIAAAB0YXJnZXRfY2xhc3Nf -aW5kZXhxDUsAaAVLAXVYBAAAAHRpbWVxDkdB1iqw6gXzRVgHAAAAY2xhc3Nlc3EPXXEQKFgLAAAA -SXJpcy1zZXRvc2FxEVgPAAAASXJpcy12ZXJzaWNvbG9ycRJYDgAAAElyaXMtdmlyZ2luaWNhcRNl -dWJhWAQAAAB6b29tcRRLBVgTAAAAc2F2ZWRXaWRnZXRHZW9tZXRyeXEVTnUu + gASVawEAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjBFsaW5lX3dpZHRoX21ldGhvZJRL +AowObWF4X25vZGVfd2lkdGiUS5aMDm1heF90cmVlX2RlcHRolEsAjBFyZWdyZXNzaW9uX2NvbG9y +c5RLAIwTc2F2ZWRXaWRnZXRHZW9tZXRyeZROjBFzaG93X2ludGVybWVkaWF0ZZSJjAR6b29tlEsF +jAtfX3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRp +bmdzlIwHQ29udGV4dJSTlCmBlH2UKIwGdmFsdWVzlH2UKIwSdGFyZ2V0X2NsYXNzX2luZGV4lEsA +aAlLAXWMBHRpbWWUR0HWKrDqBfNFjAdjbGFzc2VzlF2UKIwLSXJpcy1zZXRvc2GUjA9JcmlzLXZl +cnNpY29sb3KUjA5JcmlzLXZpcmdpbmljYZRldWJhdS4= - gAN9cQAoWBEAAAB0b29sYmFyX3NlbGVjdGlvbnEBSwBYEwAAAGF1dG9fc2VuZF9zZWxlY3Rpb25x -AohYBQAAAGdyYXBocQN9cQQoWAsAAABhbHBoYV92YWx1ZXEFS4BYDQAAAGNsYXNzX2RlbnNpdHlx -BolYCwAAAGppdHRlcl9zaXplcQdLClgJAAAAc2hvd19ncmlkcQiJWBEAAAB0b29sdGlwX3Nob3dz -X2FsbHEJiVgLAAAAc2hvd19sZWdlbmRxCohYCwAAAHBvaW50X3dpZHRocQtLClgRAAAAaml0dGVy -X2NvbnRpbnVvdXNxDIlYEwAAAGxhYmVsX29ubHlfc2VsZWN0ZWRxDYl1WAsAAABfX3ZlcnNpb25f -X3EOSwFYEAAAAGNvbnRleHRfc2V0dGluZ3NxD11xEGNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpD -b250ZXh0CnERKYFxEn1xEyhYBgAAAHZhbHVlc3EUfXEVKFgGAAAAYXR0cl95cRZYCwAAAHNlcGFs -IHdpZHRocRdLZoZxGGgDfXEZKFgKAAAAYXR0cl9zaGFwZXEaTkr+////hnEbWAkAAABhdHRyX3Np -emVxHE5K/v///4ZxHVgKAAAAYXR0cl9jb2xvcnEeWAQAAABpcmlzcR9LZYZxIFgKAAAAYXR0cl9s -YWJlbHEhTkr+////hnEidVgGAAAAYXR0cl94cSNYDAAAAHNlcGFsIGxlbmd0aHEkS2aGcSVoDksB -dVgOAAAAb3JkZXJlZF9kb21haW5xJl1xJyhoJEsChnEoaBdLAoZxKVgMAAAAcGV0YWwgbGVuZ3Ro -cSpLAoZxK1gLAAAAcGV0YWwgd2lkdGhxLEsChnEtaB9LAYZxLmVYCgAAAGF0dHJpYnV0ZXNxL31x -MChoJEsCaBdLAmgqSwJoLEsCaB9LAXVYBQAAAG1ldGFzcTF9cTJYBAAAAHRpbWVxM0dB1iqw5zw2 -0nViYVgLAAAAYXV0b19zYW1wbGVxNIhYEwAAAHNhdmVkV2lkZ2V0R2VvbWV0cnlxNU51Lg== + gASV4wIAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lE6MCXNlbGVjdGlvbpROjBF0b29sdGlwX3No +b3dzX2FsbJSIjA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CM +DWNsYXNzX2RlbnNpdHmUiYwRaml0dGVyX2NvbnRpbnVvdXOUiYwLaml0dGVyX3NpemWUSwqME2xh +YmVsX29ubHlfc2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0 +aJRLCowJc2hvd19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVy +c2lvbl9flEsFjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdD +b250ZXh0lJOUKYGUfZQojAZ2YWx1ZXOUfZQojAphdHRyX2NvbG9ylIwEaXJpc5RLZYaUjAphdHRy +X2xhYmVslE5K/v///4aUjAphdHRyX3NoYXBllE5K/v///4aUjAlhdHRyX3NpemWUTkr+////hpSM +BmF0dHJfeJSMDHNlcGFsIGxlbmd0aJRLZoaUjAZhdHRyX3mUjAtzZXBhbCB3aWR0aJRLZoaUaAl9 +lGgVSwV1jA5vcmRlcmVkX2RvbWFpbpRdlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBhbCB3aWR0 +aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlIwEaXJpc5RLAYaUZYwK +YXR0cmlidXRlc5R9lChoMUsCaDNLAmg1SwJoN0sCaDlLAXWMBW1ldGFzlH2UjAR0aW1llEdB1iqw +5zw20nViYXUu - gAN9cQAoWAsAAABfX3ZlcnNpb25fX3EBSwFYEwAAAHNhdmVkV2lkZ2V0R2VvbWV0cnlxAk5YCAAA -AHN0YXR0ZXN0cQNLAFgHAAAAY29tcGFyZXEESwJYEwAAAG9yZGVyX2J5X2ltcG9ydGFuY2VxBYlY -CQAAAHN0cmV0Y2hlZHEGiFgQAAAAY29udGV4dF9zZXR0aW5nc3EHXXEIY09yYW5nZS53aWRnZXRz -LnNldHRpbmdzCkNvbnRleHQKcQkpgXEKfXELKFgGAAAAdmFsdWVzcQx9cQ0oaAFLAVgJAAAAZ3Jv -dXBfdmFycQ5YBAAAAGlyaXNxD0tlhnEQWAkAAABhdHRyaWJ1dGVxEVgMAAAAc2VwYWwgbGVuZ3Ro -cRJLZoZxE1gKAAAAY29uZGl0aW9uc3EUXXEVdVgOAAAAb3JkZXJlZF9kb21haW5xFl1xFyhoEksC -hnEYWAsAAABzZXBhbCB3aWR0aHEZSwKGcRpYDAAAAHBldGFsIGxlbmd0aHEbSwKGcRxYCwAAAHBl -dGFsIHdpZHRocR1LAoZxHmgPSwGGcR9lWAoAAABhdHRyaWJ1dGVzcSB9cSEoaBJLAmgZSwJoG0sC -aB1LAmgPSwF1WAUAAABtZXRhc3EifXEjWAQAAAB0aW1lcSRHQdYqsOoVKdx1YmFYDQAAAHNpZ190 -aHJlc2hvbGRxJUc/qZmZmZmZmlgQAAAAc2hvd19hbm5vdGF0aW9uc3EmiFgLAAAAYXV0b19jb21t -aXRxJ4h1Lg== + gASVGAIAAAAAAAB9lCiMB2NvbXBhcmWUSwKMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjBNvcmRlcl9i +eV9pbXBvcnRhbmNllImMHG9yZGVyX2dyb3VwaW5nX2J5X2ltcG9ydGFuY2WUiYwTc2F2ZWRXaWRn +ZXRHZW9tZXRyeZROjBBzaG93X2Fubm90YXRpb25zlIiMC3Nob3dfbGFiZWxzlIiMDXNpZ190aHJl +c2hvbGSURz+pmZmZmZmajApzb3J0X2ZyZXFzlImMCHN0YXR0ZXN0lEsAjAlzdHJldGNoZWSUiIwL +X192ZXJzaW9uX1+USwGMEGNvbnRleHRfc2V0dGluZ3OUXZSMFW9yYW5nZXdpZGdldC5zZXR0aW5n +c5SMB0NvbnRleHSUk5QpgZR9lCiMBnZhbHVlc5R9lCiMCWdyb3VwX3ZhcpSMBGlyaXOUS2WGlIwJ +YXR0cmlidXRllIwMc2VwYWwgbGVuZ3RolEtmhpSMCmNvbmRpdGlvbnOUXZRoDEsBdYwOb3JkZXJl +ZF9kb21haW6UXZQoaBpLAoaUjAtzZXBhbCB3aWR0aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwL +cGV0YWwgd2lkdGiUSwKGlGgXSwGGlGWMCmF0dHJpYnV0ZXOUfZQoaBpLAmghSwJoI0sCaCVLAmgX +SwF1jAVtZXRhc5R9lIwEdGltZZRHQdYqsOoVKdx1YmF1Lg== - {'min_internal': 5, '__version__': 1, 'binary_trees': True, 'sufficient_majority': 95, 'limit_depth': True, 'savedWidgetGeometry': b'\x01\xd9\xd0\xcb\x00\x02\x00\x00\x00\x00\x02\x90\x00\x00\x01\x1f\x00\x00\x03\xec\x00\x00\x02\x86\x00\x00\x02\x90\x00\x00\x015\x00\x00\x03\xec\x00\x00\x02\x86\x00\x00\x00\x00\x00\x00\x00\x00\x06\x90', 'max_depth': 100, 'learner_name': 'Classification Tree', 'auto_apply': True, 'min_leaf': 2, 'limit_majority': True, 'limit_min_leaf': True, 'limit_min_internal': True} + {'auto_apply': True, 'binary_trees': True, 'controlAreaVisible': True, 'learner_name': 'Classification Tree', 'limit_depth': True, 'limit_majority': True, 'limit_min_internal': True, 'limit_min_leaf': True, 'max_depth': 100, 'min_internal': 5, 'min_leaf': 2, 'savedWidgetGeometry': b'\x01\xd9\xd0\xcb\x00\x02\x00\x00\x00\x00\x02\x90\x00\x00\x01\x1f\x00\x00\x03\xec\x00\x00\x02\x86\x00\x00\x02\x90\x00\x00\x015\x00\x00\x03\xec\x00\x00\x02\x86\x00\x00\x00\x00\x00\x00\x00\x00\x06\x90', 'sufficient_majority': 95, '__version__': 1} + + + diff --git a/Orange/canvas/workflows/305-pca.ows b/Orange/canvas/workflows/305-pca.ows index 0b0129ef0ed..7a8c86bcbb8 100644 --- a/Orange/canvas/workflows/305-pca.ows +++ b/Orange/canvas/workflows/305-pca.ows @@ -1,74 +1,109 @@ - + - - - - + + + + - - - + + + - Open to see the scree diagram and interactively select the number of components. - Choose two best principal components and check if the classes from the input dataset are well separated. - The File widget loads brown-selected, a dataset from molecular biology with 79 features, 186 instances and 3 classes. - - - + Open to see the scree diagram and interactively select the number of components. + Choose two best principal components and check if the classes from the input dataset are well separated. + The File widget loads brown-selected, a dataset from molecular biology with 79 features, 186 instances and 3 classes. + + + - gAN9cQAoWAMAAAB1cmxxAVgAAAAAcQJYDAAAAHJlY2VudF9wYXRoc3EDXXEEKGNPcmFuZ2Uud2lk -Z2V0cy51dGlscy5maWxlZGlhbG9ncwpSZWNlbnRQYXRoCnEFKYFxBn1xByhYBQAAAHNoZWV0cQho -AlgFAAAAdGl0bGVxCWgCWAcAAAByZWxwYXRocQpYEgAAAGJyb3duLXNlbGVjdGVkLnRhYnELWAcA -AABhYnNwYXRocQxYQgAAAC9Vc2Vycy9ibGF6L0Ryb3Bib3gvZGV2L29yYW5nZTMvT3JhbmdlL2Rh -dGFzZXRzL2Jyb3duLXNlbGVjdGVkLnRhYnENWAYAAABwcmVmaXhxDlgPAAAAc2FtcGxlLWRhdGFz -ZXRzcQ91YmgFKYFxEH1xEShoCGgCaAloAmgKWAgAAABpcmlzLnRhYnESaAxYOAAAAC9Vc2Vycy9i -bGF6L0Ryb3Bib3gvZGV2L29yYW5nZTMvT3JhbmdlL2RhdGFzZXRzL2lyaXMudGFicRNoDmgPdWJl -WAYAAABzb3VyY2VxFEsAWAsAAABzaGVldF9uYW1lc3EVfXEWWAsAAAByZWNlbnRfdXJsc3EXXXEY -WBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cRlDLgHZ0MsAAQAAAAAD/wAAAlwAAAXwAAAEQwAAA/8A -AAJyAAAF8AAABEMAAAAAAABxGlgQAAAAY29udGV4dF9zZXR0aW5nc3EbXXEcdS4= + gASVxwsAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZQojB5v +cmFuZ2V3aWRnZXQudXRpbHMuZmlsZWRpYWxvZ3OUjApSZWNlbnRQYXRolJOUKYGUfZQojAdhYnNw +YXRolIw3L1VzZXJzL2phbmV6L29yYW5nZTMvT3JhbmdlL2RhdGFzZXRzL2Jyb3duLXNlbGVjdGVk +LnRhYpSMBnByZWZpeJSMD3NhbXBsZS1kYXRhc2V0c5SMB3JlbHBhdGiUjBJicm93bi1zZWxlY3Rl +ZC50YWKUjAV0aXRsZZSMAJSMBXNoZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJoBimBlH2UKGgJjC0v +VXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUaAtoDGgNjAhpcmlz +LnRhYpRoD2gQaBFoEGgSTnViZYwLcmVjZW50X3VybHOUXZSME3NhdmVkV2lkZ2V0R2VvbWV0cnmU +Qy4B2dDLAAEAAAAAA/8AAAJcAAAF8AAABEMAAAP/AAACcgAABfAAAARDAAAAAAAAlIwLc2hlZXRf +bmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtfX3ZlcnNpb25f +X5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4 +dJSTlCmBlH2UKIwGdmFsdWVzlH2UKIwJdmFyaWFibGVzlF2UaB99lGgrXZQoXZQojAdhbHBoYSAw +lIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJDb250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiM +B2FscGhhIDeUaDNLAGgQiGVdlCiMCGFscGhhIDE0lGgzSwBoEIhlXZQojAhhbHBoYSAyMZRoM0sA +aBCIZV2UKIwIYWxwaGEgMjiUaDNLAGgQiGVdlCiMCGFscGhhIDM1lGgzSwBoEIhlXZQojAhhbHBo +YSA0MpRoM0sAaBCIZV2UKIwIYWxwaGEgNDmUaDNLAGgQiGVdlCiMCGFscGhhIDU2lGgzSwBoEIhl +XZQojAhhbHBoYSA2M5RoM0sAaBCIZV2UKIwIYWxwaGEgNzCUaDNLAGgQiGVdlCiMCGFscGhhIDc3 +lGgzSwBoEIhlXZQojAhhbHBoYSA4NJRoM0sAaBCIZV2UKIwIYWxwaGEgOTGUaDNLAGgQiGVdlCiM +CGFscGhhIDk4lGgzSwBoEIhlXZQojAlhbHBoYSAxMDWUaDNLAGgQiGVdlCiMCWFscGhhIDExMpRo +M0sAaBCIZV2UKIwJYWxwaGEgMTE5lGgzSwBoEIhlXZQojAVFbHUgMJRoM0sAaBCIZV2UKIwGRWx1 +IDMwlGgzSwBoEIhlXZQojAZFbHUgNjCUaDNLAGgQiGVdlCiMBkVsdSA5MJRoM0sAaBCIZV2UKIwH +RWx1IDEyMJRoM0sAaBCIZV2UKIwHRWx1IDE1MJRoM0sAaBCIZV2UKIwHRWx1IDE4MJRoM0sAaBCI +ZV2UKIwHRWx1IDIxMJRoM0sAaBCIZV2UKIwHRWx1IDI0MJRoM0sAaBCIZV2UKIwHRWx1IDI3MJRo +M0sAaBCIZV2UKIwHRWx1IDMwMJRoM0sAaBCIZV2UKIwHRWx1IDMzMJRoM0sAaBCIZV2UKIwHRWx1 +IDM2MJRoM0sAaBCIZV2UKIwHRWx1IDM5MJRoM0sAaBCIZV2UKIwIY2RjMTUgMTCUaDNLAGgQiGVd +lCiMCGNkYzE1IDMwlGgzSwBoEIhlXZQojAhjZGMxNSA1MJRoM0sAaBCIZV2UKIwIY2RjMTUgNzCU +aDNLAGgQiGVdlCiMCGNkYzE1IDkwlGgzSwBoEIhlXZQojAljZGMxNSAxMTCUaDNLAGgQiGVdlCiM +CWNkYzE1IDEzMJRoM0sAaBCIZV2UKIwJY2RjMTUgMTUwlGgzSwBoEIhlXZQojAljZGMxNSAxNzCU +aDNLAGgQiGVdlCiMCWNkYzE1IDE5MJRoM0sAaBCIZV2UKIwJY2RjMTUgMjEwlGgzSwBoEIhlXZQo +jAljZGMxNSAyMzCUaDNLAGgQiGVdlCiMCWNkYzE1IDI1MJRoM0sAaBCIZV2UKIwJY2RjMTUgMjcw +lGgzSwBoEIhlXZQojAljZGMxNSAyOTCUaDNLAGgQiGVdlCiMBXNwbyAwlGgzSwBoEIhlXZQojAVz +cG8gMpRoM0sAaBCIZV2UKIwFc3BvIDWUaDNLAGgQiGVdlCiMBXNwbyA3lGgzSwBoEIhlXZQojAVz +cG8gOZRoM0sAaBCIZV2UKIwGc3BvIDExlGgzSwBoEIhlXZQojAZzcG81IDKUaDNLAGgQiGVdlCiM +BnNwbzUgN5RoM0sAaBCIZV2UKIwHc3BvNSAxMZRoM0sAaBCIZV2UKIwKc3BvLSBlYXJseZRoM0sA +aBCIZV2UKIwIc3BvLSBtaWSUaDNLAGgQiGVdlCiMBmhlYXQgMJRoM0sAaBCIZV2UKIwHaGVhdCAx +MJRoM0sAaBCIZV2UKIwHaGVhdCAyMJRoM0sAaBCIZV2UKIwHaGVhdCA0MJRoM0sAaBCIZV2UKIwH +aGVhdCA4MJRoM0sAaBCIZV2UKIwIaGVhdCAxNjCUaDNLAGgQiGVdlCiMBmR0dCAxNZRoM0sAaBCI +ZV2UKIwGZHR0IDMwlGgzSwBoEIhlXZQojAZkdHQgNjCUaDNLAGgQiGVdlCiMB2R0dCAxMjCUaDNL +AGgQiGVdlCiMBmNvbGQgMJRoM0sAaBCIZV2UKIwHY29sZCAyMJRoM0sAaBCIZV2UKIwHY29sZCA0 +MJRoM0sAaBCIZV2UKIwIY29sZCAxNjCUaDNLAGgQiGVdlCiMBmRpYXUgYZRoM0sAaBCIZV2UKIwG +ZGlhdSBilGgzSwBoEIhlXZQojAZkaWF1IGOUaDNLAGgQiGVdlCiMBmRpYXUgZJRoM0sAaBCIZV2U +KIwGZGlhdSBllGgzSwBoEIhlXZQojAZkaWF1IGaUaDNLAGgQiGVdlCiMBmRpYXUgZ5RoM0sAaBCI +ZV2UKIwIZnVuY3Rpb26UaDGMEERpc2NyZXRlVmFyaWFibGWUk5RLAYwTUHJvdGVhcywgUmVzcCwg +Umlib5SJZV2UKIwEZ2VuZZRoMYwOU3RyaW5nVmFyaWFibGWUk5RLAmgQiWVlc2ghSwF1jAphdHRy +aWJ1dGVzlChoMEsChpRoNUsChpRoN0sChpRoOUsChpRoO0sChpRoPUsChpRoP0sChpRoQUsChpRo +Q0sChpRoRUsChpRoR0sChpRoSUsChpRoS0sChpRoTUsChpRoT0sChpRoUUsChpRoU0sChpRoVUsC +hpRoV0sChpRoWUsChpRoW0sChpRoXUsChpRoX0sChpRoYUsChpRoY0sChpRoZUsChpRoZ0sChpRo +aUsChpRoa0sChpRobUsChpRob0sChpRocUsChpRoc0sChpRodUsChpRod0sChpRoeUsChpRoe0sC +hpRofUsChpRof0sChpRogUsChpRog0sChpRohUsChpRoh0sChpRoiUsChpRoi0sChpRojUsChpRo +j0sChpRokUsChpRok0sChpRolUsChpRol0sChpRomUsChpRom0sChpRonUsChpRon0sChpRooUsC +hpRoo0sChpRopUsChpRop0sChpRoqUsChpRoq0sChpRorUsChpRor0sChpRosUsChpRos0sChpRo +tUsChpRot0sChpRouUsChpRou0sChpRovUsChpRov0sChpRowUsChpRow0sChpRoxUsChpRox0sC +hpRoyUsChpRoy0sChpRozUsChpRoz0sChpR0lIwFbWV0YXOUaNZLA4aUhZSMCmNsYXNzX3ZhcnOU +aNFdlCiMB1Byb3RlYXOUjARSZXNwlIwEUmlib5RlhpSFlIwSbW9kaWZpZWRfdmFyaWFibGVzlF2U +dWJhdS4= - gAN9cQAoWAcAAABhZGRyZXNzcQFYAAAAAHECWAsAAABuY29tcG9uZW50c3EDSwJYBAAAAG1heHBx -BEsUWBAAAAB2YXJpYW5jZV9jb3ZlcmVkcQVjbnVtcHkuY29yZS5tdWx0aWFycmF5CnNjYWxhcgpx -BmNudW1weQpkdHlwZQpxB1gCAAAAZjhxCEsASwGHcQlScQooSwNYAQAAADxxC05OTkr/////Sv// -//9LAHRxDGJDCHmb0rxOmEFAcQ2GcQ5ScQ9YCwAAAGF4aXNfbGFiZWxzcRBLClgLAAAAYXV0b191 -cGRhdGVxEYhYCQAAAG5vcm1hbGl6ZXESiFgKAAAAYmF0Y2hfc2l6ZXETS2RYEwAAAHNhdmVkV2lk -Z2V0R2VvbWV0cnlxFE5YCwAAAGF1dG9fY29tbWl0cRWIdS4= - - gAN9cQAoWAsAAABhdXRvX3NhbXBsZXEBiFgRAAAAdG9vbGJhcl9zZWxlY3Rpb25xAksAWAUAAABn -cmFwaHEDfXEEKFgNAAAAY2xhc3NfZGVuc2l0eXEFiFgLAAAAc2hvd19sZWdlbmRxBohYEQAAAHRv -b2x0aXBfc2hvd3NfYWxscQeJWBEAAABqaXR0ZXJfY29udGludW91c3EIiFgLAAAAYWxwaGFfdmFs -dWVxCUuAWAsAAABqaXR0ZXJfc2l6ZXEKSwFYCwAAAHBvaW50X3dpZHRocQtLClgTAAAAbGFiZWxf -b25seV9zZWxlY3RlZHEMiVgJAAAAc2hvd19ncmlkcQ2JdVgTAAAAYXV0b19zZW5kX3NlbGVjdGlv -bnEOiFgTAAAAc2F2ZWRXaWRnZXRHZW9tZXRyeXEPQy4B2dDLAAEAAAAABKgAAADjAAAH3QAAA7IA -AASoAAAA+QAAB90AAAOyAAAAAAAAcRBYEAAAAGNvbnRleHRfc2V0dGluZ3NxEV1xEmNPcmFuZ2Uu -d2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0CnETKYFxFH1xFShYBQAAAG1ldGFzcRZ9cRdYBAAAAHRp -bWVxGEdB1eCcEvjll1gKAAAAYXR0cmlidXRlc3EZfXEaKFgIAAAAZnVuY3Rpb25xG0sBWAMAAABQ -QzFxHEsCWAMAAABQQzJxHUsCdVgOAAAAb3JkZXJlZF9kb21haW5xHl1xHyhoHEsChnEgaB1LAoZx -IWgbSwGGcSJlWAYAAAB2YWx1ZXNxI31xJChoAYhK/v///4ZxJVgGAAAAYXR0cl94cSZYAwAAAFBD -MXEnSwKGcShoAksASv7///+GcSloDohK/v///4ZxKmgDfXErKGgJS4BK/v///4ZxLFgKAAAAYXR0 -cl9jb2xvcnEtaBtLAYZxLmgHiUr+////hnEvWAoAAABhdHRyX3NoYXBlcTBYAAAAAHExSv7///+G -cTJoBohK/v///4ZxM2gKSwFK/v///4ZxNGgMiUr+////hnE1aA2JSv7///+GcTZYCgAAAGF0dHJf -bGFiZWxxN2gxSv7///+GcThYCQAAAGF0dHJfc2l6ZXE5aDFK/v///4ZxOmgIiEr+////hnE7aAtL -Ckr+////hnE8aAWISv7///+GcT11aA9oEEr+////hnE+WAYAAABhdHRyX3lxP1gDAAAAUEMycUBL -AoZxQXV1YmF1Lg== - - gAN9cQAoWA4AAABkaXN0X2NvbG9yX1JHQnEBKEvcS9xL3Ev/dHECWAsAAABzZWxlY3Rfcm93c3ED -iFgLAAAAYXV0b19jb21taXRxBIhYDgAAAGNvbG9yX2J5X2NsYXNzcQWIWBMAAABzYXZlZFdpZGdl -dEdlb21ldHJ5cQZDLgHZ0MsAAQAAAAADaAAAAbkAAAaHAAADwgAAA2gAAAHPAAAGhwAAA8IAAAAA -AABxB1gVAAAAc2hvd19hdHRyaWJ1dGVfbGFiZWxzcQiIWBIAAABzaG93X2Rpc3RyaWJ1dGlvbnNx -CYlYEAAAAGNvbnRleHRfc2V0dGluZ3NxCl1xC2NPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250 -ZXh0CnEMKYFxDX1xDihYBQAAAG1ldGFzcQ99cRBYBAAAAHRpbWVxEUdB1eCcFDIJHFgKAAAAYXR0 -cmlidXRlc3ESfXETKFgIAAAAZnVuY3Rpb25xFF1xFShYBwAAAFByb3RlYXNxFlgEAAAAUmVzcHEX -WAQAAABSaWJvcRhlWAMAAABQQzFxGUsCWAMAAABQQzJxGksCdVgOAAAAb3JkZXJlZF9kb21haW5x -G11xHChoGUsChnEdaBpLAoZxHmgUSwGGcR9lWAYAAAB2YWx1ZXNxIH1xIShoAWgCSv7///+GcSJo -A4hK/v///4ZxI1gNAAAAc2VsZWN0ZWRfY29sc3EkXXElaASISv7///+GcSZoBYhK/v///4ZxJ2gG -aAdK/v///4ZxKGgIiEr+////hnEpWA0AAABzZWxlY3RlZF9yb3dzcSpdcStoCYlK/v///4ZxLHV1 -YmF1Lg== + {'auto_commit': True, 'axis_labels': 10, 'controlAreaVisible': True, 'maxp': 20, 'ncomponents': 2, 'normalize': True, 'savedWidgetGeometry': None, 'variance_covered': 35, '__version__': 1} + gASV5QQAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAASoAAAA4wAAB90AAAOy +AAAEqAAAAPkAAAfdAAADsgAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiIwRaml0dGVyX2NvbnRpbnVvdXOUiIwLaml0dGVyX3NpemWUSwGME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UKIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJST +lCmBlH2UKIwGdmFsdWVzlH2UKIwKYXR0cl9jb2xvcpSMCGZ1bmN0aW9ulEtlhpSMCmF0dHJfbGFi +ZWyUTkr+////hpSMCmF0dHJfc2hhcGWUTkr+////hpSMCWF0dHJfc2l6ZZROSv7///+GlIwGYXR0 +cl94lIwDUEMxlEtmhpSMBmF0dHJfeZSMA1BDMpRLZoaUaAp9lGgWSwV1jAphdHRyaWJ1dGVzlH2U +KGgqSwJoLUsCaCFLAXWMBW1ldGFzlH2UjARnZW5llEsDc3ViaBspgZR9lChoMn2UjAR0aW1llEdB +1eCcEvjll2gwfZQojAhmdW5jdGlvbpRLAYwDUEMxlEsCjANQQzKUSwJ1jA5vcmRlcmVkX2RvbWFp +bpRdlChoO0sChpRoPEsChpRoOksBhpRlaB59lCiMC2F1dG9fc2FtcGxllIhK/v///4aUjAZhdHRy +X3iUjANQQzGUSwKGlIwRdG9vbGJhcl9zZWxlY3Rpb26USwBK/v///4aUjBNhdXRvX3NlbmRfc2Vs +ZWN0aW9ulIhK/v///4aUjAVncmFwaJR9lCiMC2FscGhhX3ZhbHVllEuASv7///+GlIwKYXR0cl9j +b2xvcpRoOksBhpSMEXRvb2x0aXBfc2hvd3NfYWxslIlK/v///4aUjAphdHRyX3NoYXBllIwAlEr+ +////hpSMC3Nob3dfbGVnZW5klIhK/v///4aUjAtqaXR0ZXJfc2l6ZZRLAUr+////hpSME2xhYmVs +X29ubHlfc2VsZWN0ZWSUiUr+////hpSMCXNob3dfZ3JpZJSJSv7///+GlIwKYXR0cl9sYWJlbJRo +VUr+////hpSMCWF0dHJfc2l6ZZRoVUr+////hpSMEWppdHRlcl9jb250aW51b3VzlIhK/v///4aU +jAtwb2ludF93aWR0aJRLCkr+////hpSMDWNsYXNzX2RlbnNpdHmUiEr+////hpR1jBNzYXZlZFdp +ZGdldEdlb21ldHJ5lGgFSv7///+GlIwGYXR0cl95lIwDUEMylEsChpRoIGhRaCdoYmglaFZoI2hg +aBZLBXV1YmV1Lg== + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, '__version__': 1} + + + diff --git a/Orange/canvas/workflows/310-clustering.ows b/Orange/canvas/workflows/310-clustering.ows index df34cb66786..19b98d29073 100644 --- a/Orange/canvas/workflows/310-clustering.ows +++ b/Orange/canvas/workflows/310-clustering.ows @@ -1,148 +1,162 @@ - + - - - - - - - + + + + + + + - - - - - - + + + + + + - Read the data. Try this schema with the "brown-selected" data (from datasets that come with Orange). - Visualize the data distances in a heat map. - Choose any part of the clustering dendrogram in Hierarchical Clustering. Then, observe the selected data in a data table, or in any other analysis widget. Open both Hierarchical Clustering and Data Table (1) widget to turn this schema into interactive data analysis. + Read the data. Try this schema with the "brown-selected" data (from datasets that come with Orange). + Visualize the data distances in a heat map. + Choose any part of the clustering dendrogram in Hierarchical Clustering. Then, observe the selected data in a data table, or in any other analysis widget. Open both Hierarchical Clustering and Data Table (1) widget to turn this schema into interactive data analysis. - Any change in selection in hierarchical clustering will propagate to the Data Table and Box Plot widgets. - Hierarchically cluster the data. - Compute the distances between the data samples. - - - - - + Any change in selection in hierarchical clustering will propagate to the Data Table and Box Plot widgets. + Hierarchically cluster the data. + Compute the distances between the data samples. + + + + + - gAN9cQAoWAsAAAByZWNlbnRfdXJsc3EBXXECWAsAAABzaGVldF9uYW1lc3EDfXEEWBAAAABjb250 -ZXh0X3NldHRpbmdzcQVdcQZYAwAAAHVybHEHWAAAAABxCFgGAAAAc291cmNlcQlLAFgMAAAAcmVj -ZW50X3BhdGhzcQpdcQtjT3JhbmdlLndpZGdldHMudXRpbHMuZmlsZWRpYWxvZ3MKUmVjZW50UGF0 -aApxDCmBcQ19cQ4oWAUAAABzaGVldHEPaAhYBQAAAHRpdGxlcRBoCFgGAAAAcHJlZml4cRFYDwAA -AHNhbXBsZS1kYXRhc2V0c3ESWAcAAABhYnNwYXRocRNYXgAAAC9Vc2Vycy9ibGF6Ly52aXJ0dWFs -ZW52cy9vcmFuZ2UvbGliL3B5dGhvbjMuNS9zaXRlLXBhY2thZ2VzL09yYW5nZS9kYXRhc2V0cy9i -cm93bi1zZWxlY3RlZC50YWJxFFgHAAAAcmVscGF0aHEVWBIAAABicm93bi1zZWxlY3RlZC50YWJx -FnViYVgTAAAAc2F2ZWRXaWRnZXRHZW9tZXRyeXEXQy4B2dDLAAEAAAAAAhMAAAE8AAADwgAAAjUA -AAITAAABUgAAA8IAAAI1AAAAAAAAcRh1Lg== + gASVbgsAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjDcvVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvYnJvd24tc2VsZWN0ZWQu +dGFilIwGcHJlZml4lIwPc2FtcGxlLWRhdGFzZXRzlIwHcmVscGF0aJSMEmJyb3duLXNlbGVjdGVk +LnRhYpSMBXRpdGxllIwAlIwFc2hlZXSUaBCMC2ZpbGVfZm9ybWF0lE51YmGMC3JlY2VudF91cmxz +lF2UjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAITAAABPAAAA8IAAAI1AAACEwAA +AVIAAAPCAAACNQAAAAAAAJSMC3NoZWV0X25hbWVzlH2UjAZzb3VyY2WUSwCMA3VybJRoEIwNZG9t +YWluX2VkaXRvcpR9lIwLX192ZXJzaW9uX1+USwGMEGNvbnRleHRfc2V0dGluZ3OUXZSMFW9yYW5n +ZXdpZGdldC5zZXR0aW5nc5SMB0NvbnRleHSUk5QpgZR9lCiMBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoJ12UKF2UKIwHYWxwaGEgMJSMFE9yYW5nZS5kYXRhLnZhcmlhYmxllIwSQ29udGlu +dW91c1ZhcmlhYmxllJOUSwBoEIhlXZQojAdhbHBoYSA3lGgvSwBoEIhlXZQojAhhbHBoYSAxNJRo +L0sAaBCIZV2UKIwIYWxwaGEgMjGUaC9LAGgQiGVdlCiMCGFscGhhIDI4lGgvSwBoEIhlXZQojAhh +bHBoYSAzNZRoL0sAaBCIZV2UKIwIYWxwaGEgNDKUaC9LAGgQiGVdlCiMCGFscGhhIDQ5lGgvSwBo +EIhlXZQojAhhbHBoYSA1NpRoL0sAaBCIZV2UKIwIYWxwaGEgNjOUaC9LAGgQiGVdlCiMCGFscGhh +IDcwlGgvSwBoEIhlXZQojAhhbHBoYSA3N5RoL0sAaBCIZV2UKIwIYWxwaGEgODSUaC9LAGgQiGVd +lCiMCGFscGhhIDkxlGgvSwBoEIhlXZQojAhhbHBoYSA5OJRoL0sAaBCIZV2UKIwJYWxwaGEgMTA1 +lGgvSwBoEIhlXZQojAlhbHBoYSAxMTKUaC9LAGgQiGVdlCiMCWFscGhhIDExOZRoL0sAaBCIZV2U +KIwFRWx1IDCUaC9LAGgQiGVdlCiMBkVsdSAzMJRoL0sAaBCIZV2UKIwGRWx1IDYwlGgvSwBoEIhl +XZQojAZFbHUgOTCUaC9LAGgQiGVdlCiMB0VsdSAxMjCUaC9LAGgQiGVdlCiMB0VsdSAxNTCUaC9L +AGgQiGVdlCiMB0VsdSAxODCUaC9LAGgQiGVdlCiMB0VsdSAyMTCUaC9LAGgQiGVdlCiMB0VsdSAy +NDCUaC9LAGgQiGVdlCiMB0VsdSAyNzCUaC9LAGgQiGVdlCiMB0VsdSAzMDCUaC9LAGgQiGVdlCiM +B0VsdSAzMzCUaC9LAGgQiGVdlCiMB0VsdSAzNjCUaC9LAGgQiGVdlCiMB0VsdSAzOTCUaC9LAGgQ +iGVdlCiMCGNkYzE1IDEwlGgvSwBoEIhlXZQojAhjZGMxNSAzMJRoL0sAaBCIZV2UKIwIY2RjMTUg +NTCUaC9LAGgQiGVdlCiMCGNkYzE1IDcwlGgvSwBoEIhlXZQojAhjZGMxNSA5MJRoL0sAaBCIZV2U +KIwJY2RjMTUgMTEwlGgvSwBoEIhlXZQojAljZGMxNSAxMzCUaC9LAGgQiGVdlCiMCWNkYzE1IDE1 +MJRoL0sAaBCIZV2UKIwJY2RjMTUgMTcwlGgvSwBoEIhlXZQojAljZGMxNSAxOTCUaC9LAGgQiGVd +lCiMCWNkYzE1IDIxMJRoL0sAaBCIZV2UKIwJY2RjMTUgMjMwlGgvSwBoEIhlXZQojAljZGMxNSAy +NTCUaC9LAGgQiGVdlCiMCWNkYzE1IDI3MJRoL0sAaBCIZV2UKIwJY2RjMTUgMjkwlGgvSwBoEIhl +XZQojAVzcG8gMJRoL0sAaBCIZV2UKIwFc3BvIDKUaC9LAGgQiGVdlCiMBXNwbyA1lGgvSwBoEIhl +XZQojAVzcG8gN5RoL0sAaBCIZV2UKIwFc3BvIDmUaC9LAGgQiGVdlCiMBnNwbyAxMZRoL0sAaBCI +ZV2UKIwGc3BvNSAylGgvSwBoEIhlXZQojAZzcG81IDeUaC9LAGgQiGVdlCiMB3NwbzUgMTGUaC9L +AGgQiGVdlCiMCnNwby0gZWFybHmUaC9LAGgQiGVdlCiMCHNwby0gbWlklGgvSwBoEIhlXZQojAZo +ZWF0IDCUaC9LAGgQiGVdlCiMB2hlYXQgMTCUaC9LAGgQiGVdlCiMB2hlYXQgMjCUaC9LAGgQiGVd +lCiMB2hlYXQgNDCUaC9LAGgQiGVdlCiMB2hlYXQgODCUaC9LAGgQiGVdlCiMCGhlYXQgMTYwlGgv +SwBoEIhlXZQojAZkdHQgMTWUaC9LAGgQiGVdlCiMBmR0dCAzMJRoL0sAaBCIZV2UKIwGZHR0IDYw +lGgvSwBoEIhlXZQojAdkdHQgMTIwlGgvSwBoEIhlXZQojAZjb2xkIDCUaC9LAGgQiGVdlCiMB2Nv +bGQgMjCUaC9LAGgQiGVdlCiMB2NvbGQgNDCUaC9LAGgQiGVdlCiMCGNvbGQgMTYwlGgvSwBoEIhl +XZQojAZkaWF1IGGUaC9LAGgQiGVdlCiMBmRpYXUgYpRoL0sAaBCIZV2UKIwGZGlhdSBjlGgvSwBo +EIhlXZQojAZkaWF1IGSUaC9LAGgQiGVdlCiMBmRpYXUgZZRoL0sAaBCIZV2UKIwGZGlhdSBmlGgv +SwBoEIhlXZQojAZkaWF1IGeUaC9LAGgQiGVdlCiMCGZ1bmN0aW9ulGgtjBBEaXNjcmV0ZVZhcmlh +YmxllJOUSwGME1Byb3RlYXMsIFJlc3AsIFJpYm+UiWVdlCiMBGdlbmWUaC2MDlN0cmluZ1Zhcmlh +YmxllJOUSwJoEIllZXNoHUsBdYwKYXR0cmlidXRlc5QoaCxLAoaUaDFLAoaUaDNLAoaUaDVLAoaU +aDdLAoaUaDlLAoaUaDtLAoaUaD1LAoaUaD9LAoaUaEFLAoaUaENLAoaUaEVLAoaUaEdLAoaUaElL +AoaUaEtLAoaUaE1LAoaUaE9LAoaUaFFLAoaUaFNLAoaUaFVLAoaUaFdLAoaUaFlLAoaUaFtLAoaU +aF1LAoaUaF9LAoaUaGFLAoaUaGNLAoaUaGVLAoaUaGdLAoaUaGlLAoaUaGtLAoaUaG1LAoaUaG9L +AoaUaHFLAoaUaHNLAoaUaHVLAoaUaHdLAoaUaHlLAoaUaHtLAoaUaH1LAoaUaH9LAoaUaIFLAoaU +aINLAoaUaIVLAoaUaIdLAoaUaIlLAoaUaItLAoaUaI1LAoaUaI9LAoaUaJFLAoaUaJNLAoaUaJVL +AoaUaJdLAoaUaJlLAoaUaJtLAoaUaJ1LAoaUaJ9LAoaUaKFLAoaUaKNLAoaUaKVLAoaUaKdLAoaU +aKlLAoaUaKtLAoaUaK1LAoaUaK9LAoaUaLFLAoaUaLNLAoaUaLVLAoaUaLdLAoaUaLlLAoaUaLtL +AoaUaL1LAoaUaL9LAoaUaMFLAoaUaMNLAoaUaMVLAoaUaMdLAoaUaMlLAoaUaMtLAoaUdJSMBW1l +dGFzlGjSSwOGlIWUjApjbGFzc192YXJzlGjNXZQojAdQcm90ZWFzlIwEUmVzcJSMBFJpYm+UZYaU +hZSMEm1vZGlmaWVkX3ZhcmlhYmxlc5RdlHViYXUu - {'autocommit': False, 'metric_idx': 0, 'axis': 0, 'savedWidgetGeometry': None} - gAN9cQAoWA4AAABjb2xvcl9ieV9jbGFzc3EBiFgQAAAAY29udGV4dF9zZXR0aW5nc3ECXXEDY09y -YW5nZS53aWRnZXRzLnNldHRpbmdzCkNvbnRleHQKcQQpgXEFfXEGKFgKAAAAYXR0cmlidXRlc3EH -fXEIKFgIAAAAYWxwaGEgODRxCUsCWAcAAABFbHUgMzkwcQpLAlgHAAAARWx1IDM2MHELSwJYBgAA -AGR0dCAzMHEMSwJYBwAAAEVsdSAzMzBxDUsCWAkAAABhbHBoYSAxMDVxDksCWAcAAABjb2xkIDQw -cQ9LAlgGAAAAZHR0IDYwcRBLAlgHAAAAY29sZCAyMHERSwJYBgAAAEVsdSA2MHESSwJYBgAAAGRp -YXUgZ3ETSwJYBQAAAHNwbyA1cRRLAlgGAAAAaGVhdCAwcRVLAlgHAAAARWx1IDE4MHEWSwJYBQAA -AHNwbyAwcRdLAlgJAAAAY2RjMTUgMTEwcRhLAlgJAAAAY2RjMTUgMjMwcRlLAlgGAAAAY29sZCAw -cRpLAlgHAAAAc3BvNSAxMXEbSwJYCAAAAGFscGhhIDI4cRxLAlgGAAAAZGlhdSBkcR1LAlgHAAAA -RWx1IDEyMHEeSwJYCAAAAGNkYzE1IDMwcR9LAlgGAAAAZGlhdSBhcSBLAlgJAAAAY2RjMTUgMTcw -cSFLAlgIAAAAYWxwaGEgMzVxIksCWAgAAABhbHBoYSAxNHEjSwJYBgAAAHNwbyAxMXEkSwJYCQAA -AGNkYzE1IDE1MHElSwJYBwAAAGhlYXQgNDBxJksCWAYAAABkaWF1IGJxJ0sCWAgAAABhbHBoYSA2 -M3EoSwJYBwAAAEVsdSAxNTBxKUsCWAUAAABzcG8gMnEqSwJYBgAAAGRpYXUgZnErSwJYCAAAAGFs -cGhhIDk4cSxLAlgKAAAAc3BvLSBlYXJseXEtSwJYCAAAAGNvbGQgMTYwcS5LAlgIAAAAYWxwaGEg -MjFxL0sCWAYAAABFbHUgMzBxMEsCWAUAAABzcG8gN3ExSwJYCAAAAHNwby0gbWlkcTJLAlgIAAAA -YWxwaGEgNzBxM0sCWAcAAABFbHUgMzAwcTRLAlgGAAAAZGlhdSBjcTVLAlgJAAAAY2RjMTUgMTMw -cTZLAlgIAAAAYWxwaGEgNzdxN0sCWAcAAABFbHUgMjQwcThLAlgGAAAAZGlhdSBlcTlLAlgHAAAA -aGVhdCAxMHE6SwJYBQAAAEVsdSAwcTtLAlgIAAAAYWxwaGEgNDlxPEsCWAkAAABjZGMxNSAyOTBx -PUsCWAkAAABjZGMxNSAyNzBxPksCWAUAAABzcG8gOXE/SwJYCAAAAGNkYzE1IDcwcUBLAlgHAAAA -YWxwaGEgMHFBSwJYCQAAAGFscGhhIDExMnFCSwJYCAAAAGZ1bmN0aW9ucUNdcUQoWAcAAABQcm90 -ZWFzcUVYBAAAAFJlc3BxRlgEAAAAUmlib3FHZVgJAAAAY2RjMTUgMjUwcUhLAlgGAAAAZHR0IDE1 -cUlLAlgHAAAAaGVhdCA4MHFKSwJYCAAAAGhlYXQgMTYwcUtLAlgHAAAAYWxwaGEgN3FMSwJYCAAA -AGFscGhhIDkxcU1LAlgIAAAAY2RjMTUgNTBxTksCWAgAAABjZGMxNSAxMHFPSwJYBwAAAGhlYXQg -MjBxUEsCWAcAAABkdHQgMTIwcVFLAlgGAAAARWx1IDkwcVJLAlgHAAAARWx1IDIxMHFTSwJYCAAA -AGFscGhhIDU2cVRLAlgJAAAAY2RjMTUgMTkwcVVLAlgHAAAARWx1IDI3MHFWSwJYCQAAAGFscGhh -IDExOXFXSwJYCQAAAGNkYzE1IDIxMHFYSwJYBgAAAHNwbzUgMnFZSwJYCAAAAGFscGhhIDQycVpL -AlgIAAAAY2RjMTUgOTBxW0sCWAYAAABzcG81IDdxXEsCdVgFAAAAbWV0YXNxXX1xXlgGAAAAdmFs -dWVzcV99cWAoWA4AAABjb2xvcl9ieV9jbGFzc3FhiEr+////hnFiWBMAAABzYXZlZFdpZGdldEdl -b21ldHJ5cWNOSv7///+GcWRYDQAAAHNlbGVjdGVkX3Jvd3NxZV1xZlgLAAAAc2VsZWN0X3Jvd3Nx -Z4hK/v///4ZxaFgVAAAAc2hvd19hdHRyaWJ1dGVfbGFiZWxzcWmISv7///+GcWpYDgAAAGRpc3Rf -Y29sb3JfUkdCcWsoS9xL3EvcS/90cWxK/v///4ZxbVgNAAAAc2VsZWN0ZWRfY29sc3FuXXFvWAsA -AABhdXRvX2NvbW1pdHFwiEr+////hnFxWBIAAABzaG93X2Rpc3RyaWJ1dGlvbnNxcolK/v///4Zx -c3VYBAAAAHRpbWVxdEdB1eCVdgS1HFgOAAAAb3JkZXJlZF9kb21haW5xdV1xdihoQUsChnF3aExL -AoZxeGgjSwKGcXloL0sChnF6aBxLAoZxe2giSwKGcXxoWksChnF9aDxLAoZxfmhUSwKGcX9oKEsC -hnGAaDNLAoZxgWg3SwKGcYJoCUsChnGDaE1LAoZxhGgsSwKGcYVoDksChnGGaEJLAoZxh2hXSwKG -cYhoO0sChnGJaDBLAoZximgSSwKGcYtoUksChnGMaB5LAoZxjWgpSwKGcY5oFksChnGPaFNLAoZx -kGg4SwKGcZFoVksChnGSaDRLAoZxk2gNSwKGcZRoC0sChnGVaApLAoZxlmhPSwKGcZdoH0sChnGY -aE5LAoZxmWhASwKGcZpoW0sChnGbaBhLAoZxnGg2SwKGcZ1oJUsChnGeaCFLAoZxn2hVSwKGcaBo -WEsChnGhaBlLAoZxomhISwKGcaNoPksChnGkaD1LAoZxpWgXSwKGcaZoKksChnGnaBRLAoZxqGgx -SwKGcaloP0sChnGqaCRLAoZxq2hZSwKGcaxoXEsChnGtaBtLAoZxrmgtSwKGca9oMksChnGwaBVL -AoZxsWg6SwKGcbJoUEsChnGzaCZLAoZxtGhKSwKGcbVoS0sChnG2aElLAoZxt2gMSwKGcbhoEEsC -hnG5aFFLAoZxumgaSwKGcbtoEUsChnG8aA9LAoZxvWguSwKGcb5oIEsChnG/aCdLAoZxwGg1SwKG -ccFoHUsChnHCaDlLAoZxw2grSwKGccRoE0sChnHFaENLAYZxxmV1YmFYCwAAAHNlbGVjdF9yb3dz -cceIWBUAAABzaG93X2F0dHJpYnV0ZV9sYWJlbHNxyIhYDgAAAGRpc3RfY29sb3JfUkdCcclobFgL -AAAAYXV0b19jb21taXRxyohYEwAAAHNhdmVkV2lkZ2V0R2VvbWV0cnlxy05YEgAAAHNob3dfZGlz -dHJpYnV0aW9uc3HMiXUu + {'autocommit': False, 'axis': 0, 'controlAreaVisible': True, 'metric_id': 0, 'savedWidgetGeometry': None, '__version__': 4} + gASV/AcAAAAAAAB9lCiMCmF1dG9jb21taXSUiIwLY29sb3JfZ2FtbWGURwAAAAAAAAAAjApjb2xv +cl9oaWdolEc/8AAAAAAAAIwJY29sb3JfbG93lEcAAAAAAAAAAIwSY29udHJvbEFyZWFWaXNpYmxl +lIiMDHBhbGV0dGVfbmFtZZSMFWxpbmVhcl9iZ3l3XzIwXzk4X2M2NpSMEXBlbmRpbmdfc2VsZWN0 +aW9ulF2UjBNzYXZlZFdpZGdldEdlb21ldHJ5lE6MB3NvcnRpbmeUSwCMC19fdmVyc2lvbl9flEsB +jBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOU +KYGUfZQojAphdHRyaWJ1dGVzlCiMB2FscGhhIDCUSwKGlIwHYWxwaGEgN5RLAoaUjAhhbHBoYSAx +NJRLAoaUjAhhbHBoYSAyMZRLAoaUjAhhbHBoYSAyOJRLAoaUjAhhbHBoYSAzNZRLAoaUjAhhbHBo +YSA0MpRLAoaUjAhhbHBoYSA0OZRLAoaUjAhhbHBoYSA1NpRLAoaUjAhhbHBoYSA2M5RLAoaUjAhh +bHBoYSA3MJRLAoaUjAhhbHBoYSA3N5RLAoaUjAhhbHBoYSA4NJRLAoaUjAhhbHBoYSA5MZRLAoaU +jAhhbHBoYSA5OJRLAoaUjAlhbHBoYSAxMDWUSwKGlIwJYWxwaGEgMTEylEsChpSMCWFscGhhIDEx +OZRLAoaUjAVFbHUgMJRLAoaUjAZFbHUgMzCUSwKGlIwGRWx1IDYwlEsChpSMBkVsdSA5MJRLAoaU +jAdFbHUgMTIwlEsChpSMB0VsdSAxNTCUSwKGlIwHRWx1IDE4MJRLAoaUjAdFbHUgMjEwlEsChpSM +B0VsdSAyNDCUSwKGlIwHRWx1IDI3MJRLAoaUjAdFbHUgMzAwlEsChpSMB0VsdSAzMzCUSwKGlIwH +RWx1IDM2MJRLAoaUjAdFbHUgMzkwlEsChpSMCGNkYzE1IDEwlEsChpSMCGNkYzE1IDMwlEsChpSM +CGNkYzE1IDUwlEsChpSMCGNkYzE1IDcwlEsChpSMCGNkYzE1IDkwlEsChpSMCWNkYzE1IDExMJRL +AoaUjAljZGMxNSAxMzCUSwKGlIwJY2RjMTUgMTUwlEsChpSMCWNkYzE1IDE3MJRLAoaUjAljZGMx +NSAxOTCUSwKGlIwJY2RjMTUgMjEwlEsChpSMCWNkYzE1IDIzMJRLAoaUjAljZGMxNSAyNTCUSwKG +lIwJY2RjMTUgMjcwlEsChpSMCWNkYzE1IDI5MJRLAoaUjAVzcG8gMJRLAoaUjAVzcG8gMpRLAoaU +jAVzcG8gNZRLAoaUjAVzcG8gN5RLAoaUjAVzcG8gOZRLAoaUjAZzcG8gMTGUSwKGlIwGc3BvNSAy +lEsChpSMBnNwbzUgN5RLAoaUjAdzcG81IDExlEsChpSMCnNwby0gZWFybHmUSwKGlIwIc3BvLSBt +aWSUSwKGlIwGaGVhdCAwlEsChpSMB2hlYXQgMTCUSwKGlIwHaGVhdCAyMJRLAoaUjAdoZWF0IDQw +lEsChpSMB2hlYXQgODCUSwKGlIwIaGVhdCAxNjCUSwKGlIwGZHR0IDE1lEsChpSMBmR0dCAzMJRL +AoaUjAZkdHQgNjCUSwKGlIwHZHR0IDEyMJRLAoaUjAZjb2xkIDCUSwKGlIwHY29sZCAyMJRLAoaU +jAdjb2xkIDQwlEsChpSMCGNvbGQgMTYwlEsChpSMBmRpYXUgYZRLAoaUjAZkaWF1IGKUSwKGlIwG +ZGlhdSBjlEsChpSMBmRpYXUgZJRLAoaUjAZkaWF1IGWUSwKGlIwGZGlhdSBmlEsChpSMBmRpYXUg +Z5RLAoaUdJSMCmNsYXNzX3ZhcnOUjAhmdW5jdGlvbpRLAYaUhZSMBW1ldGFzlIwEZ2VuZZRLA4aU +hZSMBnZhbHVlc5R9lCiMDmFubm90YXRpb25faWR4lEsASv7///+GlGgMSwF1jAR0aW1llEdB1eCV +dgEnhYwOb3JkZXJlZF9kb21haW6UXZQoaBVLAoaUaBdLAoaUaBlLAoaUaBtLAoaUaB1LAoaUaB9L +AoaUaCFLAoaUaCNLAoaUaCVLAoaUaCdLAoaUaClLAoaUaCtLAoaUaC1LAoaUaC9LAoaUaDFLAoaU +aDNLAoaUaDVLAoaUaDdLAoaUaDlLAoaUaDtLAoaUaD1LAoaUaD9LAoaUaEFLAoaUaENLAoaUaEVL +AoaUaEdLAoaUaElLAoaUaEtLAoaUaE1LAoaUaE9LAoaUaFFLAoaUaFNLAoaUaFVLAoaUaFdLAoaU +aFlLAoaUaFtLAoaUaF1LAoaUaF9LAoaUaGFLAoaUaGNLAoaUaGVLAoaUaGdLAoaUaGlLAoaUaGtL +AoaUaG1LAoaUaG9LAoaUaHFLAoaUaHNLAoaUaHVLAoaUaHdLAoaUaHlLAoaUaHtLAoaUaH1LAoaU +aH9LAoaUaIFLAoaUaINLAoaUaIVLAoaUaIdLAoaUaIlLAoaUaItLAoaUaI1LAoaUaI9LAoaUaJFL +AoaUaJNLAoaUaJVLAoaUaJdLAoaUaJlLAoaUaJtLAoaUaJ1LAoaUaJ9LAoaUaKFLAoaUaKNLAoaU +aKVLAoaUaKdLAoaUaKlLAoaUaKtLAoaUaK1LAoaUaK9LAoaUaLFLAoaUaLVLAYaUaLlLA4aUZXVi +YXUu - gAN9cQAoWAcAAABzb3J0aW5ncQFLAFgQAAAAY29udGV4dF9zZXR0aW5nc3ECXXEDY09yYW5nZS53 -aWRnZXRzLnNldHRpbmdzCkNvbnRleHQKcQQpgXEFfXEGKFgKAAAAYXR0cmlidXRlc3EHKFgHAAAA -YWxwaGEgMHEISwKGcQlYBwAAAGFscGhhIDdxCksChnELWAgAAABhbHBoYSAxNHEMSwKGcQ1YCAAA -AGFscGhhIDIxcQ5LAoZxD1gIAAAAYWxwaGEgMjhxEEsChnERWAgAAABhbHBoYSAzNXESSwKGcRNY -CAAAAGFscGhhIDQycRRLAoZxFVgIAAAAYWxwaGEgNDlxFksChnEXWAgAAABhbHBoYSA1NnEYSwKG -cRlYCAAAAGFscGhhIDYzcRpLAoZxG1gIAAAAYWxwaGEgNzBxHEsChnEdWAgAAABhbHBoYSA3N3Ee -SwKGcR9YCAAAAGFscGhhIDg0cSBLAoZxIVgIAAAAYWxwaGEgOTFxIksChnEjWAgAAABhbHBoYSA5 -OHEkSwKGcSVYCQAAAGFscGhhIDEwNXEmSwKGcSdYCQAAAGFscGhhIDExMnEoSwKGcSlYCQAAAGFs -cGhhIDExOXEqSwKGcStYBQAAAEVsdSAwcSxLAoZxLVgGAAAARWx1IDMwcS5LAoZxL1gGAAAARWx1 -IDYwcTBLAoZxMVgGAAAARWx1IDkwcTJLAoZxM1gHAAAARWx1IDEyMHE0SwKGcTVYBwAAAEVsdSAx -NTBxNksChnE3WAcAAABFbHUgMTgwcThLAoZxOVgHAAAARWx1IDIxMHE6SwKGcTtYBwAAAEVsdSAy -NDBxPEsChnE9WAcAAABFbHUgMjcwcT5LAoZxP1gHAAAARWx1IDMwMHFASwKGcUFYBwAAAEVsdSAz -MzBxQksChnFDWAcAAABFbHUgMzYwcURLAoZxRVgHAAAARWx1IDM5MHFGSwKGcUdYCAAAAGNkYzE1 -IDEwcUhLAoZxSVgIAAAAY2RjMTUgMzBxSksChnFLWAgAAABjZGMxNSA1MHFMSwKGcU1YCAAAAGNk -YzE1IDcwcU5LAoZxT1gIAAAAY2RjMTUgOTBxUEsChnFRWAkAAABjZGMxNSAxMTBxUksChnFTWAkA -AABjZGMxNSAxMzBxVEsChnFVWAkAAABjZGMxNSAxNTBxVksChnFXWAkAAABjZGMxNSAxNzBxWEsC -hnFZWAkAAABjZGMxNSAxOTBxWksChnFbWAkAAABjZGMxNSAyMTBxXEsChnFdWAkAAABjZGMxNSAy -MzBxXksChnFfWAkAAABjZGMxNSAyNTBxYEsChnFhWAkAAABjZGMxNSAyNzBxYksChnFjWAkAAABj -ZGMxNSAyOTBxZEsChnFlWAUAAABzcG8gMHFmSwKGcWdYBQAAAHNwbyAycWhLAoZxaVgFAAAAc3Bv -IDVxaksChnFrWAUAAABzcG8gN3FsSwKGcW1YBQAAAHNwbyA5cW5LAoZxb1gGAAAAc3BvIDExcXBL -AoZxcVgGAAAAc3BvNSAycXJLAoZxc1gGAAAAc3BvNSA3cXRLAoZxdVgHAAAAc3BvNSAxMXF2SwKG -cXdYCgAAAHNwby0gZWFybHlxeEsChnF5WAgAAABzcG8tIG1pZHF6SwKGcXtYBgAAAGhlYXQgMHF8 -SwKGcX1YBwAAAGhlYXQgMTBxfksChnF/WAcAAABoZWF0IDIwcYBLAoZxgVgHAAAAaGVhdCA0MHGC -SwKGcYNYBwAAAGhlYXQgODBxhEsChnGFWAgAAABoZWF0IDE2MHGGSwKGcYdYBgAAAGR0dCAxNXGI -SwKGcYlYBgAAAGR0dCAzMHGKSwKGcYtYBgAAAGR0dCA2MHGMSwKGcY1YBwAAAGR0dCAxMjBxjksC -hnGPWAYAAABjb2xkIDBxkEsChnGRWAcAAABjb2xkIDIwcZJLAoZxk1gHAAAAY29sZCA0MHGUSwKG -cZVYCAAAAGNvbGQgMTYwcZZLAoZxl1gGAAAAZGlhdSBhcZhLAoZxmVgGAAAAZGlhdSBicZpLAoZx -m1gGAAAAZGlhdSBjcZxLAoZxnVgGAAAAZGlhdSBkcZ5LAoZxn1gGAAAAZGlhdSBlcaBLAoZxoVgG -AAAAZGlhdSBmcaJLAoZxo1gGAAAAZGlhdSBncaRLAoZxpXRxplgKAAAAY2xhc3NfdmFyc3GnWAgA -AABmdW5jdGlvbnGoSwGGcamFcapYBQAAAG1ldGFzcatYBAAAAGdlbmVxrEsDhnGthXGuWAYAAAB2 -YWx1ZXNxr31xsChYBwAAAHNvcnRpbmdxsUsASv7///+GcbJYCAAAAGNvbG9ybWFwcbNLEkr+//// -hnG0WAsAAABjb2xvcl9nYW1tYXG1RwAAAAAAAAAASv7///+GcbZYCgAAAGF1dG9jb21taXRxt4hK -/v///4ZxuFgOAAAAYW5ub3RhdGlvbl9pZHhxuUsASv7///+GcbpYCQAAAGNvbG9yX2xvd3G7RwAA -AAAAAAAASv7///+GcbxYCgAAAGNvbG9yX2hpZ2hxvUc/8AAAAAAAAEr+////hnG+WBMAAABzYXZl -ZFdpZGdldEdlb21ldHJ5cb9OSv7///+GccB1WAQAAAB0aW1lccFHQdXglXYBJ4VYDgAAAG9yZGVy -ZWRfZG9tYWluccJdccMoaAhLAoZxxGgKSwKGccVoDEsChnHGaA5LAoZxx2gQSwKGcchoEksChnHJ -aBRLAoZxymgWSwKGcctoGEsChnHMaBpLAoZxzWgcSwKGcc5oHksChnHPaCBLAoZx0GgiSwKGcdFo -JEsChnHSaCZLAoZx02goSwKGcdRoKksChnHVaCxLAoZx1mguSwKGcddoMEsChnHYaDJLAoZx2Wg0 -SwKGcdpoNksChnHbaDhLAoZx3Gg6SwKGcd1oPEsChnHeaD5LAoZx32hASwKGceBoQksChnHhaERL -AoZx4mhGSwKGceNoSEsChnHkaEpLAoZx5WhMSwKGceZoTksChnHnaFBLAoZx6GhSSwKGceloVEsC -hnHqaFZLAoZx62hYSwKGcexoWksChnHtaFxLAoZx7mheSwKGce9oYEsChnHwaGJLAoZx8WhkSwKG -cfJoZksChnHzaGhLAoZx9GhqSwKGcfVobEsChnH2aG5LAoZx92hwSwKGcfhocksChnH5aHRLAoZx -+mh2SwKGcftoeEsChnH8aHpLAoZx/Wh8SwKGcf5ofksChnH/aIBLAoZyAAEAAGiCSwKGcgEBAABo -hEsChnICAQAAaIZLAoZyAwEAAGiISwKGcgQBAABoiksChnIFAQAAaIxLAoZyBgEAAGiOSwKGcgcB -AABokEsChnIIAQAAaJJLAoZyCQEAAGiUSwKGcgoBAABolksChnILAQAAaJhLAoZyDAEAAGiaSwKG -cg0BAABonEsChnIOAQAAaJ5LAoZyDwEAAGigSwKGchABAABooksChnIRAQAAaKRLAoZyEgEAAGio -SwGGchMBAABorEsDhnIUAQAAZXViYVgKAAAAYXV0b2NvbW1pdHIVAQAAiFgLAAAAY29sb3JfZ2Ft -bWFyFgEAAEcAAAAAAAAAAFgJAAAAY29sb3JfbG93chcBAABHAAAAAAAAAABYCgAAAGNvbG9yX2hp -Z2hyGAEAAEc/8AAAAAAAAFgTAAAAc2F2ZWRXaWRnZXRHZW9tZXRyeXIZAQAATlgIAAAAY29sb3Jt -YXByGgEAAEsSdS4= + gASVsQUAAAAAAAB9lCiMF2Fubm90YXRpb25faWZfZW51bWVyYXRllIwLRW51bWVyYXRpb26UjBNh +bm5vdGF0aW9uX2lmX25hbWVzlIwETmFtZZSMCmF1dG9jb21taXSUiIwSY29udHJvbEFyZWFWaXNp +YmxllIiMCWN1dF9yYXRpb5RHQFLAAAAAAACMEWxhYmVsX29ubHlfc3Vic2V0lImMB2xpbmthZ2WU +SwGMCW1heF9kZXB0aJRLCowHcHJ1bmluZ5RLAIwTc2F2ZWRXaWRnZXRHZW9tZXRyeZROjBBzZWxl +Y3Rpb25fbWV0aG9klEsAjAV0b3BfbpRLA4wLem9vbV9mYWN0b3KUSwCMC19fdmVyc2lvbl9flEsC +jBRfX3Nlc3Npb25fc3RhdGVfZGF0YZR9lIwHdmVyc2lvbpRLAEsASwCHlHOMEGNvbnRleHRfc2V0 +dGluZ3OUXZSMFW9yYW5nZXdpZGdldC5zZXR0aW5nc5SMB0NvbnRleHSUk5QpgZR9lCiMBnZhbHVl +c5R9lCiMCmFubm90YXRpb26UjARnZW5llEtnhpSMCGNvbG9yX2J5lIwIZnVuY3Rpb26US2WGlGgQ +SwJ1jAphdHRyaWJ1dGVzlH2UKIwHYWxwaGEgMJRLAowHYWxwaGEgN5RLAowIYWxwaGEgMTSUSwKM +CGFscGhhIDIxlEsCjAhhbHBoYSAyOJRLAowIYWxwaGEgMzWUSwKMCGFscGhhIDQylEsCjAhhbHBo +YSA0OZRLAowIYWxwaGEgNTaUSwKMCGFscGhhIDYzlEsCjAhhbHBoYSA3MJRLAowIYWxwaGEgNzeU +SwKMCGFscGhhIDg0lEsCjAhhbHBoYSA5MZRLAowIYWxwaGEgOTiUSwKMCWFscGhhIDEwNZRLAowJ +YWxwaGEgMTEylEsCjAlhbHBoYSAxMTmUSwKMBUVsdSAwlEsCjAZFbHUgMzCUSwKMBkVsdSA2MJRL +AowGRWx1IDkwlEsCjAdFbHUgMTIwlEsCjAdFbHUgMTUwlEsCjAdFbHUgMTgwlEsCjAdFbHUgMjEw +lEsCjAdFbHUgMjQwlEsCjAdFbHUgMjcwlEsCjAdFbHUgMzAwlEsCjAdFbHUgMzMwlEsCjAdFbHUg +MzYwlEsCjAdFbHUgMzkwlEsCjAhjZGMxNSAxMJRLAowIY2RjMTUgMzCUSwKMCGNkYzE1IDUwlEsC +jAhjZGMxNSA3MJRLAowIY2RjMTUgOTCUSwKMCWNkYzE1IDExMJRLAowJY2RjMTUgMTMwlEsCjAlj +ZGMxNSAxNTCUSwKMCWNkYzE1IDE3MJRLAowJY2RjMTUgMTkwlEsCjAljZGMxNSAyMTCUSwKMCWNk +YzE1IDIzMJRLAowJY2RjMTUgMjUwlEsCjAljZGMxNSAyNzCUSwKMCWNkYzE1IDI5MJRLAowFc3Bv +IDCUSwKMBXNwbyAylEsCjAVzcG8gNZRLAowFc3BvIDeUSwKMBXNwbyA5lEsCjAZzcG8gMTGUSwKM +BnNwbzUgMpRLAowGc3BvNSA3lEsCjAdzcG81IDExlEsCjApzcG8tIGVhcmx5lEsCjAhzcG8tIG1p +ZJRLAowGaGVhdCAwlEsCjAdoZWF0IDEwlEsCjAdoZWF0IDIwlEsCjAdoZWF0IDQwlEsCjAdoZWF0 +IDgwlEsCjAhoZWF0IDE2MJRLAowGZHR0IDE1lEsCjAZkdHQgMzCUSwKMBmR0dCA2MJRLAowHZHR0 +IDEyMJRLAowGY29sZCAwlEsCjAdjb2xkIDIwlEsCjAdjb2xkIDQwlEsCjAhjb2xkIDE2MJRLAowG +ZGlhdSBhlEsCjAZkaWF1IGKUSwKMBmRpYXUgY5RLAowGZGlhdSBklEsCjAZkaWF1IGWUSwKMBmRp +YXUgZpRLAowGZGlhdSBnlEsCaCJLAXWMBW1ldGFzlH2UaB9LA3N1YmF1Lg== - {'max_depth': 10, 'pruning': 0, 'linkage': 1, 'cluster_name': 'Cluster', 'autocommit': True, 'top_n': 3, 'zoom_factor': 0, 'savedWidgetGeometry': None, 'append_clusters': True, 'selection_method': 0, 'annotation_idx': 0, 'cluster_role': 2, 'cut_ratio': 75.0} - {'color_by_class': True, 'context_settings': [], 'select_rows': True, 'show_attribute_labels': True, 'dist_color_RGB': (220, 220, 220, 255), 'auto_commit': True, 'savedWidgetGeometry': None, 'show_distributions': False} - {'context_settings': [], 'stretched': True, 'stattest': 0, 'compare': 1, 'sig_threshold': 0.05, 'savedWidgetGeometry': None, 'show_annotations': True} + {'compare': 1, 'controlAreaVisible': True, 'order_by_importance': False, 'order_grouping_by_importance': False, 'savedWidgetGeometry': None, 'show_annotations': True, 'show_labels': True, 'sig_threshold': 0.05, 'sort_freqs': False, 'stattest': 0, 'stretched': True, '__version__': 1, 'context_settings': []} + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': b'\x01\xd9\xd0\xcb\x00\x03\x00\x00\x00\x00\x01Z\x00\x00\x00\xdc\x00\x00\x04y\x00\x00\x02\xeb\x00\x00\x01Z\x00\x00\x00\xf8\x00\x00\x04y\x00\x00\x02\xeb\x00\x00\x00\x00\x00\x00\x00\x00\x05\xe8\x00\x00\x01Z\x00\x00\x00\xf8\x00\x00\x04y\x00\x00\x02\xeb', 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, '__version__': 1} + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, '__version__': 1} + + + diff --git a/Orange/canvas/workflows/410-feature-ranking.ows b/Orange/canvas/workflows/410-feature-ranking.ows index adfe7d392a5..c36e95493fa 100644 --- a/Orange/canvas/workflows/410-feature-ranking.ows +++ b/Orange/canvas/workflows/410-feature-ranking.ows @@ -1,104 +1,189 @@ - + - - - - + + + + - - - + + + - We imputed the missing values to be able to visualize all the data points. - Scatter plot with most informative features. Do they provide a good separation of classes? Open the widget to check this out. - Displays the feature scores. We used the widget to select two most informative features. - - - + We imputed the missing values to be able to visualize all the data points. + Scatter plot with most informative features. Do they provide a good separation of classes? Open the widget to check this out. + Displays the feature scores. We used the widget to select two most informative features. + + + - gAN9cQAoWAYAAABzb3VyY2VxAUsAWAwAAAByZWNlbnRfcGF0aHNxAl1xAyhjT3JhbmdlLndpZGdl -dHMudXRpbHMuZmlsZWRpYWxvZ3MKUmVjZW50UGF0aApxBCmBcQV9cQYoWAYAAABwcmVmaXhxB1gP -AAAAc2FtcGxlLWRhdGFzZXRzcQhYBwAAAGFic3BhdGhxCVhCAAAAL1VzZXJzL2JsYXovRHJvcGJv -eC9kZXYvb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvYnJvd24tc2VsZWN0ZWQudGFicQpYBwAAAHJl -bHBhdGhxC1gSAAAAYnJvd24tc2VsZWN0ZWQudGFicQxYBQAAAHNoZWV0cQ1YAAAAAHEOWAUAAAB0 -aXRsZXEPaA51YmgEKYFxEH1xEShoB2gIaAlYOAAAAC9Vc2Vycy9ibGF6L0Ryb3Bib3gvZGV2L29y -YW5nZTMvT3JhbmdlL2RhdGFzZXRzL2lyaXMudGFicRJoC1gIAAAAaXJpcy50YWJxE2gNaA5oD2gO -dWJlWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cRRDLgHZ0MsAAQAAAAAD/wAAAlwAAAXwAAAEQwAA -A/8AAAJyAAAF8AAABEMAAAAAAABxFVgDAAAAdXJscRZoDlgQAAAAY29udGV4dF9zZXR0aW5nc3EX -XXEYWAsAAABzaGVldF9uYW1lc3EZfXEaWAsAAAByZWNlbnRfdXJsc3EbXXEcdS4= + gASVxwsAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZQojB5v +cmFuZ2V3aWRnZXQudXRpbHMuZmlsZWRpYWxvZ3OUjApSZWNlbnRQYXRolJOUKYGUfZQojAdhYnNw +YXRolIw3L1VzZXJzL2phbmV6L29yYW5nZTMvT3JhbmdlL2RhdGFzZXRzL2Jyb3duLXNlbGVjdGVk +LnRhYpSMBnByZWZpeJSMD3NhbXBsZS1kYXRhc2V0c5SMB3JlbHBhdGiUjBJicm93bi1zZWxlY3Rl +ZC50YWKUjAV0aXRsZZSMAJSMBXNoZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJoBimBlH2UKGgJjC0v +VXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUaAtoDGgNjAhpcmlz +LnRhYpRoD2gQaBFoEGgSTnViZYwLcmVjZW50X3VybHOUXZSME3NhdmVkV2lkZ2V0R2VvbWV0cnmU +Qy4B2dDLAAEAAAAAA/8AAAJcAAAF8AAABEMAAAP/AAACcgAABfAAAARDAAAAAAAAlIwLc2hlZXRf +bmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtfX3ZlcnNpb25f +X5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4 +dJSTlCmBlH2UKIwGdmFsdWVzlH2UKIwJdmFyaWFibGVzlF2UaB99lGgrXZQoXZQojAdhbHBoYSAw +lIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJDb250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiM +B2FscGhhIDeUaDNLAGgQiGVdlCiMCGFscGhhIDE0lGgzSwBoEIhlXZQojAhhbHBoYSAyMZRoM0sA +aBCIZV2UKIwIYWxwaGEgMjiUaDNLAGgQiGVdlCiMCGFscGhhIDM1lGgzSwBoEIhlXZQojAhhbHBo +YSA0MpRoM0sAaBCIZV2UKIwIYWxwaGEgNDmUaDNLAGgQiGVdlCiMCGFscGhhIDU2lGgzSwBoEIhl +XZQojAhhbHBoYSA2M5RoM0sAaBCIZV2UKIwIYWxwaGEgNzCUaDNLAGgQiGVdlCiMCGFscGhhIDc3 +lGgzSwBoEIhlXZQojAhhbHBoYSA4NJRoM0sAaBCIZV2UKIwIYWxwaGEgOTGUaDNLAGgQiGVdlCiM +CGFscGhhIDk4lGgzSwBoEIhlXZQojAlhbHBoYSAxMDWUaDNLAGgQiGVdlCiMCWFscGhhIDExMpRo +M0sAaBCIZV2UKIwJYWxwaGEgMTE5lGgzSwBoEIhlXZQojAVFbHUgMJRoM0sAaBCIZV2UKIwGRWx1 +IDMwlGgzSwBoEIhlXZQojAZFbHUgNjCUaDNLAGgQiGVdlCiMBkVsdSA5MJRoM0sAaBCIZV2UKIwH +RWx1IDEyMJRoM0sAaBCIZV2UKIwHRWx1IDE1MJRoM0sAaBCIZV2UKIwHRWx1IDE4MJRoM0sAaBCI +ZV2UKIwHRWx1IDIxMJRoM0sAaBCIZV2UKIwHRWx1IDI0MJRoM0sAaBCIZV2UKIwHRWx1IDI3MJRo +M0sAaBCIZV2UKIwHRWx1IDMwMJRoM0sAaBCIZV2UKIwHRWx1IDMzMJRoM0sAaBCIZV2UKIwHRWx1 +IDM2MJRoM0sAaBCIZV2UKIwHRWx1IDM5MJRoM0sAaBCIZV2UKIwIY2RjMTUgMTCUaDNLAGgQiGVd +lCiMCGNkYzE1IDMwlGgzSwBoEIhlXZQojAhjZGMxNSA1MJRoM0sAaBCIZV2UKIwIY2RjMTUgNzCU +aDNLAGgQiGVdlCiMCGNkYzE1IDkwlGgzSwBoEIhlXZQojAljZGMxNSAxMTCUaDNLAGgQiGVdlCiM +CWNkYzE1IDEzMJRoM0sAaBCIZV2UKIwJY2RjMTUgMTUwlGgzSwBoEIhlXZQojAljZGMxNSAxNzCU +aDNLAGgQiGVdlCiMCWNkYzE1IDE5MJRoM0sAaBCIZV2UKIwJY2RjMTUgMjEwlGgzSwBoEIhlXZQo +jAljZGMxNSAyMzCUaDNLAGgQiGVdlCiMCWNkYzE1IDI1MJRoM0sAaBCIZV2UKIwJY2RjMTUgMjcw +lGgzSwBoEIhlXZQojAljZGMxNSAyOTCUaDNLAGgQiGVdlCiMBXNwbyAwlGgzSwBoEIhlXZQojAVz +cG8gMpRoM0sAaBCIZV2UKIwFc3BvIDWUaDNLAGgQiGVdlCiMBXNwbyA3lGgzSwBoEIhlXZQojAVz +cG8gOZRoM0sAaBCIZV2UKIwGc3BvIDExlGgzSwBoEIhlXZQojAZzcG81IDKUaDNLAGgQiGVdlCiM +BnNwbzUgN5RoM0sAaBCIZV2UKIwHc3BvNSAxMZRoM0sAaBCIZV2UKIwKc3BvLSBlYXJseZRoM0sA +aBCIZV2UKIwIc3BvLSBtaWSUaDNLAGgQiGVdlCiMBmhlYXQgMJRoM0sAaBCIZV2UKIwHaGVhdCAx +MJRoM0sAaBCIZV2UKIwHaGVhdCAyMJRoM0sAaBCIZV2UKIwHaGVhdCA0MJRoM0sAaBCIZV2UKIwH +aGVhdCA4MJRoM0sAaBCIZV2UKIwIaGVhdCAxNjCUaDNLAGgQiGVdlCiMBmR0dCAxNZRoM0sAaBCI +ZV2UKIwGZHR0IDMwlGgzSwBoEIhlXZQojAZkdHQgNjCUaDNLAGgQiGVdlCiMB2R0dCAxMjCUaDNL +AGgQiGVdlCiMBmNvbGQgMJRoM0sAaBCIZV2UKIwHY29sZCAyMJRoM0sAaBCIZV2UKIwHY29sZCA0 +MJRoM0sAaBCIZV2UKIwIY29sZCAxNjCUaDNLAGgQiGVdlCiMBmRpYXUgYZRoM0sAaBCIZV2UKIwG +ZGlhdSBilGgzSwBoEIhlXZQojAZkaWF1IGOUaDNLAGgQiGVdlCiMBmRpYXUgZJRoM0sAaBCIZV2U +KIwGZGlhdSBllGgzSwBoEIhlXZQojAZkaWF1IGaUaDNLAGgQiGVdlCiMBmRpYXUgZ5RoM0sAaBCI +ZV2UKIwIZnVuY3Rpb26UaDGMEERpc2NyZXRlVmFyaWFibGWUk5RLAYwTUHJvdGVhcywgUmVzcCwg +Umlib5SJZV2UKIwEZ2VuZZRoMYwOU3RyaW5nVmFyaWFibGWUk5RLAmgQiWVlc2ghSwF1jAphdHRy +aWJ1dGVzlChoMEsChpRoNUsChpRoN0sChpRoOUsChpRoO0sChpRoPUsChpRoP0sChpRoQUsChpRo +Q0sChpRoRUsChpRoR0sChpRoSUsChpRoS0sChpRoTUsChpRoT0sChpRoUUsChpRoU0sChpRoVUsC +hpRoV0sChpRoWUsChpRoW0sChpRoXUsChpRoX0sChpRoYUsChpRoY0sChpRoZUsChpRoZ0sChpRo +aUsChpRoa0sChpRobUsChpRob0sChpRocUsChpRoc0sChpRodUsChpRod0sChpRoeUsChpRoe0sC +hpRofUsChpRof0sChpRogUsChpRog0sChpRohUsChpRoh0sChpRoiUsChpRoi0sChpRojUsChpRo +j0sChpRokUsChpRok0sChpRolUsChpRol0sChpRomUsChpRom0sChpRonUsChpRon0sChpRooUsC +hpRoo0sChpRopUsChpRop0sChpRoqUsChpRoq0sChpRorUsChpRor0sChpRosUsChpRos0sChpRo +tUsChpRot0sChpRouUsChpRou0sChpRovUsChpRov0sChpRowUsChpRow0sChpRoxUsChpRox0sC +hpRoyUsChpRoy0sChpRozUsChpRoz0sChpR0lIwFbWV0YXOUaNZLA4aUhZSMCmNsYXNzX3ZhcnOU +aNFdlCiMB1Byb3RlYXOUjARSZXNwlIwEUmlib5RlhpSFlIwSbW9kaWZpZWRfdmFyaWFibGVzlF2U +dWJhdS4= - gAN9cQAoWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cQFDLgHZ0MsAAQAAAAACtQAAAQoAAAc7AAAE -WgAAArUAAAEgAAAHOwAABFoAAAAAAABxAlgKAAAAYXV0b19hcHBseXEDiFgLAAAAaGVhZGVyU3Rh -dGVxBENmAAAA/wAAAAAAAAABAAAAAQAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAtAAAAAIAAEBAAAA -AAAAAAAAAAAAAGT/////AAAAhAAAAAAAAAACAAAAFAAAAAEAAAAAAAACvAAAAAcAAAAAcQVDZgAA -AP8AAAAAAAAAAQAAAAEAAAABAQAAAAAAAAAAAAAAAAAAAAAAAADcAAAAAwABAQAAAAAAAAAAAAAA -AABk/////wAAAIQAAAAAAAAAAgAAABQAAAABAAAAAAAAAMgAAAACAAAAAHEGhnEHWAwAAABzZWxl -Y3RNZXRob2RxCEsDWAkAAABuU2VsZWN0ZWRxCUsCdS4= + gASVmAUAAAAAAAB9lCiMCmF1dG9fYXBwbHmUiIwSY29udHJvbEFyZWFWaXNpYmxllIiME3NhdmVk +V2lkZ2V0R2VvbWV0cnmUQy4B2dDLAAEAAAAAArUAAAEKAAAHOwAABFoAAAK1AAABIAAABzsAAARa +AAAAAAAAlIwQc2VsZWN0ZWRfbWV0aG9kc5SPlCiMCFJSZWxpZWZGlIwVVW5pdmFyaWF0ZSBSZWdy +ZXNzaW9ulIwNR2luaSBEZWNyZWFzZZSMFkluZm9ybWF0aW9uIEdhaW4gUmF0aW+UkIwHc29ydGlu +Z5RLAEsBhpSMC19fdmVyc2lvbl9flEsEjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRn +ZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOUKYGUfZQojAZ2YWx1ZXOUfZQojAluU2VsZWN0ZWSUSwJK +/v///4aUjA5zZWxlY3RlZF9hdHRyc5RdlCiMBmRpYXUgZpRLZoaUjApzcG8tIGVhcmx5lEtmhpRl +Sv3///+GlIwPc2VsZWN0aW9uTWV0aG9klEsDSv7///+GlGgNSwR1jAphdHRyaWJ1dGVzlH2UKIwH +YWxwaGEgMJRLAowHYWxwaGEgN5RLAowIYWxwaGEgMTSUSwKMCGFscGhhIDIxlEsCjAhhbHBoYSAy +OJRLAowIYWxwaGEgMzWUSwKMCGFscGhhIDQylEsCjAhhbHBoYSA0OZRLAowIYWxwaGEgNTaUSwKM +CGFscGhhIDYzlEsCjAhhbHBoYSA3MJRLAowIYWxwaGEgNzeUSwKMCGFscGhhIDg0lEsCjAhhbHBo +YSA5MZRLAowIYWxwaGEgOTiUSwKMCWFscGhhIDEwNZRLAowJYWxwaGEgMTEylEsCjAlhbHBoYSAx +MTmUSwKMBUVsdSAwlEsCjAZFbHUgMzCUSwKMBkVsdSA2MJRLAowGRWx1IDkwlEsCjAdFbHUgMTIw +lEsCjAdFbHUgMTUwlEsCjAdFbHUgMTgwlEsCjAdFbHUgMjEwlEsCjAdFbHUgMjQwlEsCjAdFbHUg +MjcwlEsCjAdFbHUgMzAwlEsCjAdFbHUgMzMwlEsCjAdFbHUgMzYwlEsCjAdFbHUgMzkwlEsCjAhj +ZGMxNSAxMJRLAowIY2RjMTUgMzCUSwKMCGNkYzE1IDUwlEsCjAhjZGMxNSA3MJRLAowIY2RjMTUg +OTCUSwKMCWNkYzE1IDExMJRLAowJY2RjMTUgMTMwlEsCjAljZGMxNSAxNTCUSwKMCWNkYzE1IDE3 +MJRLAowJY2RjMTUgMTkwlEsCjAljZGMxNSAyMTCUSwKMCWNkYzE1IDIzMJRLAowJY2RjMTUgMjUw +lEsCjAljZGMxNSAyNzCUSwKMCWNkYzE1IDI5MJRLAowFc3BvIDCUSwKMBXNwbyAylEsCjAVzcG8g +NZRLAowFc3BvIDeUSwKMBXNwbyA5lEsCjAZzcG8gMTGUSwKMBnNwbzUgMpRLAowGc3BvNSA3lEsC +jAdzcG81IDExlEsCaB1LAowIc3BvLSBtaWSUSwKMBmhlYXQgMJRLAowHaGVhdCAxMJRLAowHaGVh +dCAyMJRLAowHaGVhdCA0MJRLAowHaGVhdCA4MJRLAowIaGVhdCAxNjCUSwKMBmR0dCAxNZRLAowG +ZHR0IDMwlEsCjAZkdHQgNjCUSwKMB2R0dCAxMjCUSwKMBmNvbGQgMJRLAowHY29sZCAyMJRLAowH +Y29sZCA0MJRLAowIY29sZCAxNjCUSwKMBmRpYXUgYZRLAowGZGlhdSBilEsCjAZkaWF1IGOUSwKM +BmRpYXUgZJRLAowGZGlhdSBllEsCaBtLAowGZGlhdSBnlEsCjAhmdW5jdGlvbpRLAXWMBW1ldGFz +lH2UjARnZW5llEsDc3ViYXUu - gAN9cQAoWBMAAABhdXRvX3NlbmRfc2VsZWN0aW9ucQGIWAsAAABhdXRvX3NhbXBsZXECiFgTAAAA -c2F2ZWRXaWRnZXRHZW9tZXRyeXEDQy4B2dDLAAEAAAAAA60AAAGsAAAGbAAABJ0AAAOtAAABwgAA -BmwAAASdAAAAAAAAcQRYEQAAAHRvb2xiYXJfc2VsZWN0aW9ucQVLAFgFAAAAZ3JhcGhxBn1xByhY -DQAAAGNsYXNzX2RlbnNpdHlxCIlYCwAAAHNob3dfbGVnZW5kcQmIWBEAAAB0b29sdGlwX3Nob3dz -X2FsbHEKiVgJAAAAc2hvd19ncmlkcQuJWAsAAABqaXR0ZXJfc2l6ZXEMSwpYEQAAAGppdHRlcl9j -b250aW51b3VzcQ2JWAsAAABhbHBoYV92YWx1ZXEOS4BYEwAAAGxhYmVsX29ubHlfc2VsZWN0ZWRx -D4lYCwAAAHBvaW50X3dpZHRocRBLCnVYEAAAAGNvbnRleHRfc2V0dGluZ3NxEV1xEmNPcmFuZ2Uu -d2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0CnETKYFxFH1xFShYBQAAAG1ldGFzcRZ9cRdYBgAAAHZh -bHVlc3EYfXEZKGgBiEr+////hnEaaAKISv7///+GcRtoBn1xHChYCgAAAGF0dHJfY29sb3JxHVgI -AAAAZnVuY3Rpb25xHksBhnEfWAoAAABhdHRyX3NoYXBlcSBYAAAAAHEhSv7///+GcSJoDEsKSv7/ -//+GcSNoDkuASv7///+GcSRoCIlK/v///4ZxJWgJiEr+////hnEmaAqJSv7///+GcSdoC4lK/v// -/4ZxKFgJAAAAYXR0cl9zaXplcSloIUr+////hnEqaA+JSv7///+GcStoDYlK/v///4ZxLFgKAAAA -YXR0cl9sYWJlbHEtaCFK/v///4ZxLmgQSwpK/v///4ZxL3VoA2gESv7///+GcTBYBgAAAGF0dHJf -eHExWAYAAABkaWF1IGZxMksChnEzaAVLAEr+////hnE0WAYAAABhdHRyX3lxNVgKAAAAc3BvLSBl -YXJseXE2SwKGcTd1WAQAAAB0aW1lcThHQdXgoeNUYjVYCgAAAGF0dHJpYnV0ZXNxOX1xOihYCgAA -AHNwby0gZWFybHlxO0sCaB5LAVgGAAAAZGlhdSBmcTxLAnVYDgAAAG9yZGVyZWRfZG9tYWlucT1d -cT4oaDxLAoZxP2g7SwKGcUBoHksBhnFBZXViYXUu + gASVAwUAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAOtAAABrAAABmwAAASd +AAADrQAAAcIAAAZsAAAEnQAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiYwRaml0dGVyX2NvbnRpbnVvdXOUiYwLaml0dGVyX3NpemWUSwqME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UKIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJST +lCmBlH2UKIwGdmFsdWVzlH2UKIwKYXR0cl9jb2xvcpSMCGZ1bmN0aW9ulEtlhpSMCmF0dHJfbGFi +ZWyUTkr+////hpSMCmF0dHJfc2hhcGWUTkr+////hpSMCWF0dHJfc2l6ZZROSv7///+GlIwGYXR0 +cl94lIwGZGlhdSBmlEtmhpSMBmF0dHJfeZSMCnNwby0gZWFybHmUS2aGlGgKfZRoFksFdYwKYXR0 +cmlidXRlc5R9lChoKksCaC1LAmghSwF1jAVtZXRhc5R9lIwEZ2VuZZRLA3N1YmgbKYGUfZQoaDJ9 +lGgefZQojBNhdXRvX3NlbmRfc2VsZWN0aW9ulIhK/v///4aUjAthdXRvX3NhbXBsZZSISv7///+G +lIwFZ3JhcGiUfZQojAphdHRyX2NvbG9ylIwIZnVuY3Rpb26USwGGlIwKYXR0cl9zaGFwZZSMAJRK +/v///4aUjAtqaXR0ZXJfc2l6ZZRLCkr+////hpSMC2FscGhhX3ZhbHVllEuASv7///+GlIwNY2xh +c3NfZGVuc2l0eZSJSv7///+GlIwLc2hvd19sZWdlbmSUiEr+////hpSMEXRvb2x0aXBfc2hvd3Nf +YWxslIlK/v///4aUjAlzaG93X2dyaWSUiUr+////hpSMCWF0dHJfc2l6ZZRoQ0r+////hpSME2xh +YmVsX29ubHlfc2VsZWN0ZWSUiUr+////hpSMEWppdHRlcl9jb250aW51b3VzlIlK/v///4aUjAph +dHRyX2xhYmVslGhDSv7///+GlIwLcG9pbnRfd2lkdGiUSwpK/v///4aUdYwTc2F2ZWRXaWRnZXRH +ZW9tZXRyeZRoBUr+////hpSMBmF0dHJfeJSMBmRpYXUgZpRLAoaUjBF0b29sYmFyX3NlbGVjdGlv +bpRLAEr+////hpSMBmF0dHJfeZSMCnNwby0gZWFybHmUSwKGlGggaEFoJ2hSaCVoRGgjaFhoFksF +dYwEdGltZZRHQdXgoeNUYjVoMH2UKIwKc3BvLSBlYXJseZRLAmhASwGMBmRpYXUgZpRLAnWMDm9y +ZGVyZWRfZG9tYWlulF2UKGhoSwKGlGhnSwKGlGhASwGGlGV1YmV1Lg== - gAN9cQAoWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cQFDLgHZ0MsAAQAAAAADzQAAAboAAAYjAAAD -wgAAA80AAAHQAAAGIwAAA8IAAAAAAABxAlgQAAAAY29udGV4dF9zZXR0aW5nc3EDXXEEY09yYW5n -ZS53aWRnZXRzLnNldHRpbmdzCkNvbnRleHQKcQUpgXEGfXEHKFgFAAAAbWV0YXNxCH1xCVgGAAAA -dmFsdWVzcQp9cQsoaAFoAkr+////hnEMWBAAAAB2YXJpYWJsZV9tZXRob2RzcQ19cQ5K/v///4Zx -D1gVAAAAX2RlZmF1bHRfbWV0aG9kX2luZGV4cRBLAkr+////hnERWA0AAABkZWZhdWx0X3ZhbHVl -cRJHAAAAAAAAAABK/v///4ZxE1gKAAAAYXV0b2NvbW1pdHEUiUr+////hnEVdVgEAAAAdGltZXEW -R0HV4KHd5jNRWAoAAABhdHRyaWJ1dGVzcRd9cRgoWAcAAABhbHBoYSAwcRlLAlgGAAAARWx1IDYw -cRpLAlgGAAAAZGlhdSBjcRtLAlgGAAAAc3BvNSAycRxLAlgJAAAAY2RjMTUgMTcwcR1LAlgJAAAA -Y2RjMTUgMTUwcR5LAlgJAAAAY2RjMTUgMjMwcR9LAlgFAAAARWx1IDBxIEsCWAUAAABzcG8gNXEh -SwJYBQAAAHNwbyA3cSJLAlgIAAAAYWxwaGEgNTZxI0sCWAgAAABhbHBoYSA2M3EkSwJYBwAAAEVs -dSAxNTBxJUsCWAcAAABjb2xkIDIwcSZLAlgHAAAARWx1IDI0MHEnSwJYBwAAAGhlYXQgMTBxKEsC -WAcAAABFbHUgMzMwcSlLAlgJAAAAY2RjMTUgMTEwcSpLAlgHAAAAc3BvNSAxMXErSwJYBwAAAEVs -dSAzMDBxLEsCWAcAAABFbHUgMTIwcS1LAlgIAAAAYWxwaGEgMzVxLksCWAcAAABoZWF0IDgwcS9L -AlgIAAAAYWxwaGEgMTRxMEsCWAcAAABoZWF0IDIwcTFLAlgGAAAARWx1IDMwcTJLAlgIAAAAY2Rj -MTUgMzBxM0sCWAgAAABjZGMxNSA5MHE0SwJYBgAAAHNwbzUgN3E1SwJYBgAAAGR0dCA2MHE2SwJY -BgAAAGR0dCAzMHE3SwJYBgAAAGRpYXUgZXE4SwJYCQAAAGFscGhhIDExMnE5SwJYBgAAAGRpYXUg -YnE6SwJYCAAAAGFscGhhIDc3cTtLAlgIAAAAYWxwaGEgMjFxPEsCWAkAAABjZGMxNSAyOTBxPUsC -WAgAAABhbHBoYSAyOHE+SwJYBQAAAHNwbyAwcT9LAlgGAAAARWx1IDkwcUBLAlgGAAAAaGVhdCAw -cUFLAlgGAAAAY29sZCAwcUJLAlgHAAAAZHR0IDEyMHFDSwJYCQAAAGNkYzE1IDI3MHFESwJYCQAA -AGNkYzE1IDI1MHFFSwJYCQAAAGFscGhhIDExOXFGSwJYBgAAAGRpYXUgYXFHSwJYCAAAAGFscGhh -IDQ5cUhLAlgHAAAARWx1IDM2MHFJSwJYBwAAAEVsdSAyMTBxSksCWAYAAABkaWF1IGZxS0sCWAcA -AABoZWF0IDQwcUxLAlgKAAAAc3BvLSBlYXJseXFNSwJYCAAAAGNkYzE1IDEwcU5LAlgHAAAAYWxw -aGEgN3FPSwJYBwAAAEVsdSAzOTBxUEsCWAkAAABjZGMxNSAxMzBxUUsCWAgAAABmdW5jdGlvbnFS -SwFYBgAAAGRpYXUgZ3FTSwJYCAAAAHNwby0gbWlkcVRLAlgIAAAAYWxwaGEgNDJxVUsCWAcAAABF -bHUgMjcwcVZLAlgHAAAAY29sZCA0MHFXSwJYCAAAAGFscGhhIDkxcVhLAlgIAAAAaGVhdCAxNjBx -WUsCWAUAAABzcG8gOXFaSwJYCAAAAGFscGhhIDk4cVtLAlgIAAAAYWxwaGEgODRxXEsCWAgAAABj -ZGMxNSA3MHFdSwJYBgAAAHNwbyAxMXFeSwJYCAAAAGFscGhhIDcwcV9LAlgHAAAARWx1IDE4MHFg -SwJYCAAAAGNkYzE1IDUwcWFLAlgIAAAAY29sZCAxNjBxYksCWAYAAABkaWF1IGRxY0sCWAYAAABk -dHQgMTVxZEsCWAUAAABzcG8gMnFlSwJYCQAAAGNkYzE1IDIxMHFmSwJYCQAAAGNkYzE1IDE5MHFn -SwJYCQAAAGFscGhhIDEwNXFoSwJ1WA4AAABvcmRlcmVkX2RvbWFpbnFpXXFqKGgZSwKGcWtoT0sC -hnFsaDBLAoZxbWg8SwKGcW5oPksChnFvaC5LAoZxcGhVSwKGcXFoSEsChnFyaCNLAoZxc2gkSwKG -cXRoX0sChnF1aDtLAoZxdmhcSwKGcXdoWEsChnF4aFtLAoZxeWhoSwKGcXpoOUsChnF7aEZLAoZx -fGggSwKGcX1oMksChnF+aBpLAoZxf2hASwKGcYBoLUsChnGBaCVLAoZxgmhgSwKGcYNoSksChnGE -aCdLAoZxhWhWSwKGcYZoLEsChnGHaClLAoZxiGhJSwKGcYloUEsChnGKaE5LAoZxi2gzSwKGcYxo -YUsChnGNaF1LAoZxjmg0SwKGcY9oKksChnGQaFFLAoZxkWgeSwKGcZJoHUsChnGTaGdLAoZxlGhm -SwKGcZVoH0sChnGWaEVLAoZxl2hESwKGcZhoPUsChnGZaD9LAoZxmmhlSwKGcZtoIUsChnGcaCJL -AoZxnWhaSwKGcZ5oXksChnGfaBxLAoZxoGg1SwKGcaFoK0sChnGiaE1LAoZxo2hUSwKGcaRoQUsC -hnGlaChLAoZxpmgxSwKGcadoTEsChnGoaC9LAoZxqWhZSwKGcapoZEsChnGraDdLAoZxrGg2SwKG -ca1oQ0sChnGuaEJLAoZxr2gmSwKGcbBoV0sChnGxaGJLAoZxsmhHSwKGcbNoOksChnG0aBtLAoZx -tWhjSwKGcbZoOEsChnG3aEtLAoZxuGhTSwKGcbloUksBhnG6ZXViYWgQSwJoEkcAAAAAAAAAAGgU -iXUu + gASVyQsAAAAAAAB9lCiMFV9kZWZhdWx0X21ldGhvZF9pbmRleJRLAowKYXV0b2NvbW1pdJSJjBJj +b250cm9sQXJlYVZpc2libGWUiIwVZGVmYXVsdF9udW1lcmljX3ZhbHVllEcAAAAAAAAAAIwMZGVm +YXVsdF90aW1llEsAjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAPNAAABugAABiMA +AAPCAAADzQAAAdAAAAYjAAADwgAAAAAAAJSMC19fdmVyc2lvbl9flEsBjBBjb250ZXh0X3NldHRp +bmdzlF2UKIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJSTlCmBlH2UKIwGdmFsdWVz +lH2UKIwaX3ZhcmlhYmxlX2ltcHV0YXRpb25fc3RhdGWUfZRK/P///4aUaAhLAXWMCmF0dHJpYnV0 +ZXOUfZQojAdhbHBoYSAwlEsCjAdhbHBoYSA3lEsCjAhhbHBoYSAxNJRLAowIYWxwaGEgMjGUSwKM +CGFscGhhIDI4lEsCjAhhbHBoYSAzNZRLAowIYWxwaGEgNDKUSwKMCGFscGhhIDQ5lEsCjAhhbHBo +YSA1NpRLAowIYWxwaGEgNjOUSwKMCGFscGhhIDcwlEsCjAhhbHBoYSA3N5RLAowIYWxwaGEgODSU +SwKMCGFscGhhIDkxlEsCjAhhbHBoYSA5OJRLAowJYWxwaGEgMTA1lEsCjAlhbHBoYSAxMTKUSwKM +CWFscGhhIDExOZRLAowFRWx1IDCUSwKMBkVsdSAzMJRLAowGRWx1IDYwlEsCjAZFbHUgOTCUSwKM +B0VsdSAxMjCUSwKMB0VsdSAxNTCUSwKMB0VsdSAxODCUSwKMB0VsdSAyMTCUSwKMB0VsdSAyNDCU +SwKMB0VsdSAyNzCUSwKMB0VsdSAzMDCUSwKMB0VsdSAzMzCUSwKMB0VsdSAzNjCUSwKMB0VsdSAz +OTCUSwKMCGNkYzE1IDEwlEsCjAhjZGMxNSAzMJRLAowIY2RjMTUgNTCUSwKMCGNkYzE1IDcwlEsC +jAhjZGMxNSA5MJRLAowJY2RjMTUgMTEwlEsCjAljZGMxNSAxMzCUSwKMCWNkYzE1IDE1MJRLAowJ +Y2RjMTUgMTcwlEsCjAljZGMxNSAxOTCUSwKMCWNkYzE1IDIxMJRLAowJY2RjMTUgMjMwlEsCjAlj +ZGMxNSAyNTCUSwKMCWNkYzE1IDI3MJRLAowJY2RjMTUgMjkwlEsCjAVzcG8gMJRLAowFc3BvIDKU +SwKMBXNwbyA1lEsCjAVzcG8gN5RLAowFc3BvIDmUSwKMBnNwbyAxMZRLAowGc3BvNSAylEsCjAZz +cG81IDeUSwKMB3NwbzUgMTGUSwKMCnNwby0gZWFybHmUSwKMCHNwby0gbWlklEsCjAZoZWF0IDCU +SwKMB2hlYXQgMTCUSwKMB2hlYXQgMjCUSwKMB2hlYXQgNDCUSwKMB2hlYXQgODCUSwKMCGhlYXQg +MTYwlEsCjAZkdHQgMTWUSwKMBmR0dCAzMJRLAowGZHR0IDYwlEsCjAdkdHQgMTIwlEsCjAZjb2xk +IDCUSwKMB2NvbGQgMjCUSwKMB2NvbGQgNDCUSwKMCGNvbGQgMTYwlEsCjAZkaWF1IGGUSwKMBmRp +YXUgYpRLAowGZGlhdSBjlEsCjAZkaWF1IGSUSwKMBmRpYXUgZZRLAowGZGlhdSBmlEsCjAZkaWF1 +IGeUSwKMCGZ1bmN0aW9ulEsBdYwFbWV0YXOUfZSMBGdlbmWUSwNzdWJoDSmBlH2UKGhnfZRoEH2U +KIwTc2F2ZWRXaWRnZXRHZW9tZXRyeZRoB0r+////hpSMEHZhcmlhYmxlX21ldGhvZHOUfZRK/v// +/4aUjBVfZGVmYXVsdF9tZXRob2RfaW5kZXiUSwJK/v///4aUjA1kZWZhdWx0X3ZhbHVllEcAAAAA +AAAAAEr+////hpSMCmF1dG9jb21taXSUiUr+////hpRoCEsBdYwEdGltZZRHQdXgod3mM1FoFX2U +KIwHYWxwaGEgMJRLAowGRWx1IDYwlEsCjAZkaWF1IGOUSwKMBnNwbzUgMpRLAowJY2RjMTUgMTcw +lEsCjAljZGMxNSAxNTCUSwKMCWNkYzE1IDIzMJRLAowFRWx1IDCUSwKMBXNwbyA1lEsCjAVzcG8g +N5RLAowIYWxwaGEgNTaUSwKMCGFscGhhIDYzlEsCjAdFbHUgMTUwlEsCjAdjb2xkIDIwlEsCjAdF +bHUgMjQwlEsCjAdoZWF0IDEwlEsCjAdFbHUgMzMwlEsCjAljZGMxNSAxMTCUSwKMB3NwbzUgMTGU +SwKMB0VsdSAzMDCUSwKMB0VsdSAxMjCUSwKMCGFscGhhIDM1lEsCjAdoZWF0IDgwlEsCjAhhbHBo +YSAxNJRLAowHaGVhdCAyMJRLAowGRWx1IDMwlEsCjAhjZGMxNSAzMJRLAowIY2RjMTUgOTCUSwKM +BnNwbzUgN5RLAowGZHR0IDYwlEsCjAZkdHQgMzCUSwKMBmRpYXUgZZRLAowJYWxwaGEgMTEylEsC +jAZkaWF1IGKUSwKMCGFscGhhIDc3lEsCjAhhbHBoYSAyMZRLAowJY2RjMTUgMjkwlEsCjAhhbHBo +YSAyOJRLAowFc3BvIDCUSwKMBkVsdSA5MJRLAowGaGVhdCAwlEsCjAZjb2xkIDCUSwKMB2R0dCAx +MjCUSwKMCWNkYzE1IDI3MJRLAowJY2RjMTUgMjUwlEsCjAlhbHBoYSAxMTmUSwKMBmRpYXUgYZRL +AowIYWxwaGEgNDmUSwKMB0VsdSAzNjCUSwKMB0VsdSAyMTCUSwKMBmRpYXUgZpRLAowHaGVhdCA0 +MJRLAowKc3BvLSBlYXJseZRLAowIY2RjMTUgMTCUSwKMB2FscGhhIDeUSwKMB0VsdSAzOTCUSwKM +CWNkYzE1IDEzMJRLAowIZnVuY3Rpb26USwGMBmRpYXUgZ5RLAowIc3BvLSBtaWSUSwKMCGFscGhh +IDQylEsCjAdFbHUgMjcwlEsCjAdjb2xkIDQwlEsCjAhhbHBoYSA5MZRLAowIaGVhdCAxNjCUSwKM +BXNwbyA5lEsCjAhhbHBoYSA5OJRLAowIYWxwaGEgODSUSwKMCGNkYzE1IDcwlEsCjAZzcG8gMTGU +SwKMCGFscGhhIDcwlEsCjAdFbHUgMTgwlEsCjAhjZGMxNSA1MJRLAowIY29sZCAxNjCUSwKMBmRp +YXUgZJRLAowGZHR0IDE1lEsCjAVzcG8gMpRLAowJY2RjMTUgMjEwlEsCjAljZGMxNSAxOTCUSwKM +CWFscGhhIDEwNZRLAnWMDm9yZGVyZWRfZG9tYWlulF2UKGh7SwKGlGixSwKGlGiSSwKGlGieSwKG +lGigSwKGlGiQSwKGlGi3SwKGlGiqSwKGlGiFSwKGlGiGSwKGlGjBSwKGlGidSwKGlGi+SwKGlGi6 +SwKGlGi9SwKGlGjKSwKGlGibSwKGlGioSwKGlGiCSwKGlGiUSwKGlGh8SwKGlGiiSwKGlGiPSwKG +lGiHSwKGlGjCSwKGlGisSwKGlGiJSwKGlGi4SwKGlGiOSwKGlGiLSwKGlGirSwKGlGiySwKGlGiw +SwKGlGiVSwKGlGjDSwKGlGi/SwKGlGiWSwKGlGiMSwKGlGizSwKGlGiASwKGlGh/SwKGlGjJSwKG +lGjISwKGlGiBSwKGlGinSwKGlGimSwKGlGifSwKGlGihSwKGlGjHSwKGlGiDSwKGlGiESwKGlGi8 +SwKGlGjASwKGlGh+SwKGlGiXSwKGlGiNSwKGlGivSwKGlGi2SwKGlGijSwKGlGiKSwKGlGiTSwKG +lGiuSwKGlGiRSwKGlGi7SwKGlGjGSwKGlGiZSwKGlGiYSwKGlGilSwKGlGikSwKGlGiISwKGlGi5 +SwKGlGjESwKGlGipSwKGlGicSwKGlGh9SwKGlGjFSwKGlGiaSwKGlGitSwKGlGi1SwKGlGi0SwGG +lGV1YmV1Lg== + + + diff --git a/Orange/canvas/workflows/450-cross-validation.ows b/Orange/canvas/workflows/450-cross-validation.ows index 51fb6819cac..bad8db5f127 100644 --- a/Orange/canvas/workflows/450-cross-validation.ows +++ b/Orange/canvas/workflows/450-cross-validation.ows @@ -1,121 +1,92 @@ - + - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - Choose class-labeled dataset. Say, "iris.tab" from documentation datasets. - It's always a good idea to check out the data first. - Select a cell in confusion matrix to obtain related data instances. Here we examine them in the spreadheet. - Use for additional analysis of cross-validation results. - Cross-validation takes place here. Double click to see the performance scores. - Several learners can be scored in cross-validation at the same time. - - - - - - + Choose class-labeled dataset. Say, "iris.tab" from documentation datasets. + It's always a good idea to check out the data first. + Select a cell in confusion matrix to obtain related data instances. Here we examine them in the spreadheet. + Use for additional analysis of cross-validation results. + Cross-validation takes place here. Double click to see the performance scores. + Several learners can be scored in cross-validation at the same time. + + + + + + - gAN9cQAoWBIAAABjb250cm9sQXJlYVZpc2libGVxAYhYDAAAAHJlY2VudF9wYXRoc3ECXXEDY09y -YW5nZS53aWRnZXRzLnV0aWxzLmZpbGVkaWFsb2dzClJlY2VudFBhdGgKcQQpgXEFfXEGKFgHAAAA -YWJzcGF0aHEHWDAAAAAvVXNlcnMvYW56ZS9kZXYvb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJp -cy50YWJxCFgGAAAAcHJlZml4cQlYDwAAAHNhbXBsZS1kYXRhc2V0c3EKWAcAAAByZWxwYXRocQtY -CAAAAGlyaXMudGFicQxYBQAAAHRpdGxlcQ1YAAAAAHEOWAUAAABzaGVldHEPaA5YCwAAAGZpbGVf -Zm9ybWF0cRBOdWJhWAsAAAByZWNlbnRfdXJsc3ERXXESWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5 -cRNDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWgcRRY -CwAAAHNoZWV0X25hbWVzcRV9cRZYBgAAAHNvdXJjZXEXSwBYAwAAAHVybHEYaA5YDQAAAGRvbWFp -bl9lZGl0b3JxGX1xGlgLAAAAX192ZXJzaW9uX19xG0sBWBAAAABjb250ZXh0X3NldHRpbmdzcRxd -cR1jT3JhbmdlLndpZGdldHMuc2V0dGluZ3MKQ29udGV4dApxHimBcR99cSAoWAQAAAB0aW1lcSFH -QdanFjFxv6VYBgAAAHZhbHVlc3EifXEjKFgJAAAAdmFyaWFibGVzcSRdcSVYCQAAAHhsc19zaGVl -dHEmaA5K/////4ZxJ2gZfXEoaCRdcSkoXXEqKFgMAAAAc2VwYWwgbGVuZ3RocStjT3JhbmdlLmRh -dGEudmFyaWFibGUKQ29udGludW91c1ZhcmlhYmxlCnEsSwBoDohlXXEtKFgLAAAAc2VwYWwgd2lk -dGhxLmgsSwBoDohlXXEvKFgMAAAAcGV0YWwgbGVuZ3RocTBoLEsAaA6IZV1xMShYCwAAAHBldGFs -IHdpZHRocTJoLEsAaA6IZV1xMyhYBAAAAGlyaXNxNGNPcmFuZ2UuZGF0YS52YXJpYWJsZQpEaXNj -cmV0ZVZhcmlhYmxlCnE1SwFYLAAAAElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIElyaXMt -dmlyZ2luaWNhcTaJZWVzaBtLAXVYCgAAAGF0dHJpYnV0ZXNxNyhYDAAAAHNlcGFsIGxlbmd0aHE4 -SwKGcTlYCwAAAHNlcGFsIHdpZHRocTpLAoZxO1gMAAAAcGV0YWwgbGVuZ3RocTxLAoZxPVgLAAAA -cGV0YWwgd2lkdGhxPksChnE/dHFAWAUAAABtZXRhc3FBKVgKAAAAY2xhc3NfdmFyc3FCWAQAAABp -cmlzcUNdcUQoWAsAAABJcmlzLXNldG9zYXFFWA8AAABJcmlzLXZlcnNpY29sb3JxRlgOAAAASXJp -cy12aXJnaW5pY2FxR2WGcUiFcUlYEgAAAG1vZGlmaWVkX3ZhcmlhYmxlc3FKXXFLdWJhdS4= + gASVvAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWg +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwEdGltZZRHQdanFjFxv6WMBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoKF2UKF2UKIwMc2VwYWwgbGVuZ3RolIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJD +b250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGgwSwBoEIhlXZQojAxw +ZXRhbCBsZW5ndGiUaDBLAGgQiGVdlCiMC3BldGFsIHdpZHRolGgwSwBoEIhlXZQojARpcmlzlGgu +jBBEaXNjcmV0ZVZhcmlhYmxllJOUSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIEly +aXMtdmlyZ2luaWNhlIllZXNoHUsBdYwKYXR0cmlidXRlc5QojAxzZXBhbCBsZW5ndGiUSwKGlIwL +c2VwYWwgd2lkdGiUSwKGlIwMcGV0YWwgbGVuZ3RolEsChpSMC3BldGFsIHdpZHRolEsChpR0lIwF +bWV0YXOUKYwKY2xhc3NfdmFyc5SMBGlyaXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMtdmVyc2lj +b2xvcpSMDklyaXMtdmlyZ2luaWNhlGWGlIWUjBJtb2RpZmllZF92YXJpYWJsZXOUXZR1YmF1Lg== - gAN9cQAoWBIAAABjb250cm9sQXJlYVZpc2libGVxAYhYDQAAAGN2X3N0cmF0aWZpZWRxAohYBwAA -AG5fZm9sZHNxA0sDWAkAAABuX3JlcGVhdHNxBEsDWAoAAAByZXNhbXBsaW5ncQVLAFgLAAAAc2Ft -cGxlX3NpemVxBksJWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cQdDLgHZ0MsAAQAAAAADcgAAAnEA -AAZ9AAAD+gAAA3IAAAKHAAAGfQAAA/oAAAAAAABxCFgMAAAAc2hvd25fc2NvcmVzcQljYnVpbHRp -bnMKc2V0CnEKXXELKFgGAAAAUmVjYWxscQxYCQAAAFByZWNpc2lvbnENWAMAAABNQUVxDlgCAAAA -UjJxD1gEAAAAUk1TRXEQWAMAAABNU0VxEVgCAAAAQ0FxElgCAAAARjFxE1gDAAAAQVVDcRRlhXEV -UnEWWBIAAABzaHVmZmxlX3N0cmF0aWZpZWRxF4hYCwAAAF9fdmVyc2lvbl9fcRhLA1gQAAAAY29u -dGV4dF9zZXR0aW5nc3EZXXEaY09yYW5nZS53aWRnZXRzLnNldHRpbmdzCkNvbnRleHQKcRspgXEc -fXEdKFgEAAAAdGltZXEeR0HWpxYxhRFiWAYAAAB2YWx1ZXNxH31xIChYDwAAAGNsYXNzX3NlbGVj -dGlvbnEhWBYAAAAoQXZlcmFnZSBvdmVyIGNsYXNzZXMpcSJK/////4ZxI1gMAAAAZm9sZF9mZWF0 -dXJlcSROSv7///+GcSVYFQAAAGZvbGRfZmVhdHVyZV9zZWxlY3RlZHEmiUr+////hnEnaBhLA3VY -CgAAAGF0dHJpYnV0ZXNxKChYDAAAAHNlcGFsIGxlbmd0aHEpSwKGcSpYCwAAAHNlcGFsIHdpZHRo -cStLAoZxLFgMAAAAcGV0YWwgbGVuZ3RocS1LAoZxLlgLAAAAcGV0YWwgd2lkdGhxL0sChnEwdHEx -WAUAAABtZXRhc3EyKVgKAAAAY2xhc3NfdmFyc3EzWAQAAABpcmlzcTRLAYZxNYVxNnViYXUu + gASVjwMAAAAAAAB9lCiMFGNvbXBhcmlzb25fY3JpdGVyaW9ulEsAjBJjb250cm9sQXJlYVZpc2li +bGWUiIwNY3Zfc3RyYXRpZmllZJSIjAduX2ZvbGRzlEsDjAluX3JlcGVhdHOUSwOMCnJlc2FtcGxp +bmeUSwCMBHJvcGWURz+5mZmZmZmajAtzYW1wbGVfc2l6ZZRLCYwTc2F2ZWRXaWRnZXRHZW9tZXRy +eZRDLgHZ0MsAAQAAAAADcgAAAnEAAAZ9AAAD+gAAA3IAAAKHAAAGfQAAA/oAAAAAAACUjBJzaHVm +ZmxlX3N0cmF0aWZpZWSUiIwIdXNlX3JvcGWUiYwLc2NvcmVfdGFibGWUfZSMEHNob3dfc2NvcmVf +aGludHOUfZQojAZNb2RlbF+UiIwGVHJhaW5flImMBVRlc3RflImMAkNBlIiMF1ByZWNpc2lvblJl +Y2FsbEZTdXBwb3J0lIiMC1RhcmdldFNjb3JllIiMCVByZWNpc2lvbpSIjAZSZWNhbGyUiIwCRjGU +iIwDQVVDlIiMB0xvZ0xvc3OUiYwLU3BlY2lmaWNpdHmUiYwXTWF0dGhld3NDb3JyQ29lZmZpY2ll +bnSUiIwDTVNFlIiMBFJNU0WUiIwDTUFFlIiMAlIylIiMBkNWUk1TRZSJjA9DbHVzdGVyaW5nU2Nv +cmWUiIwKU2lsaG91ZXR0ZZSIjBdBZGp1c3RlZE11dHVhbEluZm9TY29yZZSIdXOMC19fdmVyc2lv +bl9flEsEjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250 +ZXh0lJOUKYGUfZQojAR0aW1llEdB1qcWMYURYowGdmFsdWVzlH2UKIwPY2xhc3Nfc2VsZWN0aW9u +lIwhKE5vbmUsIHNob3cgYXZlcmFnZSBvdmVyIGNsYXNzZXMplEr/////hpSMDGZvbGRfZmVhdHVy +ZZROSv7///+GlIwVZm9sZF9mZWF0dXJlX3NlbGVjdGVklIlK/v///4aUaA19lGgmSwR1jAphdHRy +aWJ1dGVzlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBhbCB3aWR0aJRLAoaUjAxwZXRhbCBsZW5n +dGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlHSUjAVtZXRhc5QpjApjbGFzc192YXJzlIwEaXJpc5RL +AYaUhZR1YmF1Lg== - {'C_index': 61, 'auto_apply': True, 'controlAreaVisible': True, 'learner_name': 'Logistic Regression', 'penalty_type': 1, 'savedWidgetGeometry': None, '__version__': 1} - {'auto_apply': True, 'controlAreaVisible': True, 'index_output': 0, 'learner_name': 'Random Forest Learner', 'max_depth': 3, 'max_features': 5, 'min_samples_split': 5, 'n_estimators': 10, 'random_state': 0, 'savedWidgetGeometry': None, 'use_max_depth': False, 'use_max_features': False, 'use_min_samples_split': True, 'use_random_state': False, '__version__': 1} - {'C': 1.0, 'auto_apply': True, 'coef0': 0.0, 'controlAreaVisible': True, 'degree': 3, 'epsilon': 0.1, 'gamma': 0.0, 'kernel_type': 0, 'learner_name': 'SVM Learner', 'limit_iter': True, 'max_iter': 100, 'nu': 0.5, 'nu_C': 1.0, 'savedWidgetGeometry': None, 'svm_type': 0, 'tol': 0.001, '__version__': 1} - gAN9cQAoWAsAAABhdXRvX2NvbW1pdHEBiFgOAAAAY29sb3JfYnlfY2xhc3NxAohYEgAAAGNvbnRy -b2xBcmVhVmlzaWJsZXEDiFgOAAAAZGlzdF9jb2xvcl9SR0JxBChL3EvcS9xL/3RxBVgTAAAAc2F2 -ZWRXaWRnZXRHZW9tZXRyeXEGTlgLAAAAc2VsZWN0X3Jvd3NxB4hYFQAAAHNob3dfYXR0cmlidXRl -X2xhYmVsc3EIiFgSAAAAc2hvd19kaXN0cmlidXRpb25zcQmJWAsAAABfX3ZlcnNpb25fX3EKSwFY -EAAAAGNvbnRleHRfc2V0dGluZ3NxC11xDGNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0 -CnENKYFxDn1xDyhYDgAAAG9yZGVyZWRfZG9tYWlucRBdcREoWAwAAABzZXBhbCBsZW5ndGhxEksC -hnETWAsAAABzZXBhbCB3aWR0aHEUSwKGcRVYDAAAAHBldGFsIGxlbmd0aHEWSwKGcRdYCwAAAHBl -dGFsIHdpZHRocRhLAoZxGVgEAAAAaXJpc3EaSwGGcRtlWAYAAAB2YWx1ZXNxHH1xHShYDQAAAHNl -bGVjdGVkX2NvbHNxHl1xH1gNAAAAc2VsZWN0ZWRfcm93c3EgXXEhaApLAXVYBAAAAHRpbWVxIkdB -1qcWMaGd4VgKAAAAYXR0cmlidXRlc3EjfXEkKGgUSwJoGl1xJShYCwAAAElyaXMtc2V0b3NhcSZY -DwAAAElyaXMtdmVyc2ljb2xvcnEnWA4AAABJcmlzLXZpcmdpbmljYXEoZWgYSwJoEksCaBZLAnVY -BQAAAG1ldGFzcSl9cSp1YmF1Lg== - - gAN9cQAoWAsAAABhdXRvX2NvbW1pdHEBiFgOAAAAY29sb3JfYnlfY2xhc3NxAohYEgAAAGNvbnRy -b2xBcmVhVmlzaWJsZXEDiFgOAAAAZGlzdF9jb2xvcl9SR0JxBChL3EvcS9xL/3RxBVgTAAAAc2F2 -ZWRXaWRnZXRHZW9tZXRyeXEGTlgLAAAAc2VsZWN0X3Jvd3NxB4hYFQAAAHNob3dfYXR0cmlidXRl -X2xhYmVsc3EIiFgSAAAAc2hvd19kaXN0cmlidXRpb25zcQmJWAsAAABfX3ZlcnNpb25fX3EKSwFY -EAAAAGNvbnRleHRfc2V0dGluZ3NxC11xDGNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0 -CnENKYFxDn1xDyhYDgAAAG9yZGVyZWRfZG9tYWlucRBdcREoWAwAAABzZXBhbCBsZW5ndGhxEksC -hnETWAsAAABzZXBhbCB3aWR0aHEUSwKGcRVYDAAAAHBldGFsIGxlbmd0aHEWSwKGcRdYCwAAAHBl -dGFsIHdpZHRocRhLAoZxGVgEAAAAaXJpc3EaSwGGcRtlWAYAAAB2YWx1ZXNxHH1xHShYDgAAAGNv -bG9yX3NldHRpbmdzcR5OSv7///+GcR9YDQAAAHNlbGVjdGVkX3Jvd3NxIF1xIVgVAAAAc2hvd19h -dHRyaWJ1dGVfbGFiZWxzcSKISv7///+GcSNYCwAAAHNlbGVjdF9yb3dzcSSISv7///+GcSVYDgAA -AGRpc3RfY29sb3JfUkdCcSZoBUr+////hnEnWBIAAABzaG93X2Rpc3RyaWJ1dGlvbnNxKIlK/v// -/4ZxKVgOAAAAY29sb3JfYnlfY2xhc3NxKohK/v///4ZxK1gLAAAAYXV0b19jb21taXRxLIhK/v// -/4ZxLVgTAAAAc2F2ZWRXaWRnZXRHZW9tZXRyeXEuTkr+////hnEvWBUAAABzZWxlY3RlZF9zY2hl -bWFfaW5kZXhxMEsASv7///+GcTFYDQAAAHNlbGVjdGVkX2NvbHNxMl1xM2gKSwF1WAQAAAB0aW1l -cTRHQdWS1C7iYANYCgAAAGF0dHJpYnV0ZXNxNX1xNihoFEsCaBpdcTcoWAsAAABJcmlzLXNldG9z -YXE4WA8AAABJcmlzLXZlcnNpY29sb3JxOVgOAAAASXJpcy12aXJnaW5pY2FxOmVoGEsCaBJLAmgW -SwJ1WAUAAABtZXRhc3E7fXE8dWJhdS4= - - gAN9cQAoWBIAAABhcHBlbmRfcHJlZGljdGlvbnNxAYhYFAAAAGFwcGVuZF9wcm9iYWJpbGl0aWVz -cQKJWAoAAABhdXRvY29tbWl0cQOIWBIAAABjb250cm9sQXJlYVZpc2libGVxBIhYEwAAAHNhdmVk -V2lkZ2V0R2VvbWV0cnlxBUMuAdnQywABAAAAAAFXAAAAwQAABEQAAALAAAABVwAAANcAAAREAAAC -wAAAAAAAAHEGWBAAAABzZWxlY3RlZF9sZWFybmVycQdjY29weXJlZwpfcmVjb25zdHJ1Y3Rvcgpx -CGNidWlsdGlucwpsaXN0CnEJaAkph3EKUnELSwBhWBEAAABzZWxlY3RlZF9xdWFudGl0eXEMSwBY -CwAAAF9fdmVyc2lvbl9fcQ1LAVgQAAAAY29udGV4dF9zZXR0aW5nc3EOXXEPY09yYW5nZS53aWRn -ZXRzLnNldHRpbmdzCkNvbnRleHQKcRApgXERfXESKFgHAAAAY2xhc3Nlc3ETXXEUKFgLAAAASXJp -cy1zZXRvc2FxFVgPAAAASXJpcy12ZXJzaWNvbG9ycRZYDgAAAElyaXMtdmlyZ2luaWNhcRdlWAQA -AAB0aW1lcRhHQdanFjGoUuNYBgAAAHZhbHVlc3EZfXEaKFgJAAAAc2VsZWN0aW9ucRtjYnVpbHRp -bnMKc2V0CnEcXXEdhXEeUnEfaA1LAXV1YmF1Lg== + {'C_index': 61, 'auto_apply': True, 'class_weight': False, 'controlAreaVisible': True, 'learner_name': 'Logistic Regression', 'penalty_type': 1, 'savedWidgetGeometry': None, '__version__': 2} + {'auto_apply': True, 'class_weight': False, 'controlAreaVisible': True, 'index_output': 0, 'learner_name': 'Random Forest Learner', 'max_depth': 3, 'max_features': 5, 'min_samples_split': 5, 'n_estimators': 10, 'savedWidgetGeometry': None, 'use_max_depth': False, 'use_max_features': False, 'use_min_samples_split': True, 'use_random_state': False, '__version__': 1} + {'C': 1.0, 'auto_apply': True, 'coef0': 0.0, 'controlAreaVisible': True, 'degree': 3, 'epsilon': 0.1, 'gamma': 0.0, 'kernel_type': 0, 'learner_name': 'SVM Learner', 'limit_iter': True, 'max_iter': 100, 'nu': 0.5, 'nu_C': 1.0, 'savedWidgetGeometry': None, 'svm_type': 0, 'tol': 0.001, '__version__': 1} + gASVjAEAAAAAAAB9lCiMEmFwcGVuZF9wcmVkaWN0aW9uc5SIjBRhcHBlbmRfcHJvYmFiaWxpdGll +c5SJjAphdXRvY29tbWl0lIiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21l +dHJ5lEMuAdnQywABAAAAAAFXAAAAwQAABEQAAALAAAABVwAAANcAAAREAAACwAAAAAAAAJSMEHNl +bGVjdGVkX2xlYXJuZXKUXZRLAGGMEXNlbGVjdGVkX3F1YW50aXR5lEsAjAtfX3ZlcnNpb25fX5RL +AYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJST +lCmBlH2UKIwHY2xhc3Nlc5RdlCiMC0lyaXMtc2V0b3NhlIwPSXJpcy12ZXJzaWNvbG9ylIwOSXJp +cy12aXJnaW5pY2GUZYwEdGltZZRHQdanFjGoUuOMBnZhbHVlc5R9lCiMCXNlbGVjdGlvbpSPlGgK +SwF1dWJhdS4= + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, '__version__': 1} + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, '__version__': 1} + + + diff --git a/Orange/canvas/workflows/470-misclassification-scatterplot.ows b/Orange/canvas/workflows/470-misclassification-scatterplot.ows index 82eaec990a1..85924e25e15 100644 --- a/Orange/canvas/workflows/470-misclassification-scatterplot.ows +++ b/Orange/canvas/workflows/470-misclassification-scatterplot.ows @@ -1,96 +1,92 @@ - + - - - - - + + + + + - - - - - + + + + + - Shows different types of misclassifications. For Iris dataset, Iris virginica are confused with versicolor and vice versa. - Misclassifications for Iris datasets are best seen in petal length-petal width projection. - Replace logistic regression with any other classification method. - - - + Shows different types of misclassifications. For Iris dataset, Iris virginica are confused with versicolor and vice versa. + Misclassifications for Iris datasets are best seen in petal length-petal width projection. + Replace logistic regression with any other classification method. + + + - gAN9cQAoWBIAAABjb250cm9sQXJlYVZpc2libGVxAYhYDAAAAHJlY2VudF9wYXRoc3ECXXEDY09y -YW5nZS53aWRnZXRzLnV0aWxzLmZpbGVkaWFsb2dzClJlY2VudFBhdGgKcQQpgXEFfXEGKFgHAAAA -YWJzcGF0aHEHWDAAAAAvVXNlcnMvYW56ZS9kZXYvb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJp -cy50YWJxCFgGAAAAcHJlZml4cQlYDwAAAHNhbXBsZS1kYXRhc2V0c3EKWAcAAAByZWxwYXRocQtY -CAAAAGlyaXMudGFicQxYBQAAAHRpdGxlcQ1YAAAAAHEOWAUAAABzaGVldHEPaA5YCwAAAGZpbGVf -Zm9ybWF0cRBOdWJhWAsAAAByZWNlbnRfdXJsc3ERXXESWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5 -cRNDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWgcRRY -CwAAAHNoZWV0X25hbWVzcRV9cRZYBgAAAHNvdXJjZXEXSwBYAwAAAHVybHEYaA5YDQAAAGRvbWFp -bl9lZGl0b3JxGX1xGlgLAAAAX192ZXJzaW9uX19xG0sBWBAAAABjb250ZXh0X3NldHRpbmdzcRxd -cR1jT3JhbmdlLndpZGdldHMuc2V0dGluZ3MKQ29udGV4dApxHimBcR99cSAoWAQAAAB0aW1lcSFH -QdanFk28/Q1YBgAAAHZhbHVlc3EifXEjKFgJAAAAdmFyaWFibGVzcSRdcSVYCQAAAHhsc19zaGVl -dHEmaA5K/////4ZxJ2gZfXEoaCRdcSkoXXEqKFgMAAAAc2VwYWwgbGVuZ3RocStjT3JhbmdlLmRh -dGEudmFyaWFibGUKQ29udGludW91c1ZhcmlhYmxlCnEsSwBoDohlXXEtKFgLAAAAc2VwYWwgd2lk -dGhxLmgsSwBoDohlXXEvKFgMAAAAcGV0YWwgbGVuZ3RocTBoLEsAaA6IZV1xMShYCwAAAHBldGFs -IHdpZHRocTJoLEsAaA6IZV1xMyhYBAAAAGlyaXNxNGNPcmFuZ2UuZGF0YS52YXJpYWJsZQpEaXNj -cmV0ZVZhcmlhYmxlCnE1SwFYLAAAAElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIElyaXMt -dmlyZ2luaWNhcTaJZWVzaBtLAXVYCgAAAGF0dHJpYnV0ZXNxNyhYDAAAAHNlcGFsIGxlbmd0aHE4 -SwKGcTlYCwAAAHNlcGFsIHdpZHRocTpLAoZxO1gMAAAAcGV0YWwgbGVuZ3RocTxLAoZxPVgLAAAA -cGV0YWwgd2lkdGhxPksChnE/dHFAWAUAAABtZXRhc3FBKVgKAAAAY2xhc3NfdmFyc3FCWAQAAABp -cmlzcUNdcUQoWAsAAABJcmlzLXNldG9zYXFFWA8AAABJcmlzLXZlcnNpY29sb3JxRlgOAAAASXJp -cy12aXJnaW5pY2FxR2WGcUiFcUlYEgAAAG1vZGlmaWVkX3ZhcmlhYmxlc3FKXXFLdWJhdS4= + gASVvAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWg +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwEdGltZZRHQdanFk28/Q2MBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoKF2UKF2UKIwMc2VwYWwgbGVuZ3RolIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJD +b250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGgwSwBoEIhlXZQojAxw +ZXRhbCBsZW5ndGiUaDBLAGgQiGVdlCiMC3BldGFsIHdpZHRolGgwSwBoEIhlXZQojARpcmlzlGgu +jBBEaXNjcmV0ZVZhcmlhYmxllJOUSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIEly +aXMtdmlyZ2luaWNhlIllZXNoHUsBdYwKYXR0cmlidXRlc5QojAxzZXBhbCBsZW5ndGiUSwKGlIwL +c2VwYWwgd2lkdGiUSwKGlIwMcGV0YWwgbGVuZ3RolEsChpSMC3BldGFsIHdpZHRolEsChpR0lIwF +bWV0YXOUKYwKY2xhc3NfdmFyc5SMBGlyaXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMtdmVyc2lj +b2xvcpSMDklyaXMtdmlyZ2luaWNhlGWGlIWUjBJtb2RpZmllZF92YXJpYWJsZXOUXZR1YmF1Lg== - gAN9cQAoWBIAAABjb250cm9sQXJlYVZpc2libGVxAYhYDQAAAGN2X3N0cmF0aWZpZWRxAohYBwAA -AG5fZm9sZHNxA0sDWAkAAABuX3JlcGVhdHNxBEsDWAoAAAByZXNhbXBsaW5ncQVLAFgLAAAAc2Ft -cGxlX3NpemVxBksJWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cQdDLgHZ0MsAAQAAAAADcgAAAnEA -AAZ9AAAD+gAAA3IAAAKHAAAGfQAAA/oAAAAAAABxCFgMAAAAc2hvd25fc2NvcmVzcQljYnVpbHRp -bnMKc2V0CnEKXXELKFgGAAAAUmVjYWxscQxYCQAAAFByZWNpc2lvbnENWAMAAABNQUVxDlgCAAAA -UjJxD1gEAAAAUk1TRXEQWAMAAABNU0VxEVgCAAAAQ0FxElgCAAAARjFxE1gDAAAAQVVDcRRlhXEV -UnEWWBIAAABzaHVmZmxlX3N0cmF0aWZpZWRxF4hYCwAAAF9fdmVyc2lvbl9fcRhLA1gQAAAAY29u -dGV4dF9zZXR0aW5nc3EZXXEaY09yYW5nZS53aWRnZXRzLnNldHRpbmdzCkNvbnRleHQKcRspgXEc -fXEdKFgEAAAAdGltZXEeR0HWpxZNxmyQWAYAAAB2YWx1ZXNxH31xIChYDwAAAGNsYXNzX3NlbGVj -dGlvbnEhWBYAAAAoQXZlcmFnZSBvdmVyIGNsYXNzZXMpcSJK/////4ZxI1gMAAAAZm9sZF9mZWF0 -dXJlcSROSv7///+GcSVYFQAAAGZvbGRfZmVhdHVyZV9zZWxlY3RlZHEmiUr+////hnEnaBhLA3VY -CgAAAGF0dHJpYnV0ZXNxKChYDAAAAHNlcGFsIGxlbmd0aHEpSwKGcSpYCwAAAHNlcGFsIHdpZHRo -cStLAoZxLFgMAAAAcGV0YWwgbGVuZ3RocS1LAoZxLlgLAAAAcGV0YWwgd2lkdGhxL0sChnEwdHEx -WAUAAABtZXRhc3EyKVgKAAAAY2xhc3NfdmFyc3EzWAQAAABpcmlzcTRLAYZxNYVxNnViYXUu + gASVjwMAAAAAAAB9lCiMFGNvbXBhcmlzb25fY3JpdGVyaW9ulEsAjBJjb250cm9sQXJlYVZpc2li +bGWUiIwNY3Zfc3RyYXRpZmllZJSIjAduX2ZvbGRzlEsDjAluX3JlcGVhdHOUSwOMCnJlc2FtcGxp +bmeUSwCMBHJvcGWURz+5mZmZmZmajAtzYW1wbGVfc2l6ZZRLCYwTc2F2ZWRXaWRnZXRHZW9tZXRy +eZRDLgHZ0MsAAQAAAAADcgAAAnEAAAZ9AAAD+gAAA3IAAAKHAAAGfQAAA/oAAAAAAACUjBJzaHVm +ZmxlX3N0cmF0aWZpZWSUiIwIdXNlX3JvcGWUiYwLc2NvcmVfdGFibGWUfZSMEHNob3dfc2NvcmVf +aGludHOUfZQojAZNb2RlbF+UiIwGVHJhaW5flImMBVRlc3RflImMAkNBlIiMF1ByZWNpc2lvblJl +Y2FsbEZTdXBwb3J0lIiMC1RhcmdldFNjb3JllIiMCVByZWNpc2lvbpSIjAZSZWNhbGyUiIwCRjGU +iIwDQVVDlIiMB0xvZ0xvc3OUiYwLU3BlY2lmaWNpdHmUiYwXTWF0dGhld3NDb3JyQ29lZmZpY2ll +bnSUiIwDTVNFlIiMBFJNU0WUiIwDTUFFlIiMAlIylIiMBkNWUk1TRZSJjA9DbHVzdGVyaW5nU2Nv +cmWUiIwKU2lsaG91ZXR0ZZSIjBdBZGp1c3RlZE11dHVhbEluZm9TY29yZZSIdXOMC19fdmVyc2lv +bl9flEsEjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250 +ZXh0lJOUKYGUfZQojAR0aW1llEdB1qcWTcZskIwGdmFsdWVzlH2UKIwPY2xhc3Nfc2VsZWN0aW9u +lIwhKE5vbmUsIHNob3cgYXZlcmFnZSBvdmVyIGNsYXNzZXMplEr/////hpSMDGZvbGRfZmVhdHVy +ZZROSv7///+GlIwVZm9sZF9mZWF0dXJlX3NlbGVjdGVklIlK/v///4aUaA19lGgmSwR1jAphdHRy +aWJ1dGVzlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBhbCB3aWR0aJRLAoaUjAxwZXRhbCBsZW5n +dGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlHSUjAVtZXRhc5QpjApjbGFzc192YXJzlIwEaXJpc5RL +AYaUhZR1YmF1Lg== - {'C_index': 61, 'auto_apply': True, 'controlAreaVisible': True, 'learner_name': 'Logistic Regression', 'penalty_type': 1, 'savedWidgetGeometry': b'\x01\xd9\xd0\xcb\x00\x01\x00\x00\x00\x00\x04b\x00\x00\x02/\x00\x00\x05\x8e\x00\x00\x03L\x00\x00\x04b\x00\x00\x02E\x00\x00\x05\x8e\x00\x00\x03L\x00\x00\x00\x00\x00\x00', '__version__': 1} - gAN9cQAoWBIAAABhcHBlbmRfcHJlZGljdGlvbnNxAYhYFAAAAGFwcGVuZF9wcm9iYWJpbGl0aWVz -cQKJWAoAAABhdXRvY29tbWl0cQOIWBIAAABjb250cm9sQXJlYVZpc2libGVxBIhYEwAAAHNhdmVk -V2lkZ2V0R2VvbWV0cnlxBUMuAdnQywABAAAAAAOBAAABvgAABm4AAAPAAAADgQAAAdQAAAZuAAAD -wAAAAAAAAHEGWBAAAABzZWxlY3RlZF9sZWFybmVycQdjY29weXJlZwpfcmVjb25zdHJ1Y3Rvcgpx -CGNidWlsdGlucwpsaXN0CnEJaAkph3EKUnELSwBhWBEAAABzZWxlY3RlZF9xdWFudGl0eXEMSwBY -CwAAAF9fdmVyc2lvbl9fcQ1LAVgQAAAAY29udGV4dF9zZXR0aW5nc3EOXXEPY09yYW5nZS53aWRn -ZXRzLnNldHRpbmdzCkNvbnRleHQKcRApgXERfXESKFgEAAAAdGltZXETR0HWpxZN0G61WAcAAABj -bGFzc2VzcRRdcRUoWAsAAABJcmlzLXNldG9zYXEWWA8AAABJcmlzLXZlcnNpY29sb3JxF1gOAAAA -SXJpcy12aXJnaW5pY2FxGGVYBgAAAHZhbHVlc3EZfXEaKFgJAAAAc2VsZWN0aW9ucRtjYnVpbHRp -bnMKc2V0CnEcXXEdKEsASwGGcR5LAUsChnEfSwJLAIZxIEsBSwCGcSFLAEsChnEiSwJLAYZxI2WF -cSRScSVoDUsBdXViYXUu + {'C_index': 61, 'auto_apply': True, 'class_weight': False, 'controlAreaVisible': True, 'learner_name': 'Logistic Regression', 'penalty_type': 1, 'savedWidgetGeometry': b'\x01\xd9\xd0\xcb\x00\x01\x00\x00\x00\x00\x04b\x00\x00\x02/\x00\x00\x05\x8e\x00\x00\x03L\x00\x00\x04b\x00\x00\x02E\x00\x00\x05\x8e\x00\x00\x03L\x00\x00\x00\x00\x00\x00', '__version__': 2} + gASVsgEAAAAAAAB9lCiMEmFwcGVuZF9wcmVkaWN0aW9uc5SIjBRhcHBlbmRfcHJvYmFiaWxpdGll +c5SJjAphdXRvY29tbWl0lIiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21l +dHJ5lEMuAdnQywABAAAAAAOBAAABvgAABm4AAAPAAAADgQAAAdQAAAZuAAADwAAAAAAAAJSMEHNl +bGVjdGVkX2xlYXJuZXKUXZRLAGGMEXNlbGVjdGVkX3F1YW50aXR5lEsAjAtfX3ZlcnNpb25fX5RL +AYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJST +lCmBlH2UKIwEdGltZZRHQdanFk3QbrWMB2NsYXNzZXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMt +dmVyc2ljb2xvcpSMDklyaXMtdmlyZ2luaWNhlGWMBnZhbHVlc5R9lCiMCXNlbGVjdGlvbpSPlChL +AEsBhpRLAUsChpRLAksBhpRLAksAhpRLAEsChpRLAUsAhpSQaApLAXV1YmF1Lg== - gAN9cQAoWAsAAABhdXRvX3NhbXBsZXEBiFgTAAAAYXV0b19zZW5kX3NlbGVjdGlvbnECiFgSAAAA -Y29udHJvbEFyZWFWaXNpYmxlcQOIWBMAAABzYXZlZFdpZGdldEdlb21ldHJ5cQRDLgHZ0MsAAQAA -AAAFigAAAaYAAAi/AAAEdQAABYoAAAG8AAAIvwAABHUAAAAAAABxBVgPAAAAc2VsZWN0aW9uX2dy -b3VwcQZOWBEAAAB0b29sYmFyX3NlbGVjdGlvbnEHSwBYBQAAAGdyYXBocQh9cQkoWAsAAABhbHBo -YV92YWx1ZXEKS4BYDQAAAGNsYXNzX2RlbnNpdHlxC4hYEQAAAGppdHRlcl9jb250aW51b3VzcQyI -WAsAAABqaXR0ZXJfc2l6ZXENSwFYEwAAAGxhYmVsX29ubHlfc2VsZWN0ZWRxDolYCwAAAHBvaW50 -X3dpZHRocQ9LClgJAAAAc2hvd19ncmlkcRCJWAsAAABzaG93X2xlZ2VuZHERiFgNAAAAc2hvd19y -ZWdfbGluZXESiVgRAAAAdG9vbHRpcF9zaG93c19hbGxxE4l1WAsAAABfX3ZlcnNpb25fX3EUSwJY -EAAAAGNvbnRleHRfc2V0dGluZ3NxFV1xFmNPcmFuZ2Uud2lkZ2V0cy5zZXR0aW5ncwpDb250ZXh0 -CnEXKYFxGH1xGShYDgAAAG9yZGVyZWRfZG9tYWlucRpdcRsoWAwAAABzZXBhbCBsZW5ndGhxHEsC -hnEdWAsAAABzZXBhbCB3aWR0aHEeSwKGcR9YDAAAAHBldGFsIGxlbmd0aHEgSwKGcSFYCwAAAHBl -dGFsIHdpZHRocSJLAoZxI1gEAAAAaXJpc3EkSwGGcSVlWAoAAABhdHRyaWJ1dGVzcSZ9cScoaBxL -AmgiSwJoIEsCaB5LAmgkSwF1WAYAAAB2YWx1ZXNxKH1xKShYBgAAAGF0dHJfeHEqWAwAAABwZXRh -bCBsZW5ndGhxK0tmhnEsWAYAAABhdHRyX3lxLVgLAAAAcGV0YWwgd2lkdGhxLktmhnEvaAh9cTAo -WAoAAABhdHRyX2NvbG9ycTFYBAAAAGlyaXNxMktlhnEzWAoAAABhdHRyX2xhYmVscTROSv7///+G -cTVYCgAAAGF0dHJfc2hhcGVxNk5K/v///4ZxN1gJAAAAYXR0cl9zaXplcThOSv7///+GcTl1aBRL -AnVYBQAAAG1ldGFzcTp9cTtYBAAAAHRpbWVxPEdB1qcWTdgAzXViYXUu + gASVEwMAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAWKAAABpgAACL8AAAR1 +AAAFigAAAbwAAAi/AAAEdQAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiIwRaml0dGVyX2NvbnRpbnVvdXOUiIwLaml0dGVyX3NpemWUSwGME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOU +KYGUfZQojA5vcmRlcmVkX2RvbWFpbpRdlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBhbCB3aWR0 +aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlIwEaXJpc5RLAYaUZYwK +YXR0cmlidXRlc5R9lChoIEsCaCZLAmgkSwJoIksCaChLAXWMBnZhbHVlc5R9lCiMCmF0dHJfY29s +b3KUjARpcmlzlEtlhpSMCmF0dHJfbGFiZWyUTkr+////hpSMCmF0dHJfc2hhcGWUTkr+////hpSM +CWF0dHJfc2l6ZZROSv7///+GlIwGYXR0cl94lIwMcGV0YWwgbGVuZ3RolEtmhpSMBmF0dHJfeZSM +C3BldGFsIHdpZHRolEtmhpRoCn2UaBZLBXWMBW1ldGFzlH2UjAR0aW1llEdB1qcWTdgAzXViYXUu + + + diff --git a/Orange/classification/__init__.py b/Orange/classification/__init__.py index 982498d6f40..2ef08288a9f 100644 --- a/Orange/classification/__init__.py +++ b/Orange/classification/__init__.py @@ -1,5 +1,5 @@ # Pull members from modules to Orange.classification namespace -# pylint: disable=wildcard-import +# pylint: disable=wildcard-import,broad-except from .base_classification import (ModelClassification as Model, LearnerClassification as Learner, @@ -20,9 +20,10 @@ from .sgd import * from .neural_network import * from .calibration import * +from .scoringsheet import * try: from .catgb import * -except ModuleNotFoundError: +except Exception: pass from .gb import * try: diff --git a/Orange/classification/_simple_tree.c b/Orange/classification/_simple_tree.c index d7d9dbcda87..fe91cd2a283 100644 --- a/Orange/classification/_simple_tree.c +++ b/Orange/classification/_simple_tree.c @@ -1,3 +1,6 @@ +#if (defined __GLIBC__ || defined __GNU__ || defined __linux__) +#define _GNU_SOURCE +#endif #include #include #include @@ -310,13 +313,13 @@ mse_c(struct Example *examples, int size, int attr, float cls_mse, struct Args * var_lt.sum += ex->weight * cls_val; var_lt.sum2 += ex->weight * cls_val * cls_val; - /* this calculation might be numarically unstable - fix */ + /* this calculation might be numerically unstable - fix */ var_ge.n -= ex->weight; var_ge.sum -= ex->weight * cls_val; var_ge.sum2 -= ex->weight * cls_val * cls_val; } - if (ex->x[attr] == ex_next->x[attr] || i + 1 < min_instances) + if (ex->x[attr] == ex_next->x[attr] || i + 1 < min_instances || var_lt.n == 0) continue; /* compute mse */ @@ -346,6 +349,10 @@ mse_d(struct Example *examples, int size, int attr, float cls_mse, struct Args * float n, sum, sum2; } *variances, *v, *v_end; + if (cls_mse <= 0) { + return 0.0; + } + attr_vals = args->attr_vals[attr]; ASSERT(variances = (struct Variance *)calloc(attr_vals, sizeof *variances)); @@ -381,8 +388,8 @@ mse_d(struct Example *examples, int size, int attr, float cls_mse, struct Args * score += v->sum2 - v->sum * v->sum / v->n; score = (cls_mse - score / size_attr_cls_known) / cls_mse * (size_attr_known / size_weight); - if (size_attr_cls_known <= 0.0 || cls_mse <= 0.0 || size_weight <= 0.0) - score = 0.0; + if (size_attr_cls_known <= 0.0 || size_weight <= 0.0) + score = -INFINITY; finish: free(attr_dist); @@ -441,15 +448,6 @@ build_tree_(struct Example *examples, int size, int depth, struct SimpleTreeNode float n, sum, sum2, cls_val; assert(args->type == Regression); - if (size == 0) { - assert(parent); - node->type = PredictorNode; - node->children_size = 0; - node->n = parent->n; - node->sum = parent->sum; - return node; - } - n = sum = sum2 = 0.0; for (ex = examples, ex_end = examples + size; ex < ex_end; ex++) if (!isnan(ex->y)) { @@ -461,6 +459,17 @@ build_tree_(struct Example *examples, int size, int depth, struct SimpleTreeNode node->n = n; node->sum = sum; + + if (n == 0) { + node->type = PredictorNode; + node->children_size = 0; + if (parent) { + node->n = parent->n; + node->sum = parent->sum; + } + return node; + } + cls_mse = (sum2 - sum * sum / n) / n; if (cls_mse < 1e-5) { @@ -747,7 +756,7 @@ predict_regression(double *x, int size, struct SimpleTreeNode *node, int num_att for (i = 0; i < size; i++) { sum = n = 0; predict_regression_(x + i * num_attrs, node, &sum, &n); - p[i] = sum / n; + p[i] = n > 0 ? sum / n : sum; } } diff --git a/Orange/classification/_tree_scorers.pyx b/Orange/classification/_tree_scorers.pyx index 00bd0639ca0..e88b86e8dba 100644 --- a/Orange/classification/_tree_scorers.pyx +++ b/Orange/classification/_tree_scorers.pyx @@ -17,7 +17,7 @@ cdef extern from "numpy/npy_math.h": cpdef enum: NULL_BRANCH = -1 -def contingency(double[:] x, int nx, double[:] y, int ny): +def contingency(const double[:] x, int nx, const double[:] y, int ny): cdef: np.ndarray[np.uint32_t, ndim=2] cont = np.zeros((ny, nx), dtype=np.uint32) int n = len(x), yi, xi @@ -28,7 +28,8 @@ def contingency(double[:] x, int nx, double[:] y, int ny): cont[yi, xi] += 1 return cont -def find_threshold_entropy(double[:] x, double[:] y, np.intp_t[:] idx, +def find_threshold_entropy(const double[:] x, const double[:] y, + const np.intp_t[:] idx, int n_classes, int min_leaf): """ Find the threshold for continuous attribute values that maximizes @@ -78,7 +79,7 @@ def find_threshold_entropy(double[:] x, double[:] y, np.intp_t[:] idx, curr_y = y[idx[i]] distr[curr_y] -= 1 distr[n_classes + curr_y] += 1 - if curr_y != y[idx[i + 1]] and x[idx[i]] != x[idx[i + 1]]: + if x[idx[i]] != x[idx[i + 1]]: entro = (i + 1) * log(i + 1) + (N - i - 1) * log(N - i - 1) for j in range(2 * n_classes): if distr[j]: @@ -89,8 +90,9 @@ def find_threshold_entropy(double[:] x, double[:] y, np.intp_t[:] idx, return (class_entro - best_entro) / N / log(2), x[idx[best_idx]] -def find_binarization_entropy(double[:, :] cont, double[:] class_distr, - double[:] val_distr, int min_leaf): +def find_binarization_entropy(const double[:, :] cont, + const double[:] class_distr, + const double[:] val_distr, int min_leaf): """ Find the split of discrete values into two groups that optimizes information gain. @@ -187,7 +189,9 @@ def find_binarization_entropy(double[:, :] cont, double[:] class_distr, return (class_entro - best_entro) / N / log(2), best_mapping -def find_threshold_MSE(double[:] x, double[:] y, np.intp_t[:] idx, int min_leaf): +def find_threshold_MSE(const double[:] x, + const double[:] y, + const np.intp_t[:] idx, int min_leaf): """ Find the threshold for continuous attribute values that minimizes MSE. @@ -232,7 +236,8 @@ def find_threshold_MSE(double[:] x, double[:] y, np.intp_t[:] idx, int min_leaf) return (best_inter - (sum * sum) / N) / N, x[idx[best_idx]] -def find_binarization_MSE(double[:] x, double[:] y, int n_values, int min_leaf): +def find_binarization_MSE(const double[:] x, + const double[:] y, int n_values, int min_leaf): """ Find the split of discrete values into two groups that minimizes the MSE. @@ -315,7 +320,9 @@ def find_binarization_MSE(double[:] x, double[:] y, int n_values, int min_leaf): return (best_inter - start_inter) / x.shape[0], best_mapping -def compute_grouped_MSE(double[:] x, double[:] y, int n_values, int min_leaf): +def compute_grouped_MSE(const double[:] x, + const double[:] y, + int n_values, int min_leaf): """ Compute the MSE decrease of the given split into groups. @@ -371,8 +378,10 @@ def compute_grouped_MSE(double[:] x, double[:] y, int n_values, int min_leaf): return (inter - sum * sum / n) / x.shape[0] -def compute_predictions(double[:, :] X, int[:] code, - double[:, :] values, double[:] thresholds): +def compute_predictions(const double[:, :] X, + const int[:] code, + const double[:, :] values, + const double[:] thresholds): """ Return the values (distributions, means and variances) stored in the nodes to which the tree classify the rows in X. @@ -419,8 +428,10 @@ def compute_predictions(double[:, :] X, int[:] code, return np.asarray(predictions) -def compute_predictions_csr(X, int[:] code, - double[:, :] values, double[:] thresholds): +def compute_predictions_csr(X, + const int[:] code, + const double[:, :] values, + const double[:] thresholds): """ Same as compute_predictions except for sparse data """ @@ -431,9 +442,9 @@ def compute_predictions_csr(X, int[:] code, double[: ,:] predictions = np.empty( (X.shape[0], values.shape[1]), dtype=np.float64) - double[:] data = X.data - np.int32_t[:] indptr = X.indptr - np.int32_t[:] indices = X.indices + const double[:] data = X.data + const np.int32_t[:] indptr = X.indptr + const np.int32_t[:] indices = X.indices int ind, attr, n_rows n_rows = X.shape[0] @@ -463,8 +474,10 @@ def compute_predictions_csr(X, int[:] code, predictions[i, j] = values[node_idx, j] return np.asarray(predictions) -def compute_predictions_csc(X, int[:] code, - double[:, :] values, double[:] thresholds): +def compute_predictions_csc(X, + const int[:] code, + const double[:, :] values, + const double[:] thresholds): """ Same as compute_predictions except for sparse data """ @@ -475,9 +488,9 @@ def compute_predictions_csc(X, int[:] code, double[: ,:] predictions = np.empty( (X.shape[0], values.shape[1]), dtype=np.float64) - double[:] data = X.data - np.int32_t[:] indptr = X.indptr - np.int32_t[:] indices = X.indices + const double[:] data = X.data + const np.int32_t[:] indptr = X.indptr + const np.int32_t[:] indices = X.indices int ind, attr, n_rows n_rows = X.shape[0] diff --git a/Orange/classification/base_classification.py b/Orange/classification/base_classification.py index 6a38bef6374..fa5a7d60c36 100644 --- a/Orange/classification/base_classification.py +++ b/Orange/classification/base_classification.py @@ -5,14 +5,19 @@ class LearnerClassification(Learner): - learner_adequacy_err_msg = "Categorical class variable expected." - def check_learner_adequacy(self, domain): - return domain.has_discrete_class + def incompatibility_reason(self, domain): + reason = None + if len(domain.class_vars) > 1 and not self.supports_multiclass: + reason = "Too many target variables." + elif not domain.has_discrete_class: + reason = "Categorical class variable expected." + return reason class ModelClassification(Model): - pass + def predict_proba(self, data): + return self(data, ret=Model.Probs) class SklModelClassification(SklModel, ModelClassification): diff --git a/Orange/classification/catgb.py b/Orange/classification/catgb.py index c19716350fb..3a77e851bdf 100644 --- a/Orange/classification/catgb.py +++ b/Orange/classification/catgb.py @@ -4,8 +4,8 @@ import catboost -from Orange.base import CatGBBaseLearner -from Orange.classification import Learner +from Orange.base import CatGBBaseLearner, CatGBModel +from Orange.classification import Learner, Model from Orange.data import Variable, DiscreteVariable, Table from Orange.preprocess.score import LearnerScorer @@ -21,5 +21,10 @@ def score(self, data: Table) -> Tuple[np.ndarray, Tuple[Variable]]: return model.cat_model.feature_importances_, model.domain.attributes +class CatGBClsModel(CatGBModel, Model): + pass + + class CatGBClassifier(CatGBBaseLearner, Learner, _FeatureScorerMixin): __wraps__ = catboost.CatBoostClassifier + __returns__ = CatGBClsModel diff --git a/Orange/classification/gb.py b/Orange/classification/gb.py index daef7726889..1cd57511d6f 100644 --- a/Orange/classification/gb.py +++ b/Orange/classification/gb.py @@ -23,9 +23,10 @@ def score(self, data: Table) -> Tuple[np.ndarray, Tuple[Variable]]: class GBClassifier(SklLearner, _FeatureScorerMixin): __wraps__ = skl_ensemble.GradientBoostingClassifier __returns__ = SklModel + supports_weights = True def __init__(self, - loss="deviance", + loss="log_loss", learning_rate=0.1, n_estimators=100, subsample=1.0, diff --git a/Orange/classification/knn.py b/Orange/classification/knn.py index 53346f87eb0..decdb354ead 100644 --- a/Orange/classification/knn.py +++ b/Orange/classification/knn.py @@ -7,3 +7,4 @@ class KNNLearner(KNNBase, SklLearner): __wraps__ = skl_neighbors.KNeighborsClassifier + supports_weights = False diff --git a/Orange/classification/logistic_regression.py b/Orange/classification/logistic_regression.py index aeb4fbfc1cb..5689496399a 100644 --- a/Orange/classification/logistic_regression.py +++ b/Orange/classification/logistic_regression.py @@ -6,6 +6,7 @@ from Orange.preprocess.score import LearnerScorer from Orange.data import Variable, DiscreteVariable + __all__ = ["LogisticRegressionLearner"] @@ -33,22 +34,24 @@ class LogisticRegressionLearner(SklLearner, _FeatureScorerMixin): __wraps__ = skl_linear_model.LogisticRegression __returns__ = LogisticRegressionClassifier preprocessors = SklLearner.preprocessors + supports_weights = True def __init__(self, penalty="l2", dual=False, tol=0.0001, C=1.0, fit_intercept=True, intercept_scaling=1, class_weight=None, random_state=None, solver="auto", max_iter=100, - multi_class="auto", verbose=0, n_jobs=1, preprocessors=None): + verbose=0, n_jobs=1, preprocessors=None): super().__init__(preprocessors=preprocessors) self.params = vars() def _initialize_wrapped(self): params = self.params.copy() + # The default scikit-learn solver `lbfgs` (v0.22) does not support the # l1 penalty. solver, penalty = params.pop("solver"), params.get("penalty") if solver == "auto": if penalty == "l1": - solver = "liblinear" + solver = "saga" else: solver = "lbfgs" params["solver"] = solver diff --git a/Orange/classification/naive_bayes.py b/Orange/classification/naive_bayes.py index 2db384f910c..3d5e8e32e59 100644 --- a/Orange/classification/naive_bayes.py +++ b/Orange/classification/naive_bayes.py @@ -98,7 +98,7 @@ def _dense_probs(self, data, probs): zeros = np.zeros((1, probs.shape[1])) for col, attr_prob in zip(data.T, self.log_cont_prob): col = col.copy() - col[np.isnan(col)] = attr_prob.shape[1] - 1 + col[np.isnan(col)] = attr_prob.shape[1] col = col.astype(int) probs0 = np.vstack((attr_prob.T, zeros)) probs += probs0[col] @@ -113,6 +113,7 @@ def _sparse_probs(self, data, probs): p0 = p.T[0].copy() probs[:] += p0 log_prob[i, :p.shape[1]] = p.T - p0 + log_prob[i, n_vals-1] = -p0 dat = data.data.copy() dat[np.isnan(dat)] = n_vals - 1 diff --git a/Orange/classification/neural_network.py b/Orange/classification/neural_network.py index 53dff79bed4..78d4cb374fa 100644 --- a/Orange/classification/neural_network.py +++ b/Orange/classification/neural_network.py @@ -25,6 +25,7 @@ class MLPClassifierWCallback(skl_nn.MLPClassifier, NIterCallbackMixin): class NNClassificationLearner(NNBase, SklLearner): __wraps__ = MLPClassifierWCallback + supports_weights = False def _initialize_wrapped(self): clf = SklLearner._initialize_wrapped(self) diff --git a/Orange/classification/outlier_detection.py b/Orange/classification/outlier_detection.py index 52f56253464..7dad7e9ce14 100644 --- a/Orange/classification/outlier_detection.py +++ b/Orange/classification/outlier_detection.py @@ -89,6 +89,7 @@ class OneClassSVMLearner(_OutlierLearner): name = "One class SVM" __wraps__ = OneClassSVM preprocessors = SklLearner.preprocessors + [AdaptiveNormalize()] + supports_weights = True def __init__(self, kernel='rbf', degree=3, gamma="auto", coef0=0.0, tol=0.001, nu=0.5, shrinking=True, cache_size=200, @@ -100,6 +101,7 @@ def __init__(self, kernel='rbf', degree=3, gamma="auto", coef0=0.0, class LocalOutlierFactorLearner(_OutlierLearner): __wraps__ = LocalOutlierFactor name = "Local Outlier Factor" + supports_weights = False def __init__(self, n_neighbors=20, algorithm="auto", leaf_size=30, metric="minkowski", p=2, metric_params=None, @@ -112,6 +114,7 @@ def __init__(self, n_neighbors=20, algorithm="auto", leaf_size=30, class IsolationForestLearner(_OutlierLearner): __wraps__ = IsolationForest name = "Isolation Forest" + supports_weights = True def __init__(self, n_estimators=100, max_samples='auto', contamination='auto', max_features=1.0, bootstrap=False, @@ -156,6 +159,7 @@ class EllipticEnvelopeLearner(_OutlierLearner): __wraps__ = EllipticEnvelope __returns__ = EllipticEnvelopeClassifier name = "Covariance Estimator" + supports_weights = False def __init__(self, store_precision=True, assume_centered=False, support_fraction=None, contamination=0.1, diff --git a/Orange/classification/random_forest.py b/Orange/classification/random_forest.py index 932258e87c7..f77b6f2c898 100644 --- a/Orange/classification/random_forest.py +++ b/Orange/classification/random_forest.py @@ -38,6 +38,7 @@ def wrap(tree, i): class RandomForestLearner(SklLearner, _FeatureScorerMixin): __wraps__ = skl_ensemble.RandomForestClassifier __returns__ = RandomForestClassifier + supports_weights = True def __init__(self, n_estimators=10, @@ -46,7 +47,7 @@ def __init__(self, min_samples_split=2, min_samples_leaf=1, min_weight_fraction_leaf=0., - max_features="auto", + max_features="sqrt", max_leaf_nodes=None, bootstrap=True, oob_score=False, diff --git a/Orange/classification/rules.py b/Orange/classification/rules.py index db9baf1395c..26ba0adb837 100644 --- a/Orange/classification/rules.py +++ b/Orange/classification/rules.py @@ -435,10 +435,12 @@ class TopDownSearchStrategy(SearchStrategy): instances is developed. The hypothesis space of possible rules is then searched repeatedly by specialising candidate rules. """ - def __init__(self, constrain_continuous=True, evaluate=True): + def __init__(self, constrain_continuous=True, evaluate=True, + restrict_equality=False): self.constrain_continuous = constrain_continuous self.storage = None self.evaluate = evaluate + self.restrict_equality = restrict_equality def initialise_rule(self, X, Y, W, target_class, base_rules, domain, initial_class_dist, prior_class_dist, @@ -531,14 +533,15 @@ def find_new_selectors(self, X, Y, W, domain, existing_selectors): possible_selectors = [] # examine covered examples, for each variable + disc_operators = ["=="] if self.restrict_equality else ["==", "!="] for i, attribute in enumerate(domain.attributes): # if discrete variable if attribute.is_discrete: # for each unique value, generate all possible selectors - for val in np.unique(X[:, i]): - s1 = Selector(column=i, op="==", value=val) - s2 = Selector(column=i, op="!=", value=val) - possible_selectors.extend([s1, s2]) + for op in disc_operators: + possible_selectors += ( + Selector(column=i, op=op, value=val) + for val in np.unique(X[:, i])) # if continuous variable elif attribute.is_continuous: if X.shape[0] == 1: @@ -914,7 +917,8 @@ class _RuleLearner(Learner): """ preprocessors = [RemoveNaNColumns(), HasClass(), Impute()] - def __init__(self, preprocessors=None, base_rules=None): + def __init__(self, preprocessors=None, base_rules=None, + *, restrict_equality=False): """ Constrain the search algorithm with a list of base rules. @@ -940,6 +944,7 @@ def __init__(self, preprocessors=None, base_rules=None): super().__init__(preprocessors=preprocessors) self.base_rules = base_rules if base_rules is not None else [] self.rule_finder = RuleHunter() + self.rule_finder.search_strategy.restrict_equality = restrict_equality self.data_stopping = self.positive_remaining_data_stopping self.cover_and_remove = self.exclusive_cover_and_remove @@ -1247,8 +1252,10 @@ class _BaseCN2Learner(_RuleLearner): """ def __init__(self, preprocessors=None, base_rules=None, beam_width=5, constrain_continuous=True, min_covered_examples=1, - max_rule_length=5, default_alpha=1.0, parent_alpha=1.0): - super().__init__(preprocessors, base_rules) + max_rule_length=5, default_alpha=1.0, parent_alpha=1.0, *, + restrict_equality=False): + super().__init__(preprocessors, base_rules, + restrict_equality=restrict_equality) rf = self.rule_finder rf.search_algorithm.beam_width = beam_width rf.search_strategy.constrain_continuous = constrain_continuous @@ -1272,8 +1279,10 @@ class CN2Learner(_RuleLearner): "The CN2 Induction Algorithm", Peter Clark and Tim Niblett, Machine Learning Journal, 3 (4), pp261-283, (1989) """ - def __init__(self, preprocessors=None, base_rules=None): - super().__init__(preprocessors, base_rules) + def __init__(self, preprocessors=None, base_rules=None, + *, restrict_equality=False): + super().__init__(preprocessors, base_rules, + restrict_equality=restrict_equality) self.rule_finder.quality_evaluator = EntropyEvaluator() def fit_storage(self, data): @@ -1326,8 +1335,10 @@ class CN2UnorderedLearner(_RuleLearner): """ name = 'CN2 unordered inducer' - def __init__(self, preprocessors=None, base_rules=None): - super().__init__(preprocessors, base_rules) + def __init__(self, preprocessors=None, base_rules=None, + *, restrict_equality=False): + super().__init__(preprocessors, base_rules, + restrict_equality=restrict_equality) self.rule_finder.quality_evaluator = LaplaceAccuracyEvaluator() def fit_storage(self, data): @@ -1392,8 +1403,10 @@ class CN2SDLearner(_RuleLearner): """ name = 'CN2-SD inducer' - def __init__(self, preprocessors=None, base_rules=None): - super().__init__(preprocessors, base_rules) + def __init__(self, preprocessors=None, base_rules=None, + *, restrict_equality=False): + super().__init__(preprocessors, base_rules, + restrict_equality=restrict_equality) self.rule_finder.quality_evaluator = WeightedRelativeAccuracyEvaluator() self.cover_and_remove = self.weighted_cover_and_remove self.gamma = 0.7 @@ -1461,8 +1474,10 @@ class CN2SDUnorderedLearner(_RuleLearner): """ name = 'CN2-SD unordered inducer' - def __init__(self, preprocessors=None, base_rules=None): - super().__init__(preprocessors, base_rules) + def __init__(self, preprocessors=None, base_rules=None, + *, restrict_equality=False): + super().__init__(preprocessors, base_rules, + restrict_equality=restrict_equality) self.rule_finder.quality_evaluator = WeightedRelativeAccuracyEvaluator() self.cover_and_remove = self.weighted_cover_and_remove self.gamma = 0.7 diff --git a/Orange/classification/scoringsheet.py b/Orange/classification/scoringsheet.py new file mode 100644 index 00000000000..0651a6e825a --- /dev/null +++ b/Orange/classification/scoringsheet.py @@ -0,0 +1,154 @@ +import numpy as np +from Orange.classification.utils.fasterrisk.fasterrisk import ( + RiskScoreOptimizer, + RiskScoreClassifier, +) + +from Orange.classification import Learner, Model +from Orange.data import Table, Storage +from Orange.data.filter import HasClass +from Orange.preprocess import Discretize, Impute, Continuize, SelectBestFeatures +from Orange.preprocess.discretize import Binning +from Orange.preprocess.score import ReliefF + + +def _change_class_var_values(y): + """ + Changes the class variable values from 0 and 1 to -1 and 1 or vice versa. + """ + return np.where(y == 0, -1, np.where(y == -1, 0, y)) + + +class ScoringSheetModel(Model): + def __init__(self, model): + self.model = model + super().__init__() + + def predict_storage(self, table): + if not isinstance(table, Storage): + raise TypeError("Data is not a subclass of Orange.data.Storage.") + + y_pred = _change_class_var_values(self.model.predict(table.X)) + y_prob = self.model.predict_prob(table.X) + + scores = np.hstack(((1 - y_prob).reshape(-1, 1), y_prob.reshape(-1, 1))) + return y_pred, scores + + +class ScoringSheetLearner(Learner): + __returns__ = ScoringSheetModel + preprocessors = [HasClass(), Discretize(method=Binning()), Impute(), Continuize()] + + def __init__( + self, + num_attr_after_selection=20, + num_decision_params=5, + max_points_per_param=5, + num_input_features=None, + preprocessors=None, + ): + # Set the num_decision_params, max_points_per_param, and num_input_features normally + self.num_decision_params = num_decision_params + self.max_points_per_param = max_points_per_param + self.num_input_features = num_input_features + self.feature_to_group = None + + if preprocessors is None: + self.preprocessors = [ + *self.preprocessors, + SelectBestFeatures( + method=ReliefF(random_state=42), k=num_attr_after_selection + ), + ] + + super().__init__(preprocessors=preprocessors) + + def incompatibility_reason(self, domain): + reason = None + if len(domain.class_vars) > 1 and not self.supports_multiclass: + reason = "Too many target variables." + elif not domain.has_discrete_class: + reason = "Categorical class variable expected." + elif len(domain.class_vars[0].values) > 2: + reason = "Too many target variable values." + return reason + + def fit_storage(self, table): + if not isinstance(table, Storage): + raise TypeError("Data is not a subclass of Orange.data.Storage.") + elif table.get_nan_count_class() > 0: + raise ValueError("Class variable contains missing values.") + + if self.num_input_features is not None: + self._generate_feature_group_index(table) + + X, y, _ = table.X, table.Y, table.W if table.has_weights() else None + learner = RiskScoreOptimizer( + X=X, + y=_change_class_var_values(y), + k=self.num_decision_params, + select_top_m=1, + lb=-self.max_points_per_param, + ub=self.max_points_per_param, + group_sparsity=self.num_input_features, + featureIndex_to_groupIndex=self.feature_to_group, + ) + + self._optimize_decision_params_adjustment(learner) + + multipliers, intercepts, coefficients = learner.get_models() + + model = RiskScoreClassifier( + multiplier=multipliers[0], + intercept=intercepts[0], + coefficients=coefficients[0], + featureNames=[attribute.name for attribute in table.domain.attributes], + X_train=X if self.num_decision_params > 10 else None, + ) + + return ScoringSheetModel(model) + + def _optimize_decision_params_adjustment(self, learner): + """ + This function attempts to optimize (fit) the learner, reducing the number of decision + parameters ('k')if optimization fails due to being too high. + + Sometimes, the number of decision parameters is too high for the + number of input features. Which results in a ValueError. + Continues until successful or 'k' cannot be reduced further. + """ + while True: + try: + learner.optimize() + return True + except ValueError as e: + learner.k -= 1 + if learner.k < 1: + # Raise a custom error when k falls below 1 + raise ValueError( + "The number of input features is too low for the current settings." + ) from e + + def _generate_feature_group_index(self, table): + """ + Returns a feature index to group index mapping. The group index is used to group + binarized features that belong to the same original feature. + """ + original_feature_names = [ + attribute.compute_value.variable.name + for attribute in table.domain.attributes + ] + feature_to_group_index = { + feature: idx for idx, feature in enumerate(set(original_feature_names)) + } + feature_to_group = [ + feature_to_group_index[feature] for feature in original_feature_names + ] + self.feature_to_group = np.asarray(feature_to_group) + + +if __name__ == "__main__": + mock_learner = ScoringSheetLearner(20, 5, 10, None) + mock_table = Table("https://datasets.biolab.si/core/heart_disease.tab") + mock_model = mock_learner(mock_table) + mock_model(mock_table) diff --git a/Orange/classification/sgd.py b/Orange/classification/sgd.py index 3bb617d61c1..ba0d9494757 100644 --- a/Orange/classification/sgd.py +++ b/Orange/classification/sgd.py @@ -12,6 +12,7 @@ class SGDClassificationLearner(SklLearner): __wraps__ = SGDClassifier __returns__ = LinearModel preprocessors = SklLearner.preprocessors + [Normalize()] + supports_weights = True def __init__(self, loss='hinge', penalty='l2', alpha=0.0001, l1_ratio=0.15, fit_intercept=True, max_iter=5, diff --git a/Orange/classification/simple_random_forest.py b/Orange/classification/simple_random_forest.py index 531b9589c61..630c1f249aa 100644 --- a/Orange/classification/simple_random_forest.py +++ b/Orange/classification/simple_random_forest.py @@ -71,9 +71,14 @@ def learn(self, learner, data): tree.seed = learner.seed + i self.estimators_.append(tree(data)) - def predict_storage(self, data): - p = np.zeros((data.X.shape[0], self.cls_vals)) + def predict(self, X): + p = np.zeros((X.shape[0], self.cls_vals)) + X = np.ascontiguousarray(X) # so that it is a no-op for individual trees for tree in self.estimators_: - p += tree(data, tree.Probs) + # SimpleTrees do not have preprocessors and domain conversion + # was already handled within this class so we can call tree.predict() directly + # instead of going through tree.__call__ + _, pt = tree.predict(X) + p += pt p /= len(self.estimators_) return p.argmax(axis=1), p diff --git a/Orange/classification/simple_tree.py b/Orange/classification/simple_tree.py index 46db0851349..0b894b54f22 100644 --- a/Orange/classification/simple_tree.py +++ b/Orange/classification/simple_tree.py @@ -157,8 +157,8 @@ def __init__(self, learner, data): learner.bootstrap, learner.seed) - def predict_storage(self, data): - X = np.ascontiguousarray(data.X) + def predict(self, X): + X = np.ascontiguousarray(X) if self.type == Classification: p = np.zeros((X.shape[0], self.cls_vals)) _tree.predict_classification( diff --git a/Orange/classification/tests/test_catgb_cls.py b/Orange/classification/tests/test_catgb_cls.py index 9be0a1b24cc..1e49bbace65 100644 --- a/Orange/classification/tests/test_catgb_cls.py +++ b/Orange/classification/tests/test_catgb_cls.py @@ -115,6 +115,31 @@ def test_retain_x(self): np.testing.assert_array_equal(data.X, X) self.assertEqual(data.X.dtype, X.dtype) + def test_doesnt_modify_data(self): + # catgb is called with force-unlocked table, so let us (attempt to) + # test it doesn't actually change it + data = Table("iris") + with data.unlocked(): + data[0, 0] = 0 + data[1, 0] = np.nan + data[:, 1] = 0 + data[:, 2] = np.nan + data.Y[0] = np.nan + x, y = data.X.copy(), data.Y.copy() + booster = CatGBClassifier() + model = booster(data) + model(data) + np.testing.assert_equal(data.X, x) + np.testing.assert_equal(data.Y, y) + + with data.unlocked(): + data = data.to_sparse() + x = data.X.copy() + booster = CatGBClassifier() + model = booster(data) + model(data) + np.testing.assert_equal(data.X.data, x.data) + if __name__ == "__main__": unittest.main() diff --git a/Orange/classification/tests/test_simple_tree.py b/Orange/classification/tests/test_simple_tree.py new file mode 100644 index 00000000000..cf48db215e8 --- /dev/null +++ b/Orange/classification/tests/test_simple_tree.py @@ -0,0 +1,55 @@ +import unittest + +import numpy as np + +from Orange.classification import SimpleTreeLearner +from Orange.data import ContinuousVariable, DiscreteVariable, Domain, \ + Table + + +class SimpleTreeTest(unittest.TestCase): + def test_nonan_classification(self): + x = ContinuousVariable("x") + y = DiscreteVariable("y", values=tuple("ab")) + d = Domain([x], y) + t = Table.from_numpy(d, [[0]], [np.nan]) + m = SimpleTreeLearner()(t) + self.assertFalse(np.isnan(m(t)[0])) + + def test_nonan_regression(self): + x = ContinuousVariable("x") + y = ContinuousVariable("y") + d = Domain([x], y) + t = Table.from_numpy(d, [[42]], [np.nan]) + m = SimpleTreeLearner()(t) + # must not be nan ... + self.assertFalse(np.isnan(m(t)[0])) + # ... and currently, it's zero (although mathematicians disagree, + # we, engineers *know* that 0 is the mean of R) + self.assertEqual(m(t)[0], 0) + + x2 = ContinuousVariable("x2") + d = Domain([x, x2], y) + t = Table.from_numpy(d, + [[-1, np.nan], [1, -1], [1, 1]], + [-20, 20, np.nan]) + m = SimpleTreeLearner(min_instances=1)(t) + # must not be nan ... + self.assertFalse(np.isnan(m(t)[0])) + # ... and currently, it's zero (although mathematicians disagree, + # we, engineers *know* that 0 is the mean of R) + np.testing.assert_equal(m(t), [-20, 20, 20]) + + def test_stub(self): + x = ContinuousVariable("x") + y = ContinuousVariable("y") + d = Domain([x], y) + t = Table.from_numpy(d, [[-1], [1]], [-5, 0]) + m = SimpleTreeLearner(min_instances=1)(t) + np.testing.assert_equal(m(t), [-5, 0]) + m = SimpleTreeLearner()(t) + np.testing.assert_equal(m(t), [-2.5, -2.5]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/classification/tests/test_xgb_cls.py b/Orange/classification/tests/test_xgb_cls.py index 6e6b0c5cf4b..e1bcff05be5 100644 --- a/Orange/classification/tests/test_xgb_cls.py +++ b/Orange/classification/tests/test_xgb_cls.py @@ -55,7 +55,7 @@ def test_predict_table(self, learner_class: XGBBase): prob = model(self.iris, model.Probs) self.assertGreaterEqual(prob.all().all(), 0) self.assertLessEqual(prob.all().all(), 1) - self.assertAlmostEqual(prob.sum(), len(self.iris)) + self.assertAlmostEqual(prob.sum(), len(self.iris), 4) @test_learners def test_predict_numpy(self, learner_class: XGBBase): @@ -66,7 +66,7 @@ def test_predict_numpy(self, learner_class: XGBBase): prob = model(self.iris.X, model.Probs) self.assertGreaterEqual(prob.all().all(), 0) self.assertLessEqual(prob.all().all(), 1) - self.assertAlmostEqual(prob.sum(), len(self.iris)) + self.assertAlmostEqual(prob.sum(), len(self.iris), 4) @test_learners def test_predict_sparse(self, learner_class: XGBBase): @@ -78,7 +78,7 @@ def test_predict_sparse(self, learner_class: XGBBase): prob = model(sparse_data, model.Probs) self.assertGreaterEqual(prob.all().all(), 0) self.assertLessEqual(prob.all().all(), 1) - self.assertAlmostEqual(prob.sum(), len(sparse_data)) + self.assertAlmostEqual(prob.sum(), len(sparse_data), 4) @test_learners def test_set_params(self, learner_class: XGBBase): diff --git a/Orange/classification/tree.py b/Orange/classification/tree.py index fa8000ac175..eaef8641576 100644 --- a/Orange/classification/tree.py +++ b/Orange/classification/tree.py @@ -112,7 +112,7 @@ def _score_disc(): cont_entr = np.sum(cont * np.log(cont)) score = (class_entr - attr_entr + cont_entr) / n / np.log(2) score *= n / len(data) # punishment for missing values - branches = col_x + branches = col_x.copy() branches[np.isnan(branches)] = -1 if score == 0: return REJECT_ATTRIBUTE @@ -233,6 +233,7 @@ class SklTreeLearner(SklLearner): __wraps__ = skl_tree.DecisionTreeClassifier __returns__ = SklTreeClassifier name = 'tree' + supports_weights = True def __init__(self, criterion="gini", splitter="best", max_depth=None, min_samples_split=2, min_samples_leaf=1, diff --git a/Orange/widgets/visualize/utils/tree/__init__.py b/Orange/classification/utils/__init__.py similarity index 100% rename from Orange/widgets/visualize/utils/tree/__init__.py rename to Orange/classification/utils/__init__.py diff --git a/Orange/classification/utils/fasterrisk/LICENSE b/Orange/classification/utils/fasterrisk/LICENSE new file mode 100644 index 00000000000..70bcf6f7de8 --- /dev/null +++ b/Orange/classification/utils/fasterrisk/LICENSE @@ -0,0 +1,32 @@ + + +BSD 3-Clause License + +Copyright (c) 2022, Jiachang Liu +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/Orange/classification/utils/fasterrisk/NOTICE b/Orange/classification/utils/fasterrisk/NOTICE new file mode 100644 index 00000000000..5f82395477e --- /dev/null +++ b/Orange/classification/utils/fasterrisk/NOTICE @@ -0,0 +1,7 @@ +Notice for Use of FasterRisk Code in Orange3 + +This directory ('Orange/classification/fasterrisk') contains code from the "FasterRisk" project by Jiachang Liu. This code is used under the BSD 3-Clause License. The source of this code can be found at https://github.com/jiachangliu/FasterRisk. + +The inclusion of the FasterRisk code in this project serves as a temporary solution to address compatibility and functionality issues arising from the strict requirements of the original package. This measure will remain in place until such time as the original maintainer updates the package to address these issues. + +A copy of the BSD 3-Clause License under which the FasterRisk code is licensed is included in this directory. diff --git a/Orange/widgets/visualize/utils/tree/tests/__init__.py b/Orange/classification/utils/fasterrisk/__init__.py similarity index 100% rename from Orange/widgets/visualize/utils/tree/tests/__init__.py rename to Orange/classification/utils/fasterrisk/__init__.py diff --git a/Orange/classification/utils/fasterrisk/base_model.py b/Orange/classification/utils/fasterrisk/base_model.py new file mode 100644 index 00000000000..c2169ec52b7 --- /dev/null +++ b/Orange/classification/utils/fasterrisk/base_model.py @@ -0,0 +1,123 @@ +import numpy as np +import sys +# import warnings +# warnings.filterwarnings("ignore") +from Orange.classification.utils.fasterrisk.utils import normalize_X, compute_logisticLoss_from_ExpyXB + +class logRegModel: + def __init__(self, X, y, lambda2=1e-8, intercept=True, original_lb=-5, original_ub=5): + self.X = X + self.X_normalized, self.X_mean, self.X_norm, self.scaled_feature_indices = normalize_X(self.X) + self.n, self.p = self.X_normalized.shape + self.y = y.reshape(-1).astype(float) + self.yX = y.reshape(-1, 1) * self.X_normalized + self.yXT = np.zeros((self.p, self.n)) + self.yXT[:] = np.transpose(self.yX)[:] + self.beta0 = 0 + self.betas = np.zeros((self.p, )) + self.ExpyXB = np.exp(self.y * self.beta0 + self.yX.dot(self.betas)) + + self.intercept = intercept + self.lambda2 = lambda2 + self.twoLambda2 = 2 * self.lambda2 + + self.Lipschitz = 0.25 + self.twoLambda2 + self.lbs = original_lb * np.ones(self.p) + self.lbs[self.scaled_feature_indices] *= self.X_norm[self.scaled_feature_indices] + self.ubs = original_ub * np.ones(self.p) + self.ubs[self.scaled_feature_indices] *= self.X_norm[self.scaled_feature_indices] + + self.total_child_added = 0 + + def warm_start_from_original_beta0_betas(self, original_beta0, original_betas): + # betas_initial has dimension (p+1, 1) + self.original_beta0 = original_beta0 + self.original_betas = original_betas + self.beta0, self.betas = self.transform_coefficients_to_normalized_space(self.original_beta0, self.original_betas) + print("warmstart solution in normalized space is {} and {}".format(self.beta0, self.betas)) + self.ExpyXB = np.exp(self.y * self.beta0 + self.yX.dot(self.betas)) + + def warm_start_from_beta0_betas(self, beta0, betas): + self.beta0, self.betas = beta0, betas + self.ExpyXB = np.exp(self.y * self.beta0 + self.yX.dot(self.betas)) + + def warm_start_from_beta0_betas_ExpyXB(self, beta0, betas, ExpyXB): + self.beta0, self.betas, self.ExpyXB = beta0, betas, ExpyXB + + def get_beta0_betas(self): + return self.beta0, self.betas + + def get_beta0_betas_ExpyXB(self): + return self.beta0, self.betas, self.ExpyXB + + def get_original_beta0_betas(self): + return self.transform_coefficients_to_original_space(self.beta0, self.betas) + + def transform_coefficients_to_original_space(self, beta0, betas): + original_betas = betas.copy() + original_betas[self.scaled_feature_indices] = original_betas[self.scaled_feature_indices]/self.X_norm[self.scaled_feature_indices] + original_beta0 = beta0 - np.dot(self.X_mean, original_betas) + return original_beta0, original_betas + + def transform_coefficients_to_normalized_space(self, original_beta0, original_betas): + betas = original_betas.copy() + betas[self.scaled_feature_indices] = betas[self.scaled_feature_indices] * self.X_norm[self.scaled_feature_indices] + beta0 = original_beta0 + self.X_mean.dot(original_betas) + return beta0, betas + + def get_grad_at_coord(self, ExpyXB, betas_j, yX_j, j): + # return -np.dot(1/(1+ExpyXB), self.yX[:, j]) + self.twoLambda2 * betas_j + # return -np.inner(1/(1+ExpyXB), self.yX[:, j]) + self.twoLambda2 * betas_j + # return -np.inner(np.reciprocal(1+ExpyXB), self.yX[:, j]) + self.twoLambda2 * betas_j + return -np.inner(np.reciprocal(1+ExpyXB), yX_j) + self.twoLambda2 * betas_j + # return -yX_j.dot(np.reciprocal(1+ExpyXB)) + self.twoLambda2 * betas_j + + def update_ExpyXB(self, ExpyXB, yX_j, diff_betas_j): + ExpyXB *= np.exp(yX_j * diff_betas_j) + + def optimize_1step_at_coord(self, ExpyXB, betas, yX_j, j): + # in-place modification, heck that ExpyXB and betas are passed by reference + prev_betas_j = betas[j] + current_betas_j = prev_betas_j + grad_at_j = self.get_grad_at_coord(ExpyXB, current_betas_j, yX_j, j) + step_at_j = grad_at_j / self.Lipschitz + current_betas_j = prev_betas_j - step_at_j + # current_betas_j = np.clip(current_betas_j, self.lbs[j], self.ubs[j]) + current_betas_j = max(self.lbs[j], min(self.ubs[j], current_betas_j)) + diff_betas_j = current_betas_j - prev_betas_j + betas[j] = current_betas_j + + # ExpyXB *= np.exp(yX_j * diff_betas_j) + self.update_ExpyXB(ExpyXB, yX_j, diff_betas_j) + + def finetune_on_current_support(self, ExpyXB, beta0, betas, total_CD_steps=100): + + support = np.where(np.abs(betas) > 1e-9)[0] + grad_on_support = -self.yXT[support].dot(np.reciprocal(1+ExpyXB)) + self.twoLambda2 * betas[support] + abs_grad_on_support = np.abs(grad_on_support) + support = support[np.argsort(-abs_grad_on_support)] + + loss_before = compute_logisticLoss_from_ExpyXB(ExpyXB) + self.lambda2 * betas[support].dot(betas[support]) + for steps in range(total_CD_steps): # number of iterations for coordinate descent + + if self.intercept: + grad_intercept = -np.reciprocal(1+ExpyXB).dot(self.y) + step_at_intercept = grad_intercept / (self.n * 0.25) # lipschitz constant is 0.25 at the intercept + beta0 = beta0 - step_at_intercept + ExpyXB *= np.exp(self.y * (-step_at_intercept)) + + for j in support: + self.optimize_1step_at_coord(ExpyXB, betas, self.yXT[j, :], j) # in-place modification on ExpyXB and betas + + if steps % 10 == 0: + loss_after = compute_logisticLoss_from_ExpyXB(ExpyXB) + self.lambda2 * betas[support].dot(betas[support]) + if abs(loss_before - loss_after)/loss_after < 1e-8: + # print("break after {} steps; support size is {}".format(steps, len(support))) + break + loss_before = loss_after + + return ExpyXB, beta0, betas + + def compute_yXB(self, beta0, betas): + return self.y*(beta0 + np.dot(self.X_normalized, betas)) + \ No newline at end of file diff --git a/Orange/classification/utils/fasterrisk/fasterrisk.py b/Orange/classification/utils/fasterrisk/fasterrisk.py new file mode 100644 index 00000000000..d7d5c92cf8b --- /dev/null +++ b/Orange/classification/utils/fasterrisk/fasterrisk.py @@ -0,0 +1,320 @@ +import numpy as np +import sklearn.metrics + +from Orange.classification.utils.fasterrisk.sparseBeamSearch import sparseLogRegModel, groupSparseLogRegModel +from Orange.classification.utils.fasterrisk.sparseDiversePool import sparseDiversePoolLogRegModel, groupSparseDiversePoolLogRegModel +from Orange.classification.utils.fasterrisk.rounding import starRaySearchModel + +from Orange.classification.utils.fasterrisk.utils import compute_logisticLoss_from_X_y_beta0_betas, get_all_product_booleans, get_support_indices, get_all_product_booleans, get_groupIndex_to_featureIndices, check_bounds + +class RiskScoreOptimizer: + def __init__(self, X, y, k, select_top_m=50, lb=-5, ub=5, \ + gap_tolerance=0.05, parent_size=10, child_size=None, \ + maxAttempts=50, num_ray_search=20, \ + lineSearch_early_stop_tolerance=0.001, \ + group_sparsity=None, featureIndex_to_groupIndex=None): + """Initialize the RiskScoreOptimizer class, which performs sparseBeamSearch and generates integer sparseDiverseSet + + Parameters + ---------- + X : ndarray + (2D array with `float` type) feature matrix, each row[i, :] corresponds to the features of sample i + y : ndarray + (1D array with `float` type) labels (+1 or -1) of each sample + k : int + number of selected features in the final sparse model + select_top_m : int, optional + number of top solutions to keep among the pool of diverse sparse solutions, by default 50 + lb : float or list, optional + lower bound(s) of the coefficients, when passed as a list, specifies lower bounds for all the features in X, by default -5 + ub : float or list, optional + upper bound(s) of the coefficients, when passed as a list, specifies lower bounds for all the features in X, by default 5 + parent_size : int, optional + how many solutions to retain after beam search, by default 10 + child_size : int, optional + how many new solutions to expand for each existing solution, by default None + maxAttempts : int, optional + how many alternative features to try in order to replace the old feature during the diverse set pool generation, by default None + num_ray_search : int, optional + how many multipliers to try for each continuous sparse solution, by default 20 + lineSearch_early_stop_tolerance : float, optional + tolerance level to stop linesearch early (error_of_loss_difference/loss_of_continuous_solution), by default 0.001 + group_sparsity : int, optional + number of groups to be selected, by default None + featureIndex_to_groupIndex : ndarray, optional + (1D array with `int` type) featureIndex_to_groupIndex[i] is the group index of feature i, by default None + """ + + # check the formats of inputs X and y + y_shape = y.shape + y_unique = np.unique(y) + y_unique_expected = np.asarray([-1, 1]) + X_shape = X.shape + assert len(y_shape) == 1, "input y must have 1-D shape!" + assert len(y_unique) == 2, "input y must have only 2 labels" + assert max(np.abs(y_unique - y_unique_expected)) < 1e-8, "input y must be equal to only +1 or -1" + assert len(X_shape) == 2, "input X must have 2-D shape!" + assert X_shape[0] == y_shape[0], "number of rows from input X must be equal to the number of elements from input y!" + self.y = y + self.X = X + + self.k = k + self.parent_size = parent_size + self.child_size = self.parent_size + if child_size is not None: + self.child_size = child_size + + self.sparseDiverseSet_gap_tolerance = gap_tolerance + self.sparseDiverseSet_select_top_m = select_top_m + self.sparseDiverseSet_maxAttempts = maxAttempts + + lb = check_bounds(lb, 'lb', X_shape[1]) + ub = check_bounds(ub, 'ub', X_shape[1]) + + self.group_sparsity = group_sparsity + self.featureIndex_to_groupIndex = featureIndex_to_groupIndex + + if self.group_sparsity is None: + self.sparseLogRegModel_object = sparseLogRegModel(X, y, intercept=True, original_lb=lb, original_ub=ub) + self.sparseDiversePoolLogRegModel_object = sparseDiversePoolLogRegModel(X, y, intercept=True, original_lb=lb, original_ub=ub) + else: + assert type(group_sparsity) == int, "group_sparsity needs to be an integer" + assert group_sparsity > 0, "group_sparsity needs to be > 0!" + assert group_sparsity > 0, "group_sparsity needs to be > 0!" + + assert self.featureIndex_to_groupIndex is not None, "featureIndex_to_groupIndex must be provided if group_sparsity is not None" + assert type(self.featureIndex_to_groupIndex[0]) == np.int_, "featureIndex_to_groupIndex needs to be a NumPy integer array" + + self.groupIndex_to_featureIndices = get_groupIndex_to_featureIndices(self.featureIndex_to_groupIndex) + + self.sparseLogRegModel_object = groupSparseLogRegModel(X, y, intercept=True, original_lb=lb, original_ub=ub, group_sparsity=self.group_sparsity, featureIndex_to_groupIndex=self.featureIndex_to_groupIndex, groupIndex_to_featureIndices=self.groupIndex_to_featureIndices) + self.sparseDiversePoolLogRegModel_object = groupSparseDiversePoolLogRegModel(X, y, intercept=True, original_lb=lb, original_ub=ub, group_sparsity=self.group_sparsity, featureIndex_to_groupIndex=self.featureIndex_to_groupIndex, groupIndex_to_featureIndices=self.groupIndex_to_featureIndices) + + self.starRaySearchModel_object = starRaySearchModel(X = X, y = y, lb=lb, ub=ub, num_ray_search=num_ray_search, early_stop_tolerance=lineSearch_early_stop_tolerance) + + self.IntegerPoolIsSorted = False + + def optimize(self): + """performs sparseBeamSearch, generates integer sparseDiverseSet, and perform star ray search + """ + self.sparseLogRegModel_object.get_sparse_sol_via_OMP(k=self.k, parent_size=self.parent_size, child_size=self.child_size) + + beta0, betas, ExpyXB = self.sparseLogRegModel_object.get_beta0_betas_ExpyXB() + self.sparseDiversePoolLogRegModel_object.warm_start_from_beta0_betas_ExpyXB(beta0 = beta0, betas = betas, ExpyXB = ExpyXB) + sparseDiversePool_beta0, sparseDiversePool_betas = self.sparseDiversePoolLogRegModel_object.get_sparseDiversePool(gap_tolerance=self.sparseDiverseSet_gap_tolerance, select_top_m=self.sparseDiverseSet_select_top_m, maxAttempts=self.sparseDiverseSet_maxAttempts) + + self.multipliers, self.sparseDiversePool_beta0_integer, self.sparseDiversePool_betas_integer = self.starRaySearchModel_object.star_ray_search_scale_and_round(sparseDiversePool_beta0, sparseDiversePool_betas) + + def _sort_IntegerPool_on_logisticLoss(self): + """sort the integer solutions in the pool by ascending order of logistic loss + """ + sparseDiversePool_XB = (self.sparseDiversePool_beta0_integer.reshape(1, -1) + self.X @ self.sparseDiversePool_betas_integer.transpose()) / (self.multipliers.reshape(1, -1)) + sparseDiversePool_yXB = self.y.reshape(-1, 1) * sparseDiversePool_XB + sparseDiversePool_ExpyXB = np.exp(sparseDiversePool_yXB) + # print(sparseDiversePool_ExpyXB.shape) + sparseDiversePool_logisticLoss = np.sum(np.log(1.+np.reciprocal(sparseDiversePool_ExpyXB)), axis=0) + orderedIndices = np.argsort(sparseDiversePool_logisticLoss) + + self.multipliers = self.multipliers[orderedIndices] + self.sparseDiversePool_beta0_integer = self.sparseDiversePool_beta0_integer[orderedIndices] + self.sparseDiversePool_betas_integer = self.sparseDiversePool_betas_integer[orderedIndices] + + self.IntegerPoolIsSorted = True + + def get_models(self, model_index=None): + """get risk score models + + Parameters + ---------- + model_index : int, optional + index of the model in the integer sparseDiverseSet, by default None + + Returns + ------- + multipliers : ndarray + (1D array with `float` type) multipliers with each entry as multipliers[i] + sparseDiversePool_integer : ndarray + (2D array with `float` type) integer coefficients (intercept included) with each row as an integer solution sparseDiversePool_integer[i] + """ + if self.IntegerPoolIsSorted is False: + self._sort_IntegerPool_on_logisticLoss() + if model_index is not None: + return self.multipliers[model_index], self.sparseDiversePool_beta0_integer[model_index], self.sparseDiversePool_betas_integer[model_index] + return self.multipliers, self.sparseDiversePool_beta0_integer, self.sparseDiversePool_betas_integer + + + +class RiskScoreClassifier: + def __init__(self, multiplier, intercept, coefficients, featureNames = None, X_train = None): + """Initialize a risk score classifier. Then we can use this classifier to predict labels, predict probabilites, and calculate total logistic loss + + Parameters + ---------- + multiplier : float + multiplier of the risk score model + intercept : float + intercept of the risk score model + coefficients : ndarray + (1D array with `float` type) coefficients of the risk score model + """ + self.multiplier = multiplier + self.intercept = intercept + self.coefficients = coefficients + + self.scaled_intercept = self.intercept / self.multiplier + self.scaled_coefficients = self.coefficients / self.multiplier + + self.X_train = X_train + + self.reset_featureNames(featureNames) + + def predict(self, X): + """Predict labels + + Parameters + ---------- + X : ndarray + (2D array with `float` type) feature matrix with shape (n, p) + + Returns + ------- + y_pred : ndarray + (1D array with `float` type) predicted labels (+1.0 or -1.0) with shape (n, ) + """ + y_score = (self.intercept + X.dot(self.coefficients)) / self.multiplier # numpy dot.() has some floating point error issues, so we avoid using self.scaled_intercept and self.scaled_coefficients directly + y_pred = 2 * (y_score > 0) - 1 + return y_pred + + def predict_prob(self, X): + """Calculate the risk probabilities of predicting each sample y_i with label +1 + + Parameters + ---------- + X : ndarray + (2D array with `float` type) feature matrix with shape (n, p) + + Returns + ------- + y_pred_prob : ndarray + (1D array with `float` type) probabilities of each sample y_i to be +1 with shape (n, ) + """ + y_score = (self.intercept + X.dot(self.coefficients)) / self.multiplier # numpy dot.() has some floating point error issues, so we avoid using self.scaled_intercept and self.scaled_coefficients directly + y_pred_prob = 1/(1+np.exp(-y_score)) + + return y_pred_prob + + def compute_logisticLoss(self, X, y): + """Compute the total logistic loss given the feature matrix X and labels y + + Parameters + ---------- + X : ndarray + (2D array with `float` type) feature matrix with shape (n, p) + y : ndarray + (1D array with `float` type) sample labels (+1 or -1) with shape (n) + + Returns + ------- + logisticLoss: float + total logistic loss, loss = $sum_{i=1}^n log(1+exp(-y_i * (beta0 + X[i, :] @ beta) / multiplier))$ + """ + return compute_logisticLoss_from_X_y_beta0_betas(X, y, self.scaled_intercept, self.scaled_coefficients) + + def get_acc_and_auc(self, X, y): + """Calculate ACC and AUC of a certain dataset with features X and label y + + Parameters + ---------- + X : ndarray + (2D array with `float` type) 2D array storing the features + y : ndarray + (1D array with `float` type) storing the labels (+1/-1) + + Returns + ------- + acc: float + accuracy + auc: float + area under the ROC curve + """ + y_pred = self.predict(X) + # print(y_pred.shape, y.shape) + acc = np.sum(y_pred == y) / len(y) + y_pred_prob = self.predict_prob(X) + + fpr, tpr, thresholds = sklearn.metrics.roc_curve(y_true=y, y_score=y_pred_prob, drop_intermediate=False) + auc = sklearn.metrics.auc(fpr, tpr) + return acc, auc + + def reset_featureNames(self, featureNames): + """Reset the feature names in the class in order to print out the model card for the user + + Parameters + ---------- + featureNames : str[:] + a list of strings which are the feature names for columns of X + """ + self.featureNames = featureNames + + def _print_score_calculation_table(self): + assert self.featureNames is not None, "please pass the featureNames to the model by using the function .reset_featureNames(featureNames)" + + nonzero_indices = get_support_indices(self.coefficients) + + max_feature_length = max([len(featureName) for featureName in self.featureNames]) + row_score_template = '{0}. {1:>%d} {2:>2} point(s) | + ...' % (max_feature_length) + + print("The Risk Score is:") + for count, feature_i in enumerate(nonzero_indices): + row_score_str = row_score_template.format(count+1, self.featureNames[feature_i], int(self.coefficients[feature_i])) + if count == 0: + row_score_str = row_score_str.replace("+", " ") + + print(row_score_str) + + final_score_str = ' ' * (14+max_feature_length) + 'SCORE | = ' + print(final_score_str) + + def _print_score_risk_row(self, scores, risks): + score_row = "SCORE |" + risk_row = "RISK |" + score_entry_template = ' {0:>4} |' + risk_entry_template = ' {0:>5}% |' + for (score, risk) in zip(scores, risks): + score_row += score_entry_template.format(score) + risk_row += risk_entry_template.format(round(100*risk, 1)) + print(score_row) + print(risk_row) + + def _print_score_risk_table(self, quantile_len): + + nonzero_indices = get_support_indices(self.coefficients) + len_nonzero_indices = len(nonzero_indices) + + if len_nonzero_indices <= 10: + ### method 1: get all possible scores; Drawback for large support size, get the product booleans is too many + all_product_booleans = get_all_product_booleans(len_nonzero_indices) + all_scores = all_product_booleans.dot(self.coefficients[nonzero_indices]) + all_scores = np.unique(all_scores) + else: + # ### method 2: calculate all scores in the training set, pick the top 20 quantile points + assert self.X_train is not None, "There are more than 10 nonzero coefficients for the risk scoring system. The number of possible total scores is too many!\n\nPlease consider re-initialize your RiskScoreClassifier_m by providing the training dataset features X_train as follows:\n\n RiskScoreClassifier_m = RiskScoreClassifier(multiplier, intercept, coefficients, X_train = X_train)" + + all_scores = self.X_train.dot(self.coefficients) + all_scores = np.unique(all_scores) + quantile_len = min(quantile_len, len(all_scores)) + quantile_points = np.asarray(range(1, 1+quantile_len)) / quantile_len + all_scores = np.quantile(all_scores, quantile_points, method = "closest_observation") + + all_scaled_scores = (self.intercept + all_scores) / self.multiplier + all_risks = 1 / (1 + np.exp(-all_scaled_scores)) + + num_scores_div_2 = (len(all_scores) + 1) // 2 + self._print_score_risk_row(all_scores[:num_scores_div_2], all_risks[:num_scores_div_2]) + self._print_score_risk_row(all_scores[num_scores_div_2:], all_risks[num_scores_div_2:]) + + def print_model_card(self, quantile_len=20): + """Print the score evaluation table and score risk table onto terminal + """ + self._print_score_calculation_table() + self._print_score_risk_table(quantile_len = quantile_len) \ No newline at end of file diff --git a/Orange/classification/utils/fasterrisk/rounding.py b/Orange/classification/utils/fasterrisk/rounding.py new file mode 100644 index 00000000000..dbfaa6726d5 --- /dev/null +++ b/Orange/classification/utils/fasterrisk/rounding.py @@ -0,0 +1,241 @@ +import numpy as np +import sys +# import warnings +# warnings.filterwarnings("ignore") + +from Orange.classification.utils.fasterrisk.utils import get_support_indices, compute_logisticLoss_from_betas_and_yX, insertIntercept_asFirstColOf_X + +class starRaySearchModel: + def __init__(self, X, y, lb=-5, ub=5, num_ray_search=20, early_stop_tolerance=0.001): + self.X = insertIntercept_asFirstColOf_X(X) + self.y = y.reshape(-1) + self.yX = self.y.reshape(-1, 1) * self.X + + self.n = self.X.shape[0] + self.p = self.X.shape[1] + + if isinstance(ub, (float, int)): + self.ub_arr = ub * np.ones((self.p, )) + self.ub_arr[0] = 100.0 # intercept upper bound + else: + self.ub_arr = np.insert(ub, 0, 100.0) # add intercept upper bound + + if isinstance(lb, (float, int)): + self.lb_arr = lb * np.ones((self.p, )) + self.lb_arr[0] = -100.0 # intercept lower bound + else: + self.lb_arr = np.insert(lb, 0, -100) # add intercept lower bound + + self.num_ray_search = num_ray_search + self.early_stop_tolerance = early_stop_tolerance + + def get_multipliers_for_line_search(self, betas): + """Get an array of multipliers to try for line search + + Parameters + ---------- + betas : ndarray + (1D array with `float` type) a given solution with shape = (1+p, ) assuming the first entry is the intercept + + Returns + ------- + multipliers : ndarray + (1D array with `float` type) an array of candidate multipliers with shape = (num_ray_search, ) + """ + # largest_multiplier = min(self.abs_coef_ub/np.max(np.abs(betas[1:])), self.abs_intercept_ub/abs(betas[0])) + pos_nonzeroIndices = np.where(betas > 1e-8)[0] + neg_nonzeroIndices = np.where(betas < -1e-8)[0] + len_pos_nonzeroIndices = len(pos_nonzeroIndices) + len_neg_nonzeroIndices = len(neg_nonzeroIndices) + + assert len_pos_nonzeroIndices + len_neg_nonzeroIndices > 0, "betas needs to have at least one nonzero entries!" + largest_multiplier = 1e8 + if len_pos_nonzeroIndices > 0: + largest_multiplier = min(largest_multiplier, min(self.ub_arr[pos_nonzeroIndices] / betas[pos_nonzeroIndices])) + if len_neg_nonzeroIndices > 0: + largest_multiplier = min(largest_multiplier, min(self.lb_arr[neg_nonzeroIndices] / betas[neg_nonzeroIndices])) + + if largest_multiplier > 1: + multipliers = np.linspace(1, largest_multiplier, self.num_ray_search) + else: + multipliers = np.linspace(1, 0.5, self.num_ray_search) + return multipliers + + def star_ray_search_scale_and_round(self, sparseDiversePool_beta0_continuous, sparseDiversePool_betas_continuous): + """For each continuous solution in the sparse diverse pool, find the best multiplier and integer solution. Return the best integer solutions and the corresponding multipliers in the sparse diverse pool + + Parameters + ---------- + sparseDiversePool_beta_continuous : ndarray + (1D array with `float` type) an array of continuous intercept with shape = (m, ) + sparseDiversePool_betas_continuous : ndarray + (2D array with `float` type) an array of continuous coefficients with shape = (m, p) + + Returns + ------- + multipliers : ndarray + (1D array with `float` type) best multiplier for each continuous solution with shape = (m, ) + best_beta0 : ndarray + (1D array with `float` type) best integer intercept for each continuous solution with shape = (m, ) + best_betas : ndarray + (2D array with `float` type) best integer coefficient for each continuous solution with shape = (m, p) + """ + sparseDiversePool_continuous = np.hstack((sparseDiversePool_beta0_continuous.reshape(-1, 1), sparseDiversePool_betas_continuous)) + + sparseDiversePool_integer = np.zeros(sparseDiversePool_continuous.shape) + multipliers = np.zeros((sparseDiversePool_integer.shape[0])) + + for i in range(len(multipliers)): + multipliers[i], sparseDiversePool_integer[i] = self.line_search_scale_and_round(sparseDiversePool_continuous[i]) + + return multipliers, sparseDiversePool_integer[:, 0], sparseDiversePool_integer[:, 1:] + + def line_search_scale_and_round(self, betas): + """For a given solution betas, multiply the solution with different multipliers and round each scaled solution to integers. Return the best integer solution based on the logistic loss. + + Parameters + ---------- + betas : ndarray + (1D array with `float` type) a given solution with shape = (1+p, ) assuming the first entry is the intercept + + Returns + ------- + best_multiplier : float + best multiplier among all pairs of (multiplier, integer_solution) + best_betas : ndarray + (1D array with `float` type) best integer solution among all pairs of (multiplier, integer_solution) + """ + nonzero_indices = get_support_indices(betas) + num_nonzero = len(nonzero_indices) + + # X_sub = self.X[:, nonzero_indices] + yX_sub = self.yX[:, nonzero_indices] + betas_sub = betas[nonzero_indices] + + multipliers = self.get_multipliers_for_line_search(betas_sub) + + loss_continuous_betas = compute_logisticLoss_from_betas_and_yX(betas_sub, yX_sub) + + best_multiplier = 1.0 + best_loss = 1e12 + best_betas_sub = np.zeros((num_nonzero, )) + + for multiplier in multipliers: + betas_sub_scaled = betas_sub * multiplier + yX_sub_scaled = yX_sub / multiplier + + betas_sub_scaled = self.auxilliary_rounding(betas_sub_scaled, yX_sub_scaled) + + tmp_loss = compute_logisticLoss_from_betas_and_yX(betas_sub_scaled / multiplier, yX_sub) + + if tmp_loss < best_loss: + best_loss = tmp_loss + best_multiplier = multiplier + best_betas_sub[:] = betas_sub_scaled[:] + + if (tmp_loss - loss_continuous_betas) / loss_continuous_betas < self.early_stop_tolerance: + break + + best_betas = np.zeros((self.p, )) + best_betas[nonzero_indices] = best_betas_sub + + return best_multiplier, best_betas + + def get_rounding_distance_and_dimension(self, betas): + """For each dimension, get distances from the real coefficient to the rounded-up integer and the rounded-down integer + + Parameters + ---------- + betas : ndarray + (1D array with `float` type) current continuous (real-valued) solution + + Returns + ------- + betas_floor : ndarray + (1D array with `float` type) rounded-down coefficients + dist_from_start_to_floor: ndarray + (1D array with `float` type) distance from the real coefficient to the rounded-down integer + betas_ceil : ndarray + (1D array with `float` type) rounded-up coefficients + dist_from_start_to_ceil: ndarray + (1D array with `float` type) distance from the real coefficient to the rounded-up integer + dimensions_to_round: int[:] + array of indices where the coefficients are not integers to begin with and upon which we should do rounding + """ + betas_floor = np.floor(betas) + # floor_is_zero = np.equal(betas_floor, 0) + dist_from_start_to_floor = betas_floor - betas + + betas_ceil = np.ceil(betas) + # ceil_is_zero = np.equal(betas_ceil, 0) + dist_from_start_to_ceil = betas_ceil - betas + + dimensions_to_round = np.flatnonzero(np.not_equal(betas_floor, betas_ceil)).tolist() + + return betas_floor, dist_from_start_to_floor, betas_ceil, dist_from_start_to_ceil, dimensions_to_round + + def auxilliary_rounding(self, betas, yX): + """Round the solutions to intgers according to the auxilliary loss proposed in the paper + + Parameters + ---------- + betas : ndarray + (1D array with `float` type) current continuous (real-valued) solution + yX : ndarray + (2D array with `float` type) yX[i, j] = y[i] * X[i, j] + + Returns + ------- + integer_beta : ndarray + (1D array with `float` type) rounded integer solution + """ + n_local, p_local = yX.shape[0], yX.shape[1] + + betas_floor, dist_from_start_to_floor, betas_ceil, dist_from_start_to_ceil, dimensions_to_round = self.get_rounding_distance_and_dimension(betas) + + # yXB = yX.dot(betas) # shape is (n_local, ) + + Gamma = np.zeros((n_local, p_local)) + Gamma[:] = betas_floor + Gamma = Gamma + 1.0 * (yX <= 0) + + yX_Gamma = yX * Gamma + yXB_extreme = np.sum(yX_Gamma, axis=1) + l_factors = np.reciprocal((1 + np.exp(yXB_extreme))) # corresponding to l_i's in the NeurIPS paper + + lyX = l_factors.reshape(-1, 1) * yX + lyX_norm_square = np.sum(lyX * lyX, axis = 0) + + upperBound_arr = 1e12 * np.ones((2 * p_local)) + lyXB_diff = np.zeros((n_local, )) # at the start, betas are not rounded, so coefficient difference is zero + current_upperBound = 0 # at the start, upper is also 0 because betas have not been rounded yet + + while len(dimensions_to_round) > 0: + upperBound_arr.fill(1e12) + + for j in dimensions_to_round: + upperBound_expectation = current_upperBound - lyX_norm_square[j] * dist_from_start_to_floor[j] * dist_from_start_to_ceil[j] + + lyX_j = lyX[:, j] + lyXB_diff_floor_j = lyXB_diff + dist_from_start_to_ceil[j] * lyX_j + upperBound_arr[2*j+1] = np.sum(lyXB_diff_floor_j ** 2) # odd positions stores upper bound for ceiling operation + + if upperBound_arr[2*j+1] > upperBound_expectation: + lyXB_diff_ceil_j = lyXB_diff + dist_from_start_to_floor[j] * lyX_j + upperBound_arr[2*j] = np.sum(lyXB_diff_ceil_j ** 2) # even positions stores upper bound for flooring operation + + best_idx_upperBound_arr = np.argmin(upperBound_arr) + current_upperBound = upperBound_arr[best_idx_upperBound_arr] + + best_j, is_ceil = best_idx_upperBound_arr // 2, best_idx_upperBound_arr % 2 + + if is_ceil: + betas[best_j] += dist_from_start_to_ceil[best_j] + lyXB_diff = lyXB_diff + dist_from_start_to_ceil[best_j] * lyX[:, best_j] + else: + betas[best_j] += dist_from_start_to_floor[best_j] + lyXB_diff = lyXB_diff + dist_from_start_to_floor[best_j] * lyX[:, best_j] + + dimensions_to_round.remove(best_j) + + return betas \ No newline at end of file diff --git a/Orange/classification/utils/fasterrisk/sparseBeamSearch.py b/Orange/classification/utils/fasterrisk/sparseBeamSearch.py new file mode 100644 index 00000000000..29a9351b112 --- /dev/null +++ b/Orange/classification/utils/fasterrisk/sparseBeamSearch.py @@ -0,0 +1,192 @@ +import numpy as np +import sys +# import warnings +# warnings.filterwarnings("ignore") + +from Orange.classification.utils.fasterrisk.utils import get_support_indices, get_nonsupport_indices, compute_logisticLoss_from_ExpyXB +from Orange.classification.utils.fasterrisk.base_model import logRegModel + +class sparseLogRegModel(logRegModel): + def __init__(self, X, y, lambda2=1e-8, intercept=True, original_lb=-5, original_ub=5): + super().__init__(X=X, y=y, lambda2=lambda2, intercept=intercept, original_lb=original_lb, original_ub=original_ub) + + def getAvailableIndices_for_expansion(self, betas): + """Get the indices of features that can be added to the support of the current sparse solution + + Parameters + ---------- + betas : ndarray + (1D array with `float` type) The current sparse solution + + Returns + ------- + available_indices : ndarray + (1D array with `int` type) The indices of features that can be added to the support of the current sparse solution + """ + available_indices = get_nonsupport_indices(betas) + return available_indices + + def expand_parent_i_support_via_OMP_by_1(self, i, child_size=10): + """For parent solution i, generate [child_size] child solutions + + Parameters + ---------- + i : int + index of the parent solution + child_size : int, optional + how many child solutions to generate based on parent solution i, by default 10 + """ + # non_support = get_nonsupport_indices(self.betas_arr_parent[i]) + non_support = self.getAvailableIndices_for_expansion(self.betas_arr_parent[i]) + support = get_support_indices(self.betas_arr_parent[i]) + + grad_on_non_support = self.yXT[non_support].dot(np.reciprocal(1+self.ExpyXB_arr_parent[i])) + abs_grad_on_non_support = np.abs(grad_on_non_support) + + num_new_js = min(child_size, len(non_support)) + new_js = non_support[np.argsort(-abs_grad_on_non_support)][:num_new_js] + child_start, child_end = i*child_size, i*child_size + num_new_js + + self.ExpyXB_arr_child[child_start:child_end] = self.ExpyXB_arr_parent[i, :] # (num_new_js, n) + # self.betas_arr_child[child_start:child_end, non_support] = 0 + self.betas_arr_child[child_start:child_end] = 0 + self.betas_arr_child[child_start:child_end, support] = self.betas_arr_parent[i, support] + self.beta0_arr_child[child_start:child_end] = self.beta0_arr_parent[i] + + beta_new_js = np.zeros((num_new_js, )) #(len(new_js), ) + diff_max = 1e3 + + step = 0 + while step < 10 and diff_max > 1e-3: + prev_beta_new_js = beta_new_js.copy() + grad_on_new_js = -np.sum(self.yXT[new_js] * np.reciprocal(1.+self.ExpyXB_arr_child[child_start:child_end]), axis=1) + self.twoLambda2 * beta_new_js + step_at_new_js = grad_on_new_js / self.Lipschitz + + beta_new_js = prev_beta_new_js - step_at_new_js + beta_new_js = np.clip(beta_new_js, self.lbs[new_js], self.ubs[new_js]) + diff_beta_new_js = beta_new_js - prev_beta_new_js + + self.ExpyXB_arr_child[child_start:child_end] *= np.exp(self.yXT[new_js] * diff_beta_new_js.reshape(-1, 1)) + + diff_max = max(np.abs(diff_beta_new_js)) + step += 1 + + for l in range(num_new_js): + child_id = child_start + l + self.betas_arr_child[child_id, new_js[l]] = beta_new_js[l] + tmp_support_str = str(get_support_indices(self.betas_arr_child[child_id])) + if tmp_support_str not in self.forbidden_support: + self.total_child_added += 1 # count how many unique child has been added for a specified support size + self.forbidden_support.add(tmp_support_str) + + self.ExpyXB_arr_child[child_id], self.beta0_arr_child[child_id], self.betas_arr_child[child_id] = self.finetune_on_current_support(self.ExpyXB_arr_child[child_id], self.beta0_arr_child[child_id], self.betas_arr_child[child_id]) + self.loss_arr_child[child_id] = compute_logisticLoss_from_ExpyXB(self.ExpyXB_arr_child[child_id]) + + def beamSearch_multipleSupports_via_OMP_by_1(self, parent_size=10, child_size=10): + """Each parent solution generates [child_size] child solutions, so there will be [parent_size] * [child_size] number of total child solutions. However, only the top [parent_size] child solutions are retained as parent solutions for the next level i+1. + + Parameters + ---------- + parent_size : int, optional + how many top solutions to retain at each level, by default 10 + child_size : int, optional + how many child solutions to generate based on each parent solution, by default 10 + """ + self.loss_arr_child.fill(1e12) + self.total_child_added = 0 + + for i in range(self.num_parent): + self.expand_parent_i_support_via_OMP_by_1(i, child_size=child_size) + + child_indices = np.argsort(self.loss_arr_child)[:min(parent_size, self.total_child_added)] # get indices of children which have the smallest losses + num_child_indices = len(child_indices) + self.ExpyXB_arr_parent[:num_child_indices], self.beta0_arr_parent[:num_child_indices], self.betas_arr_parent[:num_child_indices] = self.ExpyXB_arr_child[child_indices], self.beta0_arr_child[child_indices], self.betas_arr_child[child_indices] + + self.num_parent = num_child_indices + + def get_sparse_sol_via_OMP(self, k, parent_size=10, child_size=10): + """Get sparse solution through beam search and orthogonal matching pursuit (OMP), for level i, each parent solution generates [child_size] child solutions, so there will be [parent_size] * [child_size] number of total child solutions. However, only the top [parent_size] child solutions are retained as parent solutions for the next level i+1. + + Parameters + ---------- + k : int + number of nonzero coefficients for the final sparse solution + parent_size : int, optional + how many top solutions to retain at each level, by default 10 + child_size : int, optional + how many child solutions to generate based on each parent solution, by default 10 + """ + nonzero_indices_set = set(np.where(np.abs(self.betas) > 1e-9)[0]) + # print("get_sparse_sol_via_OMP, initial support is:", nonzero_indices_set) + zero_indices_set = set(range(self.p)) - nonzero_indices_set + num_nonzero = len(nonzero_indices_set) + + if len(zero_indices_set) == 0: + return + + # if there is no warm start solution, initialize beta0 analytically + if (self.intercept) and (len(nonzero_indices_set) == 0): + y_sum = np.sum(self.y) + num_y_pos_1 = (y_sum + self.n)/2 + num_y_neg_1 = self.n - num_y_pos_1 + self.beta0 = np.log(num_y_pos_1/num_y_neg_1) + self.ExpyXB *= np.exp(self.y * self.beta0) + + # create beam search parent + self.ExpyXB_arr_parent = np.zeros((parent_size, self.n)) + self.beta0_arr_parent = np.zeros((parent_size, )) + self.betas_arr_parent = np.zeros((parent_size, self.p)) + self.ExpyXB_arr_parent[0, :] = self.ExpyXB[:] + self.beta0_arr_parent[0] = self.beta0 + self.betas_arr_parent[0, :] = self.betas[:] + self.num_parent = 1 + + # create beam search children. parent[i]->child[i*child_size:(i+1)*child_size] + total_child_size = parent_size * child_size + self.ExpyXB_arr_child = np.zeros((total_child_size, self.n)) + self.beta0_arr_child = np.zeros((total_child_size, )) + self.betas_arr_child = np.zeros((total_child_size, self.p)) + self.isMasked_arr_child = np.ones((total_child_size, ), dtype=bool) + self.loss_arr_child = 1e12 * np.ones((total_child_size, )) + self.forbidden_support = set() + + while num_nonzero < min(k, self.p): + num_nonzero += 1 + self.beamSearch_multipleSupports_via_OMP_by_1(parent_size=parent_size, child_size=child_size) + + self.ExpyXB, self.beta0, self.betas = self.ExpyXB_arr_parent[0], self.beta0_arr_parent[0], self.betas_arr_parent[0] + +class groupSparseLogRegModel(sparseLogRegModel): + def __init__(self, X, y, lambda2=1e-8, intercept=True, original_lb=-5, original_ub=5, group_sparsity=10, featureIndex_to_groupIndex=None, groupIndex_to_featureIndices=None): + super().__init__(X=X, y=y, lambda2=lambda2, intercept=intercept, original_lb=original_lb, original_ub=original_ub) + + self.group_sparsity = group_sparsity + self.featureIndex_to_groupIndex = featureIndex_to_groupIndex # this is a numpy array + self.groupIndex_to_featureIndices = groupIndex_to_featureIndices # this is a dictionary of sets + + def getAvailableIndices_for_expansion(self, betas): + """Get the indices of features that can be added to the support of the current sparse solution + + Parameters + ---------- + betas : ndarray + (1D array with `float` type) The current sparse solution + + Returns + ------- + available_indices : ndarray + (1D array with `int` type) The indices of features that can be added to the support of the current sparse solution + """ + support = get_support_indices(betas) + existing_groupIndices = np.unique(self.featureIndex_to_groupIndex[support]) + if len(existing_groupIndices) < self.group_sparsity: + available_indices = get_nonsupport_indices(betas) + else: + available_indices = set() + for groupIndex in existing_groupIndices: + available_indices.update(self.groupIndex_to_featureIndices[groupIndex]) + available_indices = available_indices - set(support) + available_indices = np.array(list(available_indices), dtype=int) + + return available_indices + \ No newline at end of file diff --git a/Orange/classification/utils/fasterrisk/sparseDiversePool.py b/Orange/classification/utils/fasterrisk/sparseDiversePool.py new file mode 100644 index 00000000000..ddf4cdc3df4 --- /dev/null +++ b/Orange/classification/utils/fasterrisk/sparseDiversePool.py @@ -0,0 +1,161 @@ +import numpy as np +import sys +# import warnings +# warnings.filterwarnings("ignore") +from Orange.classification.utils.fasterrisk.utils import get_support_indices, get_nonsupport_indices, compute_logisticLoss_from_ExpyXB +from Orange.classification.utils.fasterrisk.base_model import logRegModel + +class sparseDiversePoolLogRegModel(logRegModel): + def __init__(self, X, y, lambda2=1e-8, intercept=True, original_lb=-5, original_ub=5): + super().__init__(X=X, y=y, lambda2=lambda2, intercept=intercept, original_lb=original_lb, original_ub=original_ub) + + def getAvailableIndices_for_expansion_but_avoid_l(self, nonsupport, support, l): + """Get the indices of features that can be added to the support of the current sparse solution + + Parameters + ---------- + betas : ndarray + (1D array with `float` type) The current sparse solution + + Returns + ------- + available_indices : ndarray + (1D array with `int` type) The indices of features that can be added to the support of the current sparse solution + """ + return nonsupport + + def get_sparseDiversePool(self, gap_tolerance=0.05, select_top_m=10, maxAttempts=50): + """For the current sparse solution, get from the sparse diverse pool [select_top_m] solutions, which perform equally well as the current sparse solution. This sparse diverse pool is also called the Rashomon set. We discover new solutions by swapping 1 feature in the support of the current sparse solution. + + Parameters + ---------- + gap_tolerance : float, optional + New solution is accepted after swapping features if the new loss is within the [gap_tolerance] of the old loss, by default 0.05 + select_top_m : int, optional + We select the top [select_top_m] solutions from support_size*maxAttempts number of new solutions, by default 10 + maxAttempts : int, optional + We try to swap each feature in the support with [maxAttempts] of new features, by default 50 + + Returns + ------- + intercept_array : ndarray + (1D array with `float` type) Return the intercept array with shape = (select_top_m, ) + coefficients_array : ndarray + (2D array with `float` type) Return the coefficients array with shape = (select_top_m, p) + """ + # select top m solutions with the lowest logistic losses + # Note Bene: loss comparison here does not include logistic loss + nonzero_indices = get_support_indices(self.betas) + zero_indices = get_nonsupport_indices(self.betas) + + num_support = len(nonzero_indices) + num_nonsupport = len(zero_indices) + + maxAttempts = min(maxAttempts, num_nonsupport) + max_num_new_js = maxAttempts + + total_solutions = 1 + num_support * maxAttempts + sparseDiversePool_betas = np.zeros((total_solutions, self.p)) + sparseDiversePool_betas[:, nonzero_indices] = self.betas[nonzero_indices] + + sparseDiversePool_beta0 = self.beta0 * np.ones((total_solutions, )) + sparseDiversePool_ExpyXB = np.zeros((total_solutions, self.n)) + sparseDiversePool_loss = 1e12 * np.ones((total_solutions, )) + + sparseDiversePool_ExpyXB[-1] = self.ExpyXB + sparseDiversePool_loss[-1] = compute_logisticLoss_from_ExpyXB(self.ExpyXB) + self.lambda2 * self.betas[nonzero_indices].dot(self.betas[nonzero_indices]) + + betas_squareSum = self.betas[nonzero_indices].dot(self.betas[nonzero_indices]) + + totalNum_in_diverseSet = 1 + for num_old_j, old_j in enumerate(nonzero_indices): + # pick $maxAttempt$ number of features that can replace old_j + sparseDiversePool_start = num_old_j * maxAttempts + sparseDiversePool_end = (1 + num_old_j) * maxAttempts + + sparseDiversePool_ExpyXB[sparseDiversePool_start:sparseDiversePool_end] = self.ExpyXB * np.exp(-self.yXT[old_j] * self.betas[old_j]) + + sparseDiversePool_betas[sparseDiversePool_start:sparseDiversePool_end, old_j] = 0 + + betas_no_old_j_squareSum = betas_squareSum - self.betas[old_j]**2 + + availableIndices = self.getAvailableIndices_for_expansion_but_avoid_l(zero_indices, nonzero_indices, old_j) + + grad_on_availableIndices = -self.yXT[availableIndices].dot(np.reciprocal(1+sparseDiversePool_ExpyXB[sparseDiversePool_start])) + abs_grad_on_availableIndices = np.abs(grad_on_availableIndices) + + # new_js = np.argpartition(abs_full_grad, -max_num_new_js)[-max_num_new_js:] + new_js = availableIndices[np.argsort(-abs_grad_on_availableIndices)[:max_num_new_js]] + + for num_new_j, new_j in enumerate(new_js): + sparseDiversePool_index = sparseDiversePool_start + num_new_j + for _ in range(10): + self.optimize_1step_at_coord(sparseDiversePool_ExpyXB[sparseDiversePool_index], sparseDiversePool_betas[sparseDiversePool_index], self.yXT[new_j, :], new_j) + + loss_sparseDiversePool_index = compute_logisticLoss_from_ExpyXB(sparseDiversePool_ExpyXB[sparseDiversePool_index]) + self.lambda2 * (betas_no_old_j_squareSum + sparseDiversePool_betas[sparseDiversePool_index, new_j] ** 2) + + if (loss_sparseDiversePool_index - sparseDiversePool_loss[-1]) / sparseDiversePool_loss[-1] < gap_tolerance: + totalNum_in_diverseSet += 1 + + sparseDiversePool_ExpyXB[sparseDiversePool_index], sparseDiversePool_beta0[sparseDiversePool_index], sparseDiversePool_betas[sparseDiversePool_index] = self.finetune_on_current_support(sparseDiversePool_ExpyXB[sparseDiversePool_index], sparseDiversePool_beta0[sparseDiversePool_index], sparseDiversePool_betas[sparseDiversePool_index]) + + sparseDiversePool_loss[sparseDiversePool_index] = compute_logisticLoss_from_ExpyXB(sparseDiversePool_ExpyXB[sparseDiversePool_index]) + self.lambda2 * (betas_no_old_j_squareSum + sparseDiversePool_betas[sparseDiversePool_index, new_j] ** 2) + + selected_sparseDiversePool_indices = np.argsort(sparseDiversePool_loss)[:totalNum_in_diverseSet][:select_top_m] + + top_m_original_betas = np.zeros((len(selected_sparseDiversePool_indices), self.p)) + top_m_original_betas[:, self.scaled_feature_indices] = sparseDiversePool_betas[selected_sparseDiversePool_indices][:, self.scaled_feature_indices] / self.X_norm[self.scaled_feature_indices] + top_m_original_beta0 = sparseDiversePool_beta0[selected_sparseDiversePool_indices] - top_m_original_betas.dot(self.X_mean) + + return top_m_original_beta0, top_m_original_betas + + original_sparseDiversePool_solution[1:] = sparseDiversePool_betas[selected_sparseDiversePool_indices].T + original_sparseDiversePool_solution[1+self.scaled_feature_indices] /= self.X_norm[self.scaled_feature_indices].reshape(-1, 1) + + original_sparseDiversePool_solution[0] = sparseDiversePool_beta0[selected_sparseDiversePool_indices] + original_sparseDiversePool_solution[0] -= self.X_mean.T @ original_sparseDiversePool_solution[1:] + + return original_sparseDiversePool_solution # (1+p, m) m is the number of solutions in the pool + +class groupSparseDiversePoolLogRegModel(sparseDiversePoolLogRegModel): + def __init__(self, X, y, lambda2=1e-8, intercept=True, original_lb=-5, original_ub=5, group_sparsity=10, featureIndex_to_groupIndex=None, groupIndex_to_featureIndices=None): + super().__init__(X=X, y=y, lambda2=lambda2, intercept=intercept, original_lb=original_lb, original_ub=original_ub) + + self.group_sparsity = group_sparsity + self.featureIndex_to_groupIndex = featureIndex_to_groupIndex + self.groupIndex_to_featureIndices = groupIndex_to_featureIndices + + def getAvailableIndices_for_expansion_but_avoid_l(self, nonsupport, support, l): + """Get the indices of features that can be added to the support of the current sparse solution + + Parameters + ---------- + nonsupport : ndarray + (1D array with `int` type) The indices of features that are not in the support of the current sparse solution + support : ndarray + (1D array with `int` type) The indices of features that are in the support of the current sparse solution + l : int + The index of the feature that is to be removed from the support of the current sparse solution and this index l belongs to support + + Returns + ------- + available_indices : ndarray + (1D array with `int` type) The indices of features that can be added to the support of the current sparse solution when we delete index l + """ + existing_groupIndices, freq_existing_groupIndices = np.unique(self.featureIndex_to_groupIndex[support], return_counts=True) + freq_groupIndex_of_l = freq_existing_groupIndices[existing_groupIndices == self.featureIndex_to_groupIndex[l]] + if len(existing_groupIndices) < self.group_sparsity: + # we have not reached the group size yet + available_indices = nonsupport + elif freq_groupIndex_of_l == 1: + # or if we remove index l, we still do not reach the group size + available_indices = nonsupport + else: + # we reach the group size even if we remove index l + available_indices = set() + for groupIndex in existing_groupIndices: + available_indices.update(self.groupIndex_to_featureIndices[groupIndex]) + available_indices = available_indices - set(support) + available_indices = np.array(list(available_indices), dtype=int) + + return available_indices diff --git a/Orange/classification/utils/fasterrisk/utils.py b/Orange/classification/utils/fasterrisk/utils.py new file mode 100644 index 00000000000..28048f5be10 --- /dev/null +++ b/Orange/classification/utils/fasterrisk/utils.py @@ -0,0 +1,118 @@ +import numpy as np +from itertools import product +import requests + +def get_groupIndex_to_featureIndices(featureIndex_to_groupIndex): + groupIndex_to_featureIndices = {} + for featureIndex, groupIndex in enumerate(featureIndex_to_groupIndex): + if groupIndex not in groupIndex_to_featureIndices: + groupIndex_to_featureIndices[groupIndex] = set() + groupIndex_to_featureIndices[groupIndex].add(featureIndex) + return groupIndex_to_featureIndices + +def get_support_indices(betas): + return np.where(np.abs(betas) > 1e-9)[0] + +def get_nonsupport_indices(betas): + return np.where(np.abs(betas) <= 1e-9)[0] + +def normalize_X(X): + X_mean = np.mean(X, axis=0) + X_norm = np.linalg.norm(X-X_mean, axis=0) + scaled_feature_indices = np.where(X_norm >= 1e-9)[0] + X_normalized = X-X_mean + X_normalized[:, scaled_feature_indices] = X_normalized[:, scaled_feature_indices]/X_norm[[scaled_feature_indices]] + return X_normalized, X_mean, X_norm, scaled_feature_indices + +def compute_logisticLoss_from_yXB(yXB): + # shape of yXB is (n, ) + return np.sum(np.log(1.+np.exp(-yXB))) + +def compute_logisticLoss_from_ExpyXB(ExpyXB): + # shape of ExpyXB is (n, ) + return np.sum(np.log(1.+np.reciprocal(ExpyXB))) + +def compute_logisticLoss_from_betas_and_yX(betas, yX): + # shape of betas is (p, ) + # shape of yX is (n, p) + yXB = yX.dot(betas) + return compute_logisticLoss_from_yXB(yXB) + +def compute_logisticLoss_from_X_y_beta0_betas(X, y, beta0, betas): + XB = X.dot(betas) + beta0 + yXB = y * XB + return compute_logisticLoss_from_yXB(yXB) + +def convert_y_to_neg_and_pos_1(y): + y_max, y_min = np.min(y), np.max(y) + y_transformed = -1 + 2 * (y-y_min)/(y_max-y_min) # convert y to -1 and 1 + return y_transformed + +def isEqual_upTo_8decimal(a, b): + if np.isscalar(a): + return abs(a - b) < 1e-8 + return np.max(np.abs(a - b)) < 1e-8 + +def isEqual_upTo_16decimal(a, b): + if np.isscalar(a): + return abs(a - b) < 1e-16 + return np.max(np.abs(a - b)) < 1e-16 + +def insertIntercept_asFirstColOf_X(X): + n = len(X) + intercept = np.ones((n, 1)) + X_with_intercept = np.hstack((intercept, X)) + return X_with_intercept + +def get_all_product_booleans(sparsity=5): + # build list of lists: + all_lists = [] + for i in range(sparsity): + all_lists.append([0, 1]) + all_products = list(product(*all_lists)) + all_products = [list(elem) for elem in all_products] + return np.array(all_products) + +def download_file_from_google_drive(id, destination): + # link: https://stackoverflow.com/a/39225272/5040208 + URL = "https://docs.google.com/uc?export=download" + + session = requests.Session() + + response = session.get(URL, params = { 'id' : id , 'confirm': 1 }, stream = True) + token = get_confirm_token(response) + + if token: + params = { 'id' : id, 'confirm' : token } + response = session.get(URL, params = params, stream = True) + + save_response_content(response, destination) + +def get_confirm_token(response): + # link: https://stackoverflow.com/a/39225272/5040208 + for key, value in response.cookies.items(): + if key.startswith('download_warning'): + return value + + return None + +def save_response_content(response, destination): + # link: https://stackoverflow.com/a/39225272/5040208 + CHUNK_SIZE = 32768 + + with open(destination, "wb") as f: + for chunk in response.iter_content(CHUNK_SIZE): + if chunk: # filter out keep-alive new chunks + f.write(chunk) + +def check_bounds(bound, bound_name, num_features): + if isinstance(bound, (float, int)): + assert bound >= 0 if bound_name == "ub" else bound <= 0, f"{bound_name} needs to be >= 0" if bound_name == "ub" else f"{bound_name} needs to be <= 0" + elif isinstance(bound, list): + bound = np.asarray(bound) + assert len(bound) == num_features, f"{bound_name}s for the features need to have the same length as the number of features" + assert np.all(bound >= 0 if bound_name == "ub" else bound <= 0), f"all of {bound_name}s needs to be >= 0" if bound_name == "ub" else f"all of {bound_name}s needs to be <= 0" + else: + raise ValueError(f"{bound_name} needs to be a float, int, or list") + + return bound \ No newline at end of file diff --git a/Orange/classification/xgb.py b/Orange/classification/xgb.py index 48ca446c3f7..4f102a07537 100644 --- a/Orange/classification/xgb.py +++ b/Orange/classification/xgb.py @@ -6,7 +6,7 @@ import xgboost from Orange.base import XGBBase -from Orange.classification import Learner +from Orange.classification import Learner, SklModel from Orange.data import Variable, DiscreteVariable, Table from Orange.preprocess.score import LearnerScorer @@ -24,6 +24,8 @@ def score(self, data: Table) -> Tuple[np.ndarray, Tuple[Variable]]: class XGBClassifier(XGBBase, Learner, _FeatureScorerMixin): __wraps__ = xgboost.XGBClassifier + __returns__ = SklModel + supports_weights = True def __init__(self, max_depth=None, @@ -81,12 +83,13 @@ def __init__(self, importance_type=importance_type, gpu_id=gpu_id, validate_parameters=validate_parameters, - use_label_encoder=False, preprocessors=preprocessors) class XGBRFClassifier(XGBBase, Learner, _FeatureScorerMixin): __wraps__ = xgboost.XGBRFClassifier + __returns__ = SklModel + supports_weights = True def __init__(self, max_depth=None, @@ -144,5 +147,4 @@ def __init__(self, importance_type=importance_type, gpu_id=gpu_id, validate_parameters=validate_parameters, - use_label_encoder=False, preprocessors=preprocessors) diff --git a/Orange/clustering/clustering.py b/Orange/clustering/clustering.py index 0643ec813bf..f89abf0bf70 100644 --- a/Orange/clustering/clustering.py +++ b/Orange/clustering/clustering.py @@ -13,7 +13,11 @@ def __init__(self, projector): self.projector = projector self.domain = None self.original_domain = None - self.labels = projector.labels_ + + @property + def labels(self): + # converted into a property for __eq__ and __hash__ implementation + return self.projector.labels_ def __call__(self, data): def fix_dim(x): @@ -23,8 +27,8 @@ def fix_dim(x): if isinstance(data, np.ndarray): one_d = data.ndim == 1 prediction = self.predict(np.atleast_2d(data)) - elif isinstance(data, scipy.sparse.csr.csr_matrix) or \ - isinstance(data, scipy.sparse.csc.csc_matrix): + elif isinstance(data, scipy.sparse.csr_matrix) or \ + isinstance(data, scipy.sparse.csc_matrix): prediction = self.predict(data) elif isinstance(data, (Table, Instance)): if isinstance(data, Instance): @@ -57,6 +61,17 @@ def predict(self, X): raise NotImplementedError( "This clustering algorithm does not support predicting.") + def __eq__(self, other): + if self is other: + return True + return type(self) is type(other) \ + and self.projector == other.projector \ + and self.domain == other.domain \ + and self.original_domain == other.original_domain + + def __hash__(self): + return hash((type(self), self.projector, self.domain, self.original_domain)) + class Clustering(metaclass=WrapperMeta): """ diff --git a/Orange/clustering/hierarchical.py b/Orange/clustering/hierarchical.py index 18ffcc84684..298af7dc6c0 100644 --- a/Orange/clustering/hierarchical.py +++ b/Orange/clustering/hierarchical.py @@ -147,8 +147,11 @@ def __iter__(self): return iter((self.__value, self.__branches)) def __repr__(self): - return ("{0.__name__}(value={1!r}, branches={2!r})" - .format(type(self), self.value, self.branches)) + try: + return ("{0.__name__}(value={1!r}, branches={2!r})" + .format(type(self), self.value, self.branches)) + except RecursionError: + return ("{0.__name__}(...)".format(type(self))) @property def is_leaf(self): diff --git a/Orange/clustering/kmeans.py b/Orange/clustering/kmeans.py index 97ba7d0e8a1..43f64cdf77d 100644 --- a/Orange/clustering/kmeans.py +++ b/Orange/clustering/kmeans.py @@ -11,10 +11,20 @@ class KMeansModel(ClusteringModel): + InheritEq = True + def __init__(self, projector): super().__init__(projector) - self.centroids = projector.cluster_centers_ - self.k = projector.get_params()["n_clusters"] + + @property + def centroids(self): + # converted into a property for __eq__ and __hash__ implementation + return self.projector.cluster_centers_ + + @property + def k(self): + # converted into a property for __eq__ and __hash__ implementation + return self.projector.get_params()["n_clusters"] def predict(self, X): return self.projector.predict(X) diff --git a/Orange/clustering/tests/test_hierarchical.py b/Orange/clustering/tests/test_hierarchical.py new file mode 100644 index 00000000000..2a78a4fc58c --- /dev/null +++ b/Orange/clustering/tests/test_hierarchical.py @@ -0,0 +1,27 @@ +import unittest +import sys + +from Orange.clustering.hierarchical import Tree + + +class TestTree(unittest.TestCase): + def test_repr(self): + tree = Tree(2) + self.assertEqual(repr(tree), "Tree(value=2, branches=())") + + tree2 = Tree(3, (Tree(2), Tree(1))) + self.assertEqual(repr(tree2), "Tree(value=3, branches=(Tree(value=2, branches=()), Tree(value=1, branches=())))") + + for i in range(100): + tree = Tree(i, (tree, tree)) + + rec_limit = sys.getrecursionlimit() + try: + sys.setrecursionlimit(30) + repr(tree) # don't care about the result, just that it doesn't crash + finally: + sys.setrecursionlimit(rec_limit) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/Orange/data/__init__.py b/Orange/data/__init__.py index 1c23aaef1b4..9efb1267735 100644 --- a/Orange/data/__init__.py +++ b/Orange/data/__init__.py @@ -10,3 +10,4 @@ from .io import * from .filter import * from .pandas_compat import * +from .aggregate import * diff --git a/Orange/data/aggregate.py b/Orange/data/aggregate.py new file mode 100644 index 00000000000..9b4383e9a59 --- /dev/null +++ b/Orange/data/aggregate.py @@ -0,0 +1,149 @@ +from functools import lru_cache +from typing import Callable, Dict, List, Tuple, Union, Type + +import pandas as pd + +from Orange.data import Domain, Table, Variable, table_from_frame, table_to_frame +from Orange.util import dummy_callback + + +class OrangeTableGroupBy: + """ + A class representing the result of the groupby operation on Orange's + Table and offers aggregation functionality on groupby object. It wraps + Panda's GroupBy object. + + Attributes + ---------- + table + Table to be grouped + by + Variable used for grouping. Resulting groups are defined with unique + combinations of those values. + + Examples + -------- + from Orange.data import Table + + table = Table("iris") + gb = table.groupby([table.domain["iris"]]) + aggregated_table = gb.aggregate( + {table.domain["sepal length"]: ["mean", "median"], + table.domain["petal length"]: ["mean"]} + ) + """ + + def __init__(self, table: Table, by: List[Variable]): + self.table = table + + df = table_to_frame(table, include_metas=True) + # observed=True keeps only groups with at leas one instance + self.group_by = df.groupby([a.name for a in by], observed=True) + self.by = tuple(by) + + # lru_cache that is caches on the object level + self.compute_aggregation = lru_cache()(self._compute_aggregation) + + AggDescType = Union[str, + Callable, + Tuple[str, Union[str, Callable]], + Tuple[str, Union[str, Callable], Union[Type[Variable], bool]] + ] + + def aggregate( + self, + aggregations: Dict[Variable, List[AggDescType]], + callback: Callable = dummy_callback, + ) -> Table: + """ + Compute aggregations for each group + + Parameters + ---------- + aggregations + The dictionary that defines aggregations that need to be computed + for variables. We support three formats: + - {variable name: [agg function 1, agg function 2]} + - {variable name: [(agg name 1, agg function 1), (agg name 1, agg function 1)]} + - {variable name: [(agg name 1, agg function 1, output_variable_type1), ...]} + Where agg name is the aggregation name used in the output column name. + Aggregation function can be either function or string that defines + aggregation in Pandas (e.g. mean). + output_variable_type can be a type for a new variable, True to copy + the input variable, or False to create a new variable of the same type + as the input + callback + Callback function to report the progress + + Returns + ------- + Table that includes aggregation columns. Variables that are used for + grouping are in metas. + """ + num_aggs = sum(len(aggs) for aggs in aggregations.values()) + count = 0 + + result_agg = [] + output_variables = [] + for col, aggs in aggregations.items(): + for agg in aggs: + res, var = self._compute_aggregation(col, agg) + result_agg.append(res) + output_variables.append(var) + count += 1 + callback(count / num_aggs * 0.8) + + agg_table = self._aggregations_to_table(result_agg, output_variables) + callback(1) + return agg_table + + def _compute_aggregation( + self, col: Variable, agg: AggDescType) -> Tuple[pd.Series, Variable]: + # use named aggregation to avoid issues with same column names when reset_index + if isinstance(agg, tuple): + name, agg, var_type, *_ = (*agg, None) + else: + name = agg if isinstance(agg, str) else agg.__name__ + var_type = None + col_name = f"{col.name} - {name}" + agg_col = self.group_by[col.name].agg(**{col_name: agg}) + if col.is_discrete and var_type is True: + dtype = pd.CategoricalDtype(categories=col.values, ordered=True) + agg_col = agg_col.astype(dtype) + if var_type is True: + var = col.copy(name=col_name) + elif var_type is False: + var = col.make(name=col_name) + elif var_type is None: + var = None + else: + assert issubclass(var_type, Variable) + var = var_type.make(name=col_name) + return agg_col, var + + def _aggregations_to_table( + self, + aggregations: List[pd.Series], + output_variables: List[Union[Variable, None]]) -> Table: + """Concatenate aggregation series and convert back to Table""" + if aggregations: + df = pd.concat(aggregations, axis=1) + else: + # when no aggregation is computed return a table with gropby columns + df = self.group_by.first() + df = df.drop(columns=df.columns) + gb_attributes = df.index.names + df = df.reset_index() # move group by var that are in index to columns + table = table_from_frame(df, variables=(*self.by, *output_variables)) + + # group by variables should be last two columns in metas in the output + metas = table.domain.metas + new_metas = [m for m in metas if m.name not in gb_attributes] + [ + table.domain[n] for n in gb_attributes + ] + new_domain = Domain( + [var for var in table.domain.attributes if var.name not in gb_attributes], + metas=new_metas, + ) + # keeps input table's type - e.g. output is Corpus if input Corpus + return self.table.from_table(new_domain, table) diff --git a/Orange/data/domain.py b/Orange/data/domain.py index 35e5a0dc76b..3191e147646 100644 --- a/Orange/data/domain.py +++ b/Orange/data/domain.py @@ -1,3 +1,4 @@ +import itertools import warnings from math import log @@ -10,6 +11,7 @@ from Orange.data import ( Unknown, Variable, ContinuousVariable, DiscreteVariable, StringVariable ) +from Orange.misc.cache import IDWeakrefCache from Orange.util import deprecated, OrangeDeprecationWarning __all__ = ["DomainConversion", "Domain"] @@ -68,7 +70,7 @@ def match(var): sourceindex = source.index(sourcevar) if var.is_discrete and var is not sourcevar: mapping = var.get_mapper_from(sourcevar) - return lambda table: mapping(table.get_column_view(sourceindex)[0]) + return lambda table: mapping(table.get_column(sourceindex)) return source.index(var) return var.compute_value # , which may also be None @@ -163,16 +165,41 @@ def __init__(self, attributes, class_vars=None, metas=None, source=None): if not all(var.is_primitive() for var in self._variables): raise TypeError("variables must be primitive") - self._indices = dict(chain.from_iterable( - ((var, idx), (var.name, idx), (idx, idx)) - for idx, var in enumerate(self._variables))) - self._indices.update(chain.from_iterable( - ((var, -1-idx), (var.name, -1-idx), (-1-idx, -1-idx)) - for idx, var in enumerate(self.metas))) + self._indices = None self.anonymous = False self._hash = None # cache for __hash__() + self._eq_cache = IDWeakrefCache(_LRS10Dict()) # cache for __eq__() + + def _ensure_indices(self): + if self._indices is None: + indices = dict(chain.from_iterable( + ((var, idx), (var.name, idx), (idx, idx)) + for idx, var in enumerate(self._variables))) + indices.update(chain.from_iterable( + ((var, -1-idx), (var.name, -1-idx), (-1-idx, -1-idx)) + for idx, var in enumerate(self.metas))) + self._indices = indices + + def __setstate__(self, state): + self.__dict__.update(state) + self._variables = self.attributes + self.class_vars + self._indices = None + self._hash = None + self._eq_cache = {} + + def __getstate__(self): + # Do not pickle dictionaries because unpickling dictionaries that + # include objects that redefine __hash__ as keys is sometimes problematic + # (when said objects do not have __dict__ filled yet in but are used as + # keys in a restored dictionary). + state = self.__dict__.copy() + del state["_variables"] + del state["_indices"] + del state["_hash"] + del state["_eq_cache"] + return state # noinspection PyPep8Naming @classmethod @@ -245,14 +272,13 @@ def variables(self): def metas(self): return self._metas - @deprecated("len(Domain.variables)") def __len__(self): """The number of variables (features and class attributes). The current behavior returns the length of only features and class attributes. In the near future, it will include the length of metas, too, and __iter__ will act accordingly.""" - return len(self._variables) + return len(self._variables) + len(self._metas) def __bool__(self): warnings.warn( @@ -289,7 +315,7 @@ def __getitem__(self, idx): """ if isinstance(idx, slice): return self._variables[idx] - + self._ensure_indices() index = self._indices.get(idx) if index is None: var = self._get_equivalent(idx) @@ -306,20 +332,14 @@ def __contains__(self, item): Return `True` if the item (`str`, `int`, :class:`Variable`) is in the domain. """ + self._ensure_indices() return item in self._indices or self._get_equivalent(item) is not None - @deprecated("Domain.variables") def __iter__(self): """ Return an iterator through variables (features and class attributes). - - The current behaviour is confusing, as `x in domain` returns True - for meta variables, but iter(domain) does not yield them. - This will be consolidated eventually (in 3.12?), the code that - currently iterates over domain should iterate over domain.variables - instead. """ - return iter(self._variables) + return itertools.chain(self._variables, self._metas) def __str__(self): """ @@ -341,7 +361,7 @@ def index(self, var): Return the index of the given variable or meta attribute, represented with an instance of :class:`Variable`, `int` or `str`. """ - + self._ensure_indices() idx = self._indices.get(var) if idx is not None: return idx @@ -502,11 +522,26 @@ def __eq__(self, other): if not isinstance(other, Domain): return False - return (self.attributes == other.attributes and - self.class_vars == other.class_vars and - self.metas == other.metas) + try: + eq = self._eq_cache[(other,)] + except KeyError: + eq = (self.attributes == other.attributes and + self.class_vars == other.class_vars and + self.metas == other.metas) + self._eq_cache[(other,)] = eq + + return eq def __hash__(self): if self._hash is None: self._hash = hash(self.attributes) ^ hash(self.class_vars) ^ hash(self.metas) return self._hash + + +class _LRS10Dict(dict): + """ A small "least recently stored" (not LRU) dict """ + + def __setitem__(self, key, value): + if len(self) >= 10: + del self[next(iter(self))] + super().__setitem__(key, value) diff --git a/Orange/data/filter.py b/Orange/data/filter.py index 92bf67dcb05..6875d07dca2 100644 --- a/Orange/data/filter.py +++ b/Orange/data/filter.py @@ -31,6 +31,12 @@ def __init__(self, negate=False): def __call__(self, data): return + def __eq__(self, other): + return type(self) is type(other) and self.negate == other.negate + + def __hash__(self): + return hash(self.negate) + class IsDefined(Filter): """ @@ -53,7 +59,7 @@ class IsDefined(Filter): def __init__(self, columns=None, negate=False): super().__init__(negate) - self.columns = columns + self.columns = tuple(columns) if columns is not None else None def __call__(self, data): if isinstance(data, Instance): @@ -70,6 +76,12 @@ def __call__(self, data): r = np.logical_not(r) return data[r] + def __eq__(self, other): + return super().__eq__(other) and self.columns == other.columns + + def __hash__(self): + return hash((super().__hash__(), hash(self.columns))) + class HasClass(Filter): """ @@ -407,7 +419,7 @@ class FilterString(ValueFilter): The operator; should be `FilterString.Equal`, `NotEqual`, `Less`, `LessEqual`, `Greater`, `GreaterEqual`, `Between`, `Outside`, - `Contains`, `StartsWith`, `EndsWith` or `IsDefined`. + `Contains`, `NotContain`, `StartsWith`, `NotStartsWith`, `EndsWith`, `NotEndsWith`, `IsDefined` or `NotIsDefined`. .. attribute:: case_sensitive @@ -415,10 +427,10 @@ class FilterString(ValueFilter): """ Type = Enum('FilterString', 'Equal, NotEqual, Less, LessEqual, Greater,' - 'GreaterEqual, Between, Outside, Contains,' - 'StartsWith, EndsWith, IsDefined') + 'GreaterEqual, Between, Outside, Contains, NotContain,' + 'StartsWith, NotStartsWith, EndsWith, NotEndsWith, IsDefined, NotIsDefined') (Equal, NotEqual, Less, LessEqual, Greater, GreaterEqual, - Between, Outside, Contains, StartsWith, EndsWith, IsDefined) = Type + Between, Outside, Contains, NotContain, StartsWith, NotStartsWith, EndsWith, NotEndsWith, IsDefined, NotIsDefined) = Type def __init__(self, position, oper, ref=None, max=None, case_sensitive=True, **a): @@ -448,6 +460,8 @@ def __call__(self, inst): value = inst[inst.domain.index(self.column)] if self.oper == self.IsDefined: return not np.isnan(value) + if self.oper == self.NotIsDefined: + return np.isnan(value) if self.case_sensitive: value = str(value) refval = str(self.ref) @@ -468,10 +482,16 @@ def __call__(self, inst): return value >= refval if self.oper == self.Contains: return refval in value + if self.oper == self.NotContain: + return refval not in value if self.oper == self.StartsWith: return value.startswith(refval) + if self.oper == self.NotStartsWith: + return not value.startswith(refval) if self.oper == self.EndsWith: return value.endswith(refval) + if self.oper == self.NotEndsWith: + return not value.endswith(refval) high = self.max if self.case_sensitive else self.max.lower() if self.oper == self.Between: return refval <= value <= high diff --git a/Orange/data/instance.py b/Orange/data/instance.py index c02ddf9f570..434dbe0ae55 100644 --- a/Orange/data/instance.py +++ b/Orange/data/instance.py @@ -34,11 +34,12 @@ def __init__(self, domain, data=None, id=None): self._weight = 1 elif isinstance(data, Instance) and data.domain == domain: self._x = np.array(data._x) - self._y = np.array(data._y) + self._y = np.atleast_1d(np.array(data._y)) self._metas = np.array(data._metas) self._weight = data._weight else: self._x, self._y, self._metas = domain.convert(data) + self._y = np.atleast_1d(self._y) self._weight = 1 if id is not None: @@ -116,7 +117,10 @@ def __getitem__(self, key): if 0 <= idx < len(self._domain.attributes): value = self._x[idx] elif idx >= len(self._domain.attributes): - value = self._y[idx - len(self.domain.attributes)] + if self._y.ndim == 0: + value = self._y + else: + value = self._y[idx - len(self.domain.attributes)] else: value = self._metas[-1 - idx] var = self._domain[idx] diff --git a/Orange/data/io.py b/Orange/data/io.py index 1a952bcd0cf..0959bb725c2 100644 --- a/Orange/data/io.py +++ b/Orange/data/io.py @@ -24,7 +24,7 @@ import xlsxwriter import openpyxl -from Orange.data import _io, Table, Domain, ContinuousVariable +from Orange.data import _io, Table, Domain, ContinuousVariable, update_origin from Orange.data import Compression, open_compressed, detect_encoding, \ isnastr, guess_data_type, sanitize_variable from Orange.data.io_base import FileFormatBase, Flags, DataTableMixin, PICKLE_PROTOCOL @@ -164,14 +164,7 @@ def read(self): skipinitialspace=True, ) data = self.data_table(reader) - - # TODO: Name can be set unconditionally when/if - # self.filename will always be a string with the file name. - # Currently, some tests pass StringIO instead of - # the file name to a reader. - if isinstance(self.filename, str): - data.name = path.splitext( - path.split(self.filename)[-1])[0] + data.name = path.splitext(path.split(self.filename)[-1])[0] if error and isinstance(error, UnicodeDecodeError): pos, endpos = error.args[2], error.args[3] warning = ('Skipped invalid byte(s) in position ' @@ -179,6 +172,7 @@ def read(self): ('-' + str(endpos)) if (endpos - pos) > 1 else '') warnings.warn(warning) self.set_table_metadata(self.filename, data) + update_origin(data, self.filename) return data except Exception as e: error = e @@ -215,6 +209,7 @@ def read(self): if not isinstance(table, Table): raise TypeError("file does not contain a data table") else: + update_origin(table, self.filename) return table @classmethod @@ -264,6 +259,7 @@ def read(self): try: cells = self.get_cells() table = self.data_table(cells) + update_origin(table, self.filename) table.name = path.splitext(path.split(self.filename)[-1])[0] if self.sheet and len(self.sheets) > 1: table.name = '-'.join((table.name, self.sheet)) @@ -277,6 +273,7 @@ class ExcelReader(_BaseExcelReader): EXTENSIONS = ('.xlsx',) DESCRIPTION = 'Microsoft Excel spreadsheet' ERRORS = ("#VALUE!", "#DIV/0!", "#REF!", "#NUM!", "#NULL!", "#NAME?") + OPTIONAL_TYPE_ANNOTATIONS = True def __init__(self, filename): super().__init__(filename) @@ -319,22 +316,30 @@ def _get_active_sheet(self) -> openpyxl.worksheet.worksheet.Worksheet: return self.workbook.active @classmethod - def write_file(cls, filename, data): + def write_file(cls, filename, data, with_annotations=False): vars = list(chain((ContinuousVariable('_w'),) if data.has_weights() else (), - data.domain.attributes, data.domain.class_vars, - data.domain.metas)) + data.domain.metas, + data.domain.attributes)) formatters = [cls.formatter(v) for v in vars] zipped_list_data = zip(data.W if data.W.ndim > 1 else data.W[:, np.newaxis], - data.X, data.Y if data.Y.ndim > 1 else data.Y[:, np.newaxis], - data.metas) - headers = cls.header_names(data) + data.metas, + data.X) + names = cls.header_names(data) + headers = (names,) + if with_annotations: + types = cls.header_types(data) + flags = cls.header_flags(data) + headers = (names, types, flags) + workbook = xlsxwriter.Workbook(filename) sheet = workbook.add_worksheet() - for c, header in enumerate(headers): - sheet.write(0, c, header) - for i, row in enumerate(zipped_list_data, 1): + + for r, parts in enumerate(headers): + for c, part in enumerate(parts): + sheet.write(r, c, part) + for i, row in enumerate(zipped_list_data, len(headers)): for j, (fmt, v) in enumerate(zip(formatters, flatten(row))): sheet.write(i, j, fmt(v)) workbook.close() @@ -406,7 +411,13 @@ def __init__(self, filename): filename = filename.strip() if not urlparse(filename).scheme: filename = 'http://' + filename - filename = quote(filename, safe="/:") + + # Fully support URL with query or fragment like http://filename.txt?a=1&b=2#c=3 + def quote_byte(b): + return chr(b) if b < 0x80 else '%{:02X}'.format(b) + + filename = ''.join(map(quote_byte, filename.encode("utf-8"))) + super().__init__(filename) @staticmethod @@ -444,6 +455,7 @@ def _resolve_redirects(self, url): def _trim(cls, url): URL_TRIMMERS = ( cls._trim_googlesheet, + cls._trim_googledrive, cls._trim_dropbox, ) for trim in URL_TRIMMERS: @@ -473,6 +485,18 @@ def _trim_googlesheet(url): url += '&gid=' + sheet return url + @staticmethod + def _trim_googledrive(url): + parts = urlsplit(url) + if not parts.netloc.endswith("drive.google.com"): + raise ValueError + match = re.match(r'/file/d/(?P[^/]+).*', parts.path) + if not match: + raise ValueError + id_ = match.group("id") + parts = parts._replace(path=f"uc?export=download&id={id_}", query=None) + return urlunsplit(parts) + @staticmethod def _trim_dropbox(url): parts = urlsplit(url) diff --git a/Orange/data/io_base.py b/Orange/data/io_base.py index 8149ec962a6..df3d7155796 100644 --- a/Orange/data/io_base.py +++ b/Orange/data/io_base.py @@ -15,6 +15,7 @@ from glob import glob import numpy as np +import pandas from Orange.data import Table, Domain, Variable, DiscreteVariable, \ StringVariable, ContinuousVariable, TimeVariable @@ -31,6 +32,11 @@ PICKLE_PROTOCOL = 4 +class MissingReaderException(IOError): + # subclasses IOError for backward compatibility + pass + + class Flags: """Parser for column flags (i.e. third header row)""" DELIMITER = ' ' @@ -43,7 +49,7 @@ class Flags: ('weight', 'w'), ('.+?=.*?', ''), # general key=value attributes )) - _RE_ALL = re.compile(r'^({})$'.format('|'.join( + RE_ALL = re.compile(r'^({})$'.format('|'.join( filter(None, flatten(ALL.items()))))) def __init__(self, flags): @@ -52,7 +58,7 @@ def __init__(self, flags): self.attributes = {} for flag in flags or []: flag = flag.strip() - if self._RE_ALL.match(flag): + if self.RE_ALL.match(flag): if '=' in flag: k, v = flag.split('=', 1) if not Flags._RE_ATTR_UNQUOTED_STR(v): @@ -162,10 +168,28 @@ def _header1(cls, headers: List[List[str]]) -> Tuple[List, List, List]: 2) -||- with type and flags prepended, separated by #, e.g. d#sex,c#age,cC#IQ """ - flags, names = zip(*[i.split(cls.HEADER1_FLAG_SEP, 1) - if cls.HEADER1_FLAG_SEP in i else ('', i) - for i in headers[0]]) - names = list(names) + + roles = "".join([f for f in Flags.ALL.values() if len(f) == 1]) # cimw + types = "".join([t for t in flatten(getattr(vartype, 'TYPE_HEADERS') + for vartype in Variable.registry.values()) + if len(t) == 1]).upper() # CNDST + + res = ('^((?P' + f'[{roles}{types}]|' + f'([{roles}][{types}])|' + f'([{types}][{roles}])' + ')#)?(?P.*)') + + header1_re = re.compile(res) + + flags = [] + names = [] + for i in headers[0]: + m = header1_re.match(i) + f, n = m.group("flags", "name") + flags.append('' if f is None else f) + names.append(n) + return names, cls._type_from_flag(flags), cls._flag_from_flag(flags) @classmethod @@ -357,7 +381,7 @@ def get_arrays(self) -> Tuple[np.ndarray, np.ndarray, (self.cols_W, float)) X, Y, M, W = [self._list_into_ndarray(lst, dt) for lst, dt in lists] if X is None: - X = np.empty((self.data.shape[0], 0), dtype=np.float_) + X = np.empty((self.data.shape[0], 0), dtype=np.float64) return X, Y, M, W @staticmethod @@ -369,7 +393,7 @@ def _list_into_ndarray(lst: List, dtype=None) -> Optional[np.ndarray]: if dtype is not None: array.astype(dtype) else: - assert array.dtype == np.float_ + assert array.dtype == np.float64 return array @@ -551,7 +575,7 @@ def get_reader(cls, filename): if fnmatch(path.basename(filename), '*' + ext): return reader(filename) - raise IOError('No readers for file "{}"'.format(filename)) + raise MissingReaderException('No readers for file "{}"'.format(filename)) @classmethod def set_table_metadata(cls, filename, table): @@ -599,9 +623,9 @@ def write_file(fn): @staticmethod def header_names(data): return ['weights'] * data.has_weights() + \ - [v.name for v in chain(data.domain.attributes, - data.domain.class_vars, - data.domain.metas)] + [v.name for v in chain(data.domain.class_vars, + data.domain.metas, + data.domain.attributes)] @staticmethod def header_types(data): @@ -618,9 +642,9 @@ def _vartype(var): raise NotImplementedError return ['continuous'] * data.has_weights() + \ - [_vartype(v) for v in chain(data.domain.attributes, - data.domain.class_vars, - data.domain.metas)] + [_vartype(v) for v in chain(data.domain.class_vars, + data.domain.metas, + data.domain.attributes)] @staticmethod def header_flags(data): @@ -628,10 +652,10 @@ def header_flags(data): ['weight'] * data.has_weights(), (Flags.join([flag], *('{}={}'.format(*a) for a in sorted(var.attributes.items()))) - for flag, var in chain(zip(repeat(''), data.domain.attributes), - zip(repeat('class'), + for flag, var in chain(zip(repeat('class'), data.domain.class_vars), - zip(repeat('meta'), data.domain.metas))))) + zip(repeat('meta'), data.domain.metas), + zip(repeat(''), data.domain.attributes))))) @classmethod def write_headers(cls, write, data, with_annotations=True): @@ -649,11 +673,11 @@ def formatter(cls, var): if var.is_time: return var.repr_val elif var.is_continuous: - return lambda value: "" if isnan(value) else value + return lambda value: "" if isnan(value) else var.repr_val(value) elif var.is_discrete: return lambda value: "" if isnan(value) else var.values[int(value)] elif var.is_string: - return lambda value: value + return lambda value: "" if pandas.isnull(value) else value else: return var.repr_val @@ -662,15 +686,15 @@ def write_data(cls, write, data): """`write` is a callback that accepts an iterable""" vars_ = list( chain((ContinuousVariable('_w'),) if data.has_weights() else (), - data.domain.attributes, data.domain.class_vars, - data.domain.metas)) + data.domain.metas, + data.domain.attributes)) formatters = [cls.formatter(v) for v in vars_] for row in zip(data.W if data.W.ndim > 1 else data.W[:, np.newaxis], - data.X, data.Y if data.Y.ndim > 1 else data.Y[:, np.newaxis], - data.metas): + data.metas, + data.X): write([fmt(v) for fmt, v in zip(formatters, flatten(row))]) diff --git a/Orange/data/io_util.py b/Orange/data/io_util.py index b14d8c93f71..72820bc0130 100644 --- a/Orange/data/io_util.py +++ b/Orange/data/io_util.py @@ -1,17 +1,39 @@ +import codecs +import os.path import subprocess +from datetime import datetime from collections import defaultdict +from typing import Tuple, Optional, Literal, Sequence import numpy as np -from chardet.universaldetector import UniversalDetector +import pandas as pd +try: + from pandas.tseries.api import guess_datetime_format +except ImportError: # pandas < 2.2.0 + from pandas.core.tools.datetimes import guess_datetime_format + +from chardet import UniversalDetector from Orange.data import ( is_discrete_values, MISSING_VALUES, Variable, - DiscreteVariable, StringVariable, ContinuousVariable, TimeVariable, + DiscreteVariable, StringVariable, ContinuousVariable, TimeVariable, Table, ) from Orange.misc.collections import natural_sorted - -__all__ = ["Compression", "open_compressed", "detect_encoding", "isnastr", - "guess_data_type", "sanitize_variable"] +from Orange.util import ftry, frompyfunc + +__all__ = [ + "Compression", + "open_compressed", + "detect_encoding", + "isnastr", + "guess_data_type", + "sanitize_variable", + "update_origin", + "isnatstr", + "array_strptime", + "parse_datetime", + "to_datetime", +] class Compression: @@ -35,6 +57,16 @@ def open_compressed(filename, *args, _open=open, **kwargs): # Else already a file, just pass it through return filename +def _is_utf8_sig(filename: str) -> bool: + """Does filename start with an UTF-8 BOM.""" + try: + with open(filename, "rb") as f: + bom = f.read(3) + return bom == codecs.BOM_UTF8 + except OSError: # pragma: no cover + return False + + def detect_encoding(filename): """ @@ -49,6 +81,9 @@ def detect_encoding(filename): proc.wait() if proc.returncode == 0: encoding = proc.stdout.read().strip() + # file does not detect/report UTF-8 BOM + if encoding == b'utf-8': + return "utf-8-sig" if _is_utf8_sig(filename) else "utf-8" # file only supports these encodings; for others it says # unknown-8bit or binary. So we give chardet a chance to do # better @@ -109,7 +144,10 @@ def isnastr(arr, out=None): arr = np.asarray(arr) if out is None and arr.shape != (): out = np.empty_like(arr, dtype=bool) - return __isnastr(arr, out=out) + return __isnastr(arr, out=out, casting="unsafe") + + +_as_string_array = np.frompyfunc(str, 1, 1) def guess_data_type(orig_values, namask=None): @@ -118,7 +156,7 @@ def guess_data_type(orig_values, namask=None): """ valuemap, values = None, orig_values is_discrete = is_discrete_values(orig_values) - orig_values = np.asarray(orig_values, dtype=str) + orig_values = _as_string_array(orig_values) if namask is None: namask = isnastr(orig_values) if is_discrete: @@ -182,7 +220,7 @@ def get_number_of_decimals(values): def mapvalues(arr): arr = np.asarray(arr, dtype=object) - return mapvalues_(arr, out=np.empty_like(arr, dtype=float)) + return mapvalues_(arr, out=np.empty_like(arr, dtype=float), casting="unsafe") values = mapvalues(orig_values) @@ -207,3 +245,158 @@ def mapvalues(arr): values = [_var.parse(i) for i in orig_values] return values, var + + +def _extract_new_origin(attr: Variable, table: Table, lookup_dirs: Tuple[str]) -> Optional[str]: + # origin exists + if os.path.exists(attr.attributes["origin"]): + return attr.attributes["origin"] + + # last dir of origin in lookup dirs + dir_ = os.path.basename(os.path.normpath(attr.attributes["origin"])) + for ld in lookup_dirs: + new_dir = os.path.join(ld, dir_) + if os.path.isdir(new_dir): + return new_dir + + # all column paths in lookup dirs + for ld in lookup_dirs: + if all( + os.path.exists(os.path.join(ld, attr.str_val(v))) + for v in table.get_column(attr) + if v and not pd.isna(v) + ): + return ld + + return None + + +def update_origin(table: Table, file_path: str): + """ + When a dataset with file paths in the column is moved to another computer, + the absolute path may not be correct. This function updates the path for all + columns with an "origin" attribute. + + The process consists of two steps. First, we identify directories to search + for files, and in the second step, we check if paths exist. + + Lookup directories: + 1. The directory where the file from file_path is placed + 2. The parent directory of 1. The situation when the user places dataset + file in the directory with files (for example, workflow in a directory + with images) + + Possible situations for file search: + 1. The last directory of origin (basedir) is in one of the lookup directories + 2. Origin doesn't exist in any lookup directories, but paths in a column can + be found in one of the lookup directories. This is usually a situation + when paths in a column are complex (e.g. a/b/c/d/file.txt). + + Note: This function updates the existing table + + Parameters + ---------- + table + Orange Table to be updated if origin exits in any column + file_path + Path of the loaded dataset for reference. Only paths inside datasets + directory or its parent directory will be considered for new origin. + """ + file_dir = os.path.dirname(file_path) + parent_dir = os.path.dirname(file_dir) + # if file_dir already root file_dir == parent_dir + lookup_dirs = tuple({file_dir: 0, parent_dir: 0}) + for attr in table.domain.metas: + if "origin" in attr.attributes and (attr.is_string or attr.is_discrete): + new_orig = _extract_new_origin(attr, table, lookup_dirs) + if new_orig: + attr.attributes["origin"] = new_orig + + +isnatstr = frompyfunc( + (MISSING_VALUES | {"nat", "Nat", "NaT", "NAT"}).__contains__, + 1, 1, dtype=bool, +) + + +def array_strptime( + values: Sequence[str], + format: str, + errors: Literal["raise", "coerce"] = "raise", + dtype=np.dtype("M8[us]"), +) -> 'np.ndarray[np.datetime64]': + """ + Parse an array `values` of date/time strings. + + Parameters + ---------- + values: Sequence[str] + format: str + A `time.strptime` date/time format string. + dtype: np.dtype + The return dtype + errors: Literal["raise", "coerce"] + How to treat parse errors. + """ + values = np.asarray(values, dtype=object) + out = np.full(values.shape, np.datetime64("NaT"), dtype=dtype) + if errors == "raise": + f = np.frompyfunc(datetime.strptime, 2, 1) + elif errors == "coerce": + f = np.frompyfunc(ftry(datetime.strptime, ValueError, np.datetime64("NaT")), 2, 1) + else: # pragma: no cover + raise TypeError(f"Invalid 'errors' argument {errors}") + na_mask = isnatstr(values) + return f(values, format, where=~na_mask, out=out, casting="unsafe") + + +def first_non_natstr(c: Sequence[str]) -> int | None: + """Return first element of `c` that is not a NaT string.""" + mask = isnatstr(c) + idxs = np.flatnonzero(~mask) + if idxs.size: + return int(idxs[0]) + return None + + +def parse_datetime( + values: Sequence[str], + format: str | None = None, + errors: Literal["raise", "coerce"] = "raise", + dtype=np.dtype("M8[us]"), +) -> 'np.ndarray[np.datetime64]': + values = np.asarray(values, dtype=object) + if format is None: + idx = first_non_natstr(values) + if idx is not None: + format = guess_datetime_format(values[idx]) + if format is None: # pragma: no cover + raise ValueError("Cannot guess date/time format") + return array_strptime(values, format, errors=errors, dtype=dtype) + + +def to_datetime( + values: Sequence[str], + format: str | None = None, + errors: Literal["raise", "coerce"] = "raise", +) -> "np.ndarray[np.datetime64]": + """ + Similar to `pandas.to_datetime` but support parsing years before 1677 + and after 2262. + + https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#timeseries-timestamp-limits + """ + try: + # try with errors="raise" to catch OutOfBoundsDatetime, even if errors was + # "coerce" + return (pd.to_datetime(values, format=format, errors="raise", utc=True) + .values.astype("M8[us]")) + except pd.errors.OutOfBoundsDatetime: + # slower path + return parse_datetime(values, format, errors=errors) + except Exception: # pylint: disable=broad-except + if errors != "raise": + return (pd.to_datetime(values, format=format, errors=errors, utc=True) + .values.astype("M8[us]")) + else: + raise diff --git a/Orange/data/pandas_compat.py b/Orange/data/pandas_compat.py index e05bf9d8fba..dd25d2ba6e1 100644 --- a/Orange/data/pandas_compat.py +++ b/Orange/data/pandas_compat.py @@ -1,16 +1,18 @@ """Pandas DataFrame↔Table conversion helpers""" -from unittest.mock import patch +from functools import partial +from itertools import zip_longest import numpy as np -from pandas.core.dtypes.common import is_string_dtype from scipy import sparse as sp from scipy.sparse import csr_matrix import pandas as pd from pandas.core.arrays import SparseArray -from pandas.core.arrays.sparse.dtype import SparseDtype +import pandas.core.arrays.sparse.accessor from pandas.api.types import ( - is_categorical_dtype, is_object_dtype, - is_datetime64_any_dtype, is_numeric_dtype, is_integer_dtype + is_object_dtype, + is_datetime64_any_dtype, + is_numeric_dtype, + is_integer_dtype, ) from Orange.data import ( @@ -22,6 +24,19 @@ __all__ = ['table_from_frame', 'table_to_frame'] +# Patch a bug in pandas SparseFrameAccessor.to_dense +# As of pandas=3.0.0.dev0+1524.g23c497bb2f, to_dense ignores _constructor +# and alwats returns DataFrame. +if pd.__version__ < "3": + def to_dense(self): + # pylint: disable=protected-access + data = {k: v.array.to_dense() for k, v in self._parent.items()} + constr = self._parent._constructor + return constr(data, index=self._parent.index, columns=self._parent.columns) + + pandas.core.arrays.sparse.accessor.SparseFrameAccessor.to_dense = to_dense + + class OrangeDataFrame(pd.DataFrame): _metadata = ["orange_variables", "orange_weights", "orange_attributes", "orange_role"] @@ -66,7 +81,7 @@ def __init__(self, *args, **kwargs): index = ['_o' + str(id_) for id_ in table.ids] varsdict = {var._name: var for var in vars_} - columns = varsdict.keys() + columns = list(varsdict.keys()) if sp.issparse(data): data = data.asformat('csc') @@ -74,10 +89,11 @@ def __init__(self, *args, **kwargs): data = dict(enumerate(sparrays)) super().__init__(data, index=index, **kwargs) self.columns = columns - # a hack to keep Orange df _metadata in sparse->dense conversion - self.sparse.to_dense = self.__patch_constructor(self.sparse.to_dense) else: - super().__init__(data=data, index=index, columns=columns, **kwargs) + copy = kwargs.pop("copy", False) + super().__init__( + data=data, index=index, columns=columns, copy=copy, **kwargs + ) self.orange_role = role self.orange_variables = varsdict @@ -85,21 +101,15 @@ def __init__(self, *args, **kwargs): if table.W.size > 0 else {}) self.orange_attributes = table.attributes - def __patch_constructor(self, method): - def new_method(*args, **kwargs): - with patch( - 'pandas.DataFrame', - OrangeDataFrame - ): - df = method(*args, **kwargs) - df.__finalize__(self) - return df - - return new_method - @property def _constructor(self): - return OrangeDataFrame + return partial(self.from_existing, self) + + @staticmethod + def from_existing(existing, *args, **kwargs): + self = type(existing)(*args, **kwargs) + self.__finalize__(existing) + return self def to_orange_table(self): return table_from_frame(self) @@ -146,10 +156,32 @@ def __finalize__(self, other, method=None, **_): pd.DataFrame.__finalize__ = __finalize__ +def _reset_index(df: pd.DataFrame) -> pd.DataFrame: + """If df index is not a simple RangeIndex (or similar), include it into a table""" + if ( + # not range-like index - test first to skip slow startswith(_o) check + not ( + is_integer_dtype(df.index) + and (df.index.is_monotonic_increasing or df.index.is_monotonic_decreasing) + ) + # check that it does not contain Orange index + and ( + # startswith is slow (for long dfs) - firs check if col has strings + isinstance(df.index, pd.MultiIndex) + or not is_object_dtype(df.index) + or not any(str(i).startswith("_o") for i in df.index) + ) + ): + df = df.reset_index() + return df + + def _is_discrete(s, force_nominal): - return (is_categorical_dtype(s) or - is_object_dtype(s) and (force_nominal or - s.nunique() < s.size ** .666)) + return ( + isinstance(s.dtype, pd.CategoricalDtype) + or is_object_dtype(s) + and (force_nominal or s.nunique() < s.size**0.666) + ) def _is_datetime(s): @@ -157,152 +189,195 @@ def _is_datetime(s): return True try: if is_object_dtype(s): - pd.to_datetime(s, infer_datetime_format=True) + # pd.to_datetime would successfully parse column of numbers to datetime + # but for column of object dtype with numbers we want to be either + # discrete or string - following code try to parse column to numeric + # if conversion to numeric is successful return False + try: + pd.to_numeric(s) + return False + except (ValueError, TypeError): + pass + + # utc=True - to allow different timezones in a series object + pd.to_datetime(s, utc=True) return True except Exception: # pylint: disable=broad-except pass return False -def vars_from_df(df, role=None, force_nominal=False): - if role is None and hasattr(df, 'orange_role'): - _role = df.orange_role - else: - _role = role +def _convert_datetime(series, var): + def col_type(dt): + """Test if is date, time or datetime""" + dt_nonnat = dt[~pd.isnull(dt)] # nat == nat is False + if (dt_nonnat.dt.floor("D") == dt_nonnat).all(): + # all times are 00:00:00.0 - pure date + return 1, 0 + elif (dt_nonnat.dt.date == pd.Timestamp("now").date()).all(): + # all dates are today's date - pure time + return 0, 1 # pure time + else: + # else datetime + return 1, 1 - # If df index is not a simple RangeIndex (or similar), put it into data - if ( - # not range-like index - test first to skip slow startswith(_o) check - not ( - df.index.is_integer() - and (df.index.is_monotonic_increasing or df.index.is_monotonic_decreasing) - ) - # check that it does not contain Orange index - and ( - # startswith is slow (for long drs) - firs check if col has strings - isinstance(df.index, pd.MultiIndex) - or not is_string_dtype(df.index) - or not any(str(i).startswith("_o") for i in df.index) - ) - ): - df = df.reset_index() + try: + dt = pd.to_datetime(series) + except ValueError: + # series with type object and different timezones will raise a + # ValueError - normalizing to utc + dt = pd.to_datetime(series, utc=True) + + # set variable type to date, time or datetime + var.have_date, var.have_time = col_type(dt) + + if dt.dt.tz is not None: + # set timezone if available and convert to utc + var.timezone = dt.dt.tz + dt = dt.dt.tz_convert("UTC") + + if var.have_time and not var.have_date: + # if time only measure seconds from midnight - equal to setting date + # to unix epoch + return ( + (dt.dt.tz_localize(None) - pd.Timestamp("now").normalize()) + / pd.Timedelta("1s") + ).values + + return ( + (dt.dt.tz_localize(None) - pd.Timestamp("1970-01-01")) / pd.Timedelta("1s") + ).values - Xcols, Ycols, Mcols = [], [], [] - Xexpr, Yexpr, Mexpr = [], [], [] - attrs, class_vars, metas = [], [], [] - contains_strings = _role == Role.Meta +def to_categorical(s, _): + x = s.astype("category").cat.codes + # it is same than x.replace(-1, np.nan), but much faster + x = x.where(x != -1, np.nan) + return np.asarray(x) + + +def to_numeric(s, _): + return np.asarray(pd.to_numeric(s)) + + +def vars_from_df(df, role=None, force_nominal=False, variables=None): + if variables is not None: + assert len(variables) == len(df.columns) + + if role is None and hasattr(df, 'orange_role'): + role = df.orange_role + df = _reset_index(df) + + cols = [], [], [] + exprs = [], [], [] + vars_ = [], [], [] + + def _convert_string(s, _): + return np.asarray( + # to object so that fillna can replace with nans if Unknown in nan + # replace nan with object Unknown assure that all values are string + s.infer_objects(copy=False).fillna(StringVariable.Unknown).astype(str), + dtype=object + ) + + conversions = { + DiscreteVariable: to_categorical, + ContinuousVariable: to_numeric, + TimeVariable: _convert_datetime, + StringVariable: _convert_string + } - for column in df.columns: + for column, var in zip_longest(df.columns, variables or [], fillvalue=None): s = df[column] - if hasattr(df, 'orange_variables') and column in df.orange_variables: + _role = Role.Attribute if role is None else role + if var is not None: + if not var.is_primitive(): + _role = Role.Meta + expr = conversions[type(var)] + elif hasattr(df, 'orange_variables') and column in df.orange_variables: original_var = df.orange_variables[column] var = original_var.copy(compute_value=None) - if _role == Role.Attribute: - Xcols.append(column) - Xexpr.append(None) - attrs.append(var) - elif _role == Role.ClassAttribute: - Ycols.append(column) - Yexpr.append(None) - class_vars.append(var) - else: # if role == Role.Meta: - Mcols.append(column) - Mexpr.append(None) - metas.append(var) - elif _is_discrete(s, force_nominal): - discrete = s.astype('category').cat - var = DiscreteVariable(str(column), - discrete.categories.astype(str).tolist()) - attrs.append(var) - Xcols.append(column) - - def to_cat(s, _): - x = s.astype("category").cat.codes - # it is same than x.replace(-1, np.nan), but much faster - x = x.where(x != -1, np.nan) - return np.asarray(x) - - Xexpr.append(to_cat) - elif _is_datetime(s): - var = TimeVariable(str(column)) - attrs.append(var) - Xcols.append(column) - Xexpr.append(lambda s, v: np.asarray( - s.astype('str').replace('NaT', np.nan).map(v.parse) - )) - elif is_numeric_dtype(s): - var = ContinuousVariable( - # set number of decimals to 0 if int else keeps default behaviour - str(column), number_of_decimals=(0 if is_integer_dtype(s) else None) - ) - attrs.append(var) - Xcols.append(column) - Xexpr.append(None) + expr = None else: - contains_strings = True - var = StringVariable(str(column)) - metas.append(var) - Mcols.append(column) - Mexpr.append(lambda s, _: np.asarray(s, dtype=object)) - - # if role isn't explicitly set, try to - # export dataframes into one contiguous block. - # for this all columns must be of the same role - if isinstance(df, OrangeDataFrame) \ - and not role \ - and contains_strings \ - and not force_nominal: - attrs.extend(class_vars) - attrs.extend(metas) - metas = attrs - Xcols.extend(Ycols) - Xcols.extend(Mcols) - Mcols = Xcols - Xexpr.extend(Yexpr) - Xexpr.extend(Mexpr) - Mexpr = Xexpr - - attrs, class_vars = [], [] - Xcols, Ycols = [], [] - Xexpr, Yexpr = [], [] - - XYM = [] - for Avars, Acols, Aexpr in zip( - (attrs, class_vars, metas), - (Xcols, Ycols, Mcols), - (Xexpr, Yexpr, Mexpr)): - if not Acols: - A = None if Acols != Xcols else np.empty((df.shape[0], 0)) - XYM.append(A) - continue - if not any(Aexpr): - Adf = df if all(c in Acols - for c in df.columns) else df[Acols] - if all(isinstance(a, SparseDtype) for a in Adf.dtypes): - A = csr_matrix(Adf.sparse.to_coo()) + if _is_datetime(s): + var = TimeVariable(str(column)) + elif _is_discrete(s, force_nominal): + discrete = s.astype("category").cat + var = DiscreteVariable( + str(column), discrete.categories.astype(str).tolist() + ) + elif is_numeric_dtype(s): + var = ContinuousVariable( + # set number of decimals to 0 if int else keeps default behaviour + str(column), number_of_decimals=(0 if is_integer_dtype(s) else None) + ) + else: + if role is not None and role != Role.Meta: + raise ValueError("String variable must be in metas.") + _role = Role.Meta + var = StringVariable(str(column)) + expr = conversions[type(var)] + + + cols[_role].append(column) + exprs[_role].append(expr) + vars_[_role].append(var) + + xym = [] + for a_vars, a_cols, a_expr in zip(vars_, cols, exprs): + if not a_cols: + arr = None if a_cols != cols[0] else np.empty((df.shape[0], 0)) + elif not any(a_expr): + # if all c in columns table will share memory with dataframe + a_df = df if all(c in a_cols for c in df.columns) else df[a_cols] + if all(isinstance(a, pd.SparseDtype) for a in a_df.dtypes): + arr = csr_matrix(a_df.sparse.to_coo()) else: - A = np.asarray(Adf) - XYM.append(A) - continue - # we'll have to copy the table to resolve any expressions - # TODO eliminate expr (preprocessing for pandas -> table) - A = np.array([expr(df[col], var) if expr else np.asarray(df[col]) - for var, col, expr in zip(Avars, Acols, Aexpr)]).T - XYM.append(A) + arr = np.asarray(a_df) + else: + # we'll have to copy the table to resolve any expressions + arr = np.array( + [ + expr(df[col], var) if expr else np.asarray(df[col]) + for var, col, expr in zip(a_vars, a_cols, a_expr) + ] + ).T + xym.append(arr) + + # Let the tables share memory with pandas frame + if xym[1] is not None and xym[1].ndim == 2 and xym[1].shape[1] == 1: + xym[1] = xym[1][:, 0] - return XYM, Domain(attrs, class_vars, metas) + return xym, Domain(*vars_) -def table_from_frame(df, *, force_nominal=False): - XYM, domain = vars_from_df(df, force_nominal=force_nominal) +def table_from_frame(df, *, force_nominal=False, variables=None): + """ + Convert pandas DataFrame to Orange.data.Table. + + Parameters + ---------- + df : pandas DataFrame + + force_nominal : bool, (default=False) + Force all string variables to be nominal. + + variables : list of Variable, optional + + Returns + ------- + Orange.data.Table + """ + XYM, domain = vars_from_df(df, + force_nominal=force_nominal, + variables=variables) if hasattr(df, 'orange_weights') and hasattr(df, 'orange_attributes'): W = [df.orange_weights[i] for i in df.index if i in df.orange_weights] if len(W) != len(df.index): W = None attributes = df.orange_attributes - if isinstance(df.index, pd.MultiIndex) or not is_string_dtype(df.index): + if isinstance(df.index, pd.MultiIndex) or not is_object_dtype(df.index): # we can skip checking for Orange indices when MultiIndex an when # not string dtype and so speedup the conversion ids = None @@ -328,6 +403,15 @@ def table_from_frame(df, *, force_nominal=False): def table_from_frames(xdf, ydf, mdf): + if not (xdf.index.equals(ydf.index) and xdf.index.equals(mdf.index)): + raise ValueError( + "Indexes not equal. Make sure that all three dataframes have equal index" + ) + + # drop index from x and y - it makes sure that index if not range will be + # placed in metas + xdf = xdf.reset_index(drop=True) + ydf = ydf.reset_index(drop=True) dfs = xdf, ydf, mdf if not all(df.shape[0] == xdf.shape[0] for df in dfs): @@ -341,23 +425,23 @@ def table_from_frames(xdf, ydf, mdf): XYM = (xXYM[0], yXYM[1], mXYM[2]) domain = Domain(xDomain.attributes, yDomain.class_vars, mDomain.metas) - index_iter = (filter(lambda ind: ind.startswith('_o'), - set(df.index[i] for df in dfs)) - for i in range(len(xdf.shape[0]))) - ids = (i[0] if len(i) == 1 else Table.new_id() - for i in index_iter) + ids = [ + int(idx[2:]) + if str(idx).startswith("_o") and idx[2:].isdigit() + else Table.new_id() + for idx in mdf.index + ] attributes = {} W = None for df in dfs: if isinstance(df, OrangeDataFrame): - W = [df.orange_weights[i] for i in df.index - if i in df.orange_weights] + W = [df.orange_weights[i] for i in df.index if i in df.orange_weights] if len(W) != len(df.index): W = None + attributes.update(df.orange_attributes) else: W = None - attributes.update(df.orange_attributes) return Table.from_numpy( domain, @@ -387,7 +471,7 @@ def table_to_frame(tab, include_metas=False): def _column_to_series(col, vals): result = () if col.is_discrete: - codes = pd.Series(vals).fillna(-1).astype(int) + codes = pd.Series(vals).infer_objects(copy=False).fillna(-1).astype(int) result = (col.name, pd.Categorical.from_codes( codes=codes, categories=col.values, ordered=True )) diff --git a/Orange/data/sql/backend/base.py b/Orange/data/sql/backend/base.py index 208e01a375c..9261d75e9b2 100644 --- a/Orange/data/sql/backend/base.py +++ b/Orange/data/sql/backend/base.py @@ -64,10 +64,42 @@ def list_tables(self, schema=None): for schema, name in cur.fetchall(): sql = "{}.{}".format( self.quote_identifier(schema), - self.quote_identifier(name)) if schema else self.quote_identifier(name) + self.quote_identifier( + name)) if schema else self.quote_identifier(name) tables.append(TableDesc(name, schema, sql)) return tables + def n_tables_query(self, schema=None) -> str: + """Return a query to count tables in database. + + Parameters + ---------- + schema : Optional[str] + If set, only tables from schema should be listed + + Returns + ------- + Query string. + """ + raise NotImplementedError + + def n_tables(self, schema=None) -> int: + """Return number of tables in database. + + Parameters + ---------- + schema : Optional[str] + If set, only tables from given schema will be listed. + + Returns + ------- + Number of tables in the database. + """ + query = self.n_tables_query(schema) + with self.execute_sql_query(query) as cur: + res = cur.fetchone() + return res[0] + def get_fields(self, table_name): """Return a list of field names and metadata in the given table @@ -141,19 +173,6 @@ def create_variable(self, field_name, field_metadata, """ raise NotImplementedError - def count_approx(self, query): - """Return estimated number of rows returned by query. - - Parameters - ---------- - query : str - - Returns - ------- - Approximate number of rows - """ - raise NotImplementedError - # query related methods def create_sql_query( diff --git a/Orange/data/sql/backend/mssql.py b/Orange/data/sql/backend/mssql.py index e6ae16dd15a..360bedb7029 100644 --- a/Orange/data/sql/backend/mssql.py +++ b/Orange/data/sql/backend/mssql.py @@ -43,6 +43,9 @@ def list_tables_query(self, schema=None): ORDER BY [TABLE_NAME] """ + def n_tables_query(self, _=None) -> str: + return "SELECT COUNT(*) FROM information_schema.tables" + def quote_identifier(self, name): return "[{}]".format(name) @@ -127,41 +130,17 @@ def _guess_variable(self, field_name, field_metadata, inspect_table): EST_ROWS_RE = re.compile(r'StatementEstRows="(\d+)"') - def count_approx(self, query): - with self.connection.cursor() as cur: - try: - cur.execute("SET SHOWPLAN_XML ON") - try: - cur.execute(query) - result = cur.fetchone() - match = self.EST_ROWS_RE.search(result[0]) - if not match: - # Either StatementEstRows was not found or - # a float is received. - # If it is a float then it is most probable - # that the server's statistics are out of date - # and the result is false. In that case - # it is preferable to return None so - # an exact count be used. - return None - return int(match.group(1)) - finally: - cur.execute("SET SHOWPLAN_XML OFF") - except pymssql.Error as ex: - if "SHOWPLAN permission denied" in str(ex): - warnings.warn("SHOWPLAN permission denied, count approximates will not be used") - return None - raise BackendError(parse_ex(ex)) from ex - def distinct_values_query(self, field_name: str, table_name: str) -> str: field = self.quote_identifier(field_name) return self.create_sql_query( table_name, [field], - # workaround for collations that are not case sensitive and - # UTF characters sensitive - in the domain we still want to - # have all values (collation independent) - group_by=[f"{field}, Cast({field} as binary)"], + # Cast - workaround for collations that are not case-sensitive and + # UTF characters sensitive + # DATALENGTH - workaround for string comparison that ignore trailing + # spaces, two strings that differ only in space in the end would + # group together if DATALENGTH wouldn't be used + group_by=[f"{field}, Cast({field} as binary), DATALENGTH({field})"], order_by=[field], limit=21, ) diff --git a/Orange/data/sql/backend/postgres.py b/Orange/data/sql/backend/postgres.py index b6ebbae2b51..3bafaf03e5b 100644 --- a/Orange/data/sql/backend/postgres.py +++ b/Orange/data/sql/backend/postgres.py @@ -21,7 +21,7 @@ class Psycopg2Backend(Backend): display_name = "PostgreSQL" connection_pool = None - auto_create_extensions = True + auto_create_extensions = False def __init__(self, connection_params): super().__init__(connection_params) @@ -111,7 +111,13 @@ def list_tables_query(self, schema=None): AND n.nspname !~ '^pg_toast' {} AND NOT c.relname LIKE '\\_\\_%' - ORDER BY 1;""".format(schema_clause) + ORDER BY 1,2;""".format(schema_clause) + + def n_tables_query(self, schema=None) -> str: + query = "SELECT COUNT(*) FROM information_schema.tables" + if schema: + query += f" WHERE table_schema = '{schema}'" + return query def create_variable(self, field_name, field_metadata, type_hints, inspect_table=None): @@ -129,6 +135,8 @@ def create_variable(self, field_name, field_metadata, else: var.to_sql = ToSql("({})::double precision" .format(field_name_q)) + elif var.is_discrete and sorted(var.values) == ["false", "true"]: + var.to_sql = ToSql(field_name_q) else: # discrete or string var.to_sql = ToSql("({})::text" .format(field_name_q)) @@ -174,12 +182,6 @@ def _guess_variable(self, field_name, field_metadata, inspect_table): return StringVariable.make(field_name) - def count_approx(self, query): - sql = "EXPLAIN " + query - with self.execute_sql_query(sql) as cur: - s = ''.join(row[0] for row in cur.fetchall()) - return int(re.findall(r'rows=(\d*)', s)[0]) - def distinct_values_query(self, field_name: str, table_name: str) -> str: fields = [self.quote_identifier(field_name)] return self.create_sql_query( diff --git a/Orange/data/sql/filter.py b/Orange/data/sql/filter.py index c2314dd3b2e..68f8181af7f 100644 --- a/Orange/data/sql/filter.py +++ b/Orange/data/sql/filter.py @@ -2,6 +2,8 @@ class IsDefinedSql(filter.IsDefined): + InheritEq = True + def to_sql(self): sql = " AND ".join([ '%s IS NOT NULL' % column diff --git a/Orange/data/sql/table.py b/Orange/data/sql/table.py index d3e59f6d0c7..23a50d5ff6a 100644 --- a/Orange/data/sql/table.py +++ b/Orange/data/sql/table.py @@ -1,9 +1,9 @@ """ Support for example tables wrapping data stored on a PostgreSQL server. """ +import contextlib import functools import logging -import threading import warnings from contextlib import contextmanager from itertools import islice @@ -15,6 +15,7 @@ from Orange.data.sql import filter as sql_filter from Orange.data.sql.backend import Backend from Orange.data.sql.backend.base import TableDesc, BackendError +from Orange.util import OrangeDeprecationWarning LARGE_TABLE = 100000 AUTO_DL_LIMIT = 10000 @@ -176,8 +177,8 @@ def _fetch_row(self, row_index): rows = [row_index] values = list(self._query(attributes, rows=rows)) if not values: - raise IndexError('Could not retrieve row {} from table {}'.format( - row_index, self.name)) + raise IndexError(f'Could not retrieve row {row_index} ' + f'from table {self.name}') return Instance(self.domain, values[0]) def __iter__(self): @@ -258,22 +259,9 @@ def _count_rows(self): return self._cached__len__ def approx_len(self, get_exact=False): - if self._cached__len__ is not None: - return self._cached__len__ - - approx_len = None - try: - query = self._sql_query(["*"]) - approx_len = self.backend.count_approx(query) - if get_exact: - threading.Thread(target=len, args=(self,)).start() - except NotImplementedError: - pass - - if approx_len is None: - approx_len = len(self) - - return approx_len + warnings.warn("table.approx_len() has been deprecated. Use len(table)" + " instead.", OrangeDeprecationWarning) + return len(self) _X = None _Y = None @@ -283,7 +271,7 @@ def approx_len(self, get_exact=False): def download_data(self, limit=None, partial=False): """Download SQL data and store it in memory as numpy matrices.""" - if limit and not partial and self.approx_len() > limit: + if limit and not partial and len(self) > limit: raise ValueError("Too many rows to download the data into memory.") X = [np.empty((0, len(self.domain.attributes)))] Y = [np.empty((0, len(self.domain.class_vars)))] @@ -347,8 +335,8 @@ def has_weights(self): return False def _compute_basic_stats(self, columns=None, - include_metas=False, compute_var=False): - if self.approx_len() > LARGE_TABLE: + include_metas=False, compute_variance=False): + if len(self) > LARGE_TABLE: self = self.sample_time(DEFAULT_SAMPLE_TIME) if columns is not None: @@ -380,7 +368,7 @@ def _get_stats(self, columns): return stats def _compute_distributions(self, columns=None): - if self.approx_len() > LARGE_TABLE: + if len(self) > LARGE_TABLE: self = self.sample_time(DEFAULT_SAMPLE_TIME) if columns is not None: @@ -407,7 +395,7 @@ def _get_distributions(self, columns): return dists def _compute_contingency(self, col_vars=None, row_var=None): - if self.approx_len() > LARGE_TABLE: + if len(self) > LARGE_TABLE: self = self.sample_time(DEFAULT_SAMPLE_TIME) if col_vars is None: @@ -557,6 +545,7 @@ def _filter_values(self, f): @classmethod def from_table(cls, domain, source, row_indices=...): + # pylint: disable=unused-argument assert row_indices is ... table = source.copy() @@ -598,7 +587,6 @@ def sample_time(self, time_in_seconds, no_cache=False): def _sample(self, method, parameter, no_cache=False): # the module is optional, but this function is not called if it's not installed # pylint: disable=import-error - import psycopg2 if "," in self.table_name: raise NotImplementedError("Sampling of complex queries is not supported") @@ -640,9 +628,6 @@ def _sample(self, method, parameter, no_cache=False): sampled_table = self.copy() sampled_table.table_name = sample_table_q - with sampled_table.backend.execute_sql_query('ANALYZE' - + sample_table_q): - pass return sampled_table @contextmanager @@ -653,3 +638,41 @@ def _execute_sql_query(self, query, param=None): def checksum(self, include_metas=True): return np.nan + + def __get_nan_frequency(self, columns): + try: + query = self._sql_query([" + ".join([f"COUNT(*) - COUNT({col.to_sql()})" + for col in columns])]) + with self.backend.execute_sql_query(query) as cur: + return cur.fetchone()[0] / (len(self) * len(columns)) + except BackendError: + return None + + def get_nan_frequency_attribute(self): + return self.__get_nan_frequency(self.domain.attributes) + + def get_nan_frequency_class(self): + return self.__get_nan_frequency(self.domain.class_vars) + + def __getstate__(self): + # avoids locking magic in Table.__getstate__ + return self.__dict__ + + def __setstate__(self, state): + # avoid locking magic in Table.__setstate__ + self.__dict__.update(state) + + # if X is defined then it was already downloaded + # thus ids exist to, rewrite them + if self._X is not None: + self._init_ids(self) + + # pylint: disable=unused-argument + def _update_locks(self, *args, **kwargs): + # avoid locking inherited from Table + return + + # pylint: disable=unused-argument + def unlocked(self, *parts): + # avoid locking inherited from Table + return contextlib.nullcontext() diff --git a/Orange/data/storage.py b/Orange/data/storage.py index 14b4dd89596..7cd49f4e0ac 100644 --- a/Orange/data/storage.py +++ b/Orange/data/storage.py @@ -1,3 +1,8 @@ +import warnings + +from Orange.util import OrangeDeprecationWarning + + class Storage: domain = None @@ -7,6 +12,8 @@ class Storage: MISSING, DENSE, SPARSE, SPARSE_BOOL = range(4) def approx_len(self): + warnings.warn("table.approx_len() has been deprecated. Use len(table)" + " instead.", OrangeDeprecationWarning) return len(self) def X_density(self): diff --git a/Orange/data/table.py b/Orange/data/table.py index 69935b1ac24..e7af91f2156 100644 --- a/Orange/data/table.py +++ b/Orange/data/table.py @@ -1,14 +1,18 @@ import operator import os +import sys import threading import warnings import weakref import zlib from collections.abc import Iterable, Sequence, Sized +from contextlib import contextmanager +from copy import deepcopy from functools import reduce from itertools import chain from numbers import Real, Integral from threading import Lock +from typing import List, TYPE_CHECKING, Union import bottleneck as bn import numpy as np @@ -25,11 +29,15 @@ from Orange.data.util import SharedComputeValue, \ assure_array_dense, assure_array_sparse, \ assure_column_dense, assure_column_sparse, get_unique_names_duplicates +from Orange.misc.cache import IDWeakrefCache from Orange.misc.collections import frozendict from Orange.statistics.util import bincount, countnans, contingency, \ stats as fast_stats, sparse_has_implicit_zeros, sparse_count_implicit_zeros, \ sparse_implicit_zero_weights -from Orange.util import OrangeDeprecationWarning, dummy_callback +from Orange.util import deprecated, OrangeDeprecationWarning, dummy_callback +if TYPE_CHECKING: + # import just for type checking - avoid circular import + from Orange.data.aggregate import OrangeTableGroupBy __all__ = ["dataset_dirs", "get_sample_datasets_dir", "RowInstance", "Table"] @@ -95,6 +103,7 @@ def __init__(self, table, row_index): if sp.issparse(self._y): self.sparse_y = sp.csr_matrix(self._y) self._y = np.asarray(self._y.todense())[0] + self._y = np.atleast_1d(self._y) self._metas = table.metas[row_index] if sp.issparse(self._metas): self.sparse_metas = sp.csr_matrix(self._metas) @@ -113,12 +122,16 @@ def weight(self, weight): self.table.W[self.row_index] = weight def set_class(self, value): + # pylint: disable=protected-access self._check_single_class() if not isinstance(value, Real): value = self.table.domain.class_var.to_val(value) - self._y[0] = value if self.sparse_y: self.table._Y[self.row_index, 0] = value + else: + self.table._Y[self.row_index] = value + if self.table._Y.ndim == 1: # if _y is not a view + self._y[0] = value def __setitem__(self, key, value): if not isinstance(key, Integral): @@ -131,25 +144,27 @@ def __setitem__(self, key, value): raise TypeError("Expected primitive value, got '%s'" % type(value).__name__) if key < len(self._x): - self._x[key] = value + # write to self.table.X to support table unlocking for live instances + self.table.X[self.row_index, key] = value if self.sparse_x is not None: - self.table.X[self.row_index, key] = value + self._x[key] = value else: - self._y[key - len(self._x)] = value - if self.sparse_y is not None: + if self.table._Y.ndim == 2: self.table._Y[self.row_index, key - len(self._x)] = value + else: + self.table._Y[self.row_index] = value + self._y[0] = value # _y is not a view else: - self._metas[-1 - key] = value - if self.sparse_metas: - self.table.metas[self.row_index, -1 - key] = value + self.table.metas[self.row_index, -1 - key] = value + if self.sparse_metas is not None: + self._metas[-1 - key] = value def _str(self, limit): - def sp_values(matrix, variables): - if not sp.issparse(matrix): - if matrix.ndim == 1: - matrix = matrix[:, np.newaxis] - return Instance.str_values(matrix[row], variables, limit) + def sp_values(row, variables, sparsity=None): + if sparsity is None: + return Instance.str_values(row, variables, limit) + # row is sparse row_entries, idx = [], 0 while idx < len(variables): # Make sure to stop printing variables if we limit the output @@ -157,8 +172,8 @@ def sp_values(matrix, variables): break var = variables[idx] - if var.is_discrete or matrix[row, idx]: - row_entries.append("%s=%s" % (var.name, var.str_val(matrix[row, idx]))) + if var.is_discrete or row[idx]: + row_entries.append("%s=%s" % (var.name, var.str_val(row[idx]))) idx += 1 @@ -169,15 +184,13 @@ def sp_values(matrix, variables): return s - table = self.table - domain = table.domain - row = self.row_index - s = "[" + sp_values(table.X, domain.attributes) + domain = self._domain + s = "[" + sp_values(self._x, domain.attributes, self.sparse_x) if domain.class_vars: - s += " | " + sp_values(table.Y, domain.class_vars) + s += " | " + sp_values(self._y, domain.class_vars, self.sparse_y) s += "]" - if self._domain.metas: - s += " {" + sp_values(table.metas, domain.metas) + "}" + if domain.metas: + s += " {" + sp_values(self._metas, domain.metas, self.sparse_metas) + "}" return s def __str__(self): @@ -193,12 +206,24 @@ def __init__(self, domain): setattr(self, v.name.replace(" ", "_"), v) -class _ArrayConversion: +def _compute_column(func, *args, **kwargs): + col = func(*args, **kwargs) + if isinstance(col, np.ndarray) and col.ndim != 1: + err = f"{type(col)} must return a column, not {col.ndim}d array" + if col.ndim == 2: + warnings.warn(err) + col = col.reshape(-1) + else: + raise ValueError(err) + return col + +class _ArrayConversion: def __init__(self, target, src_cols, variables, is_sparse, source_domain): self.target = target self.src_cols = src_cols self.is_sparse = is_sparse + self.results_inplace = not is_sparse self.subarray_from = self._can_copy_all(src_cols, source_domain) self.variables = variables dtype = np.float64 @@ -219,7 +244,8 @@ def _can_copy_all(self, src_cols, source_domain): for x in src_cols): return "Y" - def get_subarray(self, source, row_indices, n_rows): + def get_subarray(self, source, row_indices): + n_rows = _selection_length(row_indices, len(source)) if not len(self.src_cols): if self.is_sparse: return sp.csr_matrix((n_rows, 0), dtype=source.X.dtype) @@ -234,22 +260,22 @@ def get_subarray(self, source, row_indices, n_rows): arr = match_density(_subarray(source.metas, row_indices, [-1 - x for x in self.src_cols])) elif self.subarray_from == "Y": + Y = source.Y if source.Y.ndim == 2 else source.Y[:, None] arr = match_density(_subarray( - source._Y, row_indices, + Y, row_indices, [x - n_src_attrs for x in self.src_cols])) else: assert False if arr.dtype != self.dtype: arr = arr.astype(self.dtype) - assert arr.ndim == 2 + assert arr.ndim == 2 or self.subarray_from == "Y" and arr.ndim == 1 return arr - def get_columns(self, source, row_indices, n_rows, out=None, target_indices=None): + def get_columns(self, source, row_indices, out=None, target_indices=None): + n_rows = _selection_length(row_indices, len(source)) n_src_attrs = len(source.domain.attributes) data = [] - sp_col = [] - sp_row = [] match_density = ( assure_column_sparse if self.is_sparse else assure_column_dense ) @@ -257,8 +283,13 @@ def get_columns(self, source, row_indices, n_rows, out=None, target_indices=None # converting to csc before instead of each column is faster # do not convert if not required if any(isinstance(x, int) for x in self.src_cols): - X = csc_matrix(source.X) if self.is_sparse else source.X - Y = csc_matrix(source._Y) if self.is_sparse else source._Y + X = source.X + Y = source.Y + if Y.ndim == 1: + Y = Y[:, None] + if self.is_sparse: + X = csc_matrix(X) + Y = csc_matrix(Y) if self.row_selection_needed: if row_indices is ...: @@ -274,14 +305,15 @@ def get_columns(self, source, row_indices, n_rows, out=None, target_indices=None ) elif not isinstance(col, Integral): if isinstance(col, SharedComputeValue): - shared = _idcache_restore(shared_cache, (col.compute_shared, source)) - if shared is None: + try: + shared = shared_cache[(col.compute_shared, source)] + except KeyError: shared = col.compute_shared(sourceri) - _idcache_save(shared_cache, (col.compute_shared, source), shared) + shared_cache[col.compute_shared, source] = shared col_array = match_density( - col(sourceri, shared_data=shared)) + _compute_column(col, sourceri, shared_data=shared)) else: - col_array = match_density(col(sourceri)) + col_array = match_density(_compute_column(col, sourceri)) elif col < 0: col_array = match_density( source.metas[row_indices, -1 - col] @@ -293,30 +325,60 @@ def get_columns(self, source, row_indices, n_rows, out=None, target_indices=None Y[row_indices, col - n_src_attrs] ) - if self.is_sparse: - # col_array should be coo matrix - data.append(col_array.data) - sp_col.append(np.full(len(col_array.data), i)) - sp_row.append(col_array.indices) # row indices should be same - else: + if self.results_inplace: out[target_indices, i] = col_array + else: + data.append(col_array) + if self.results_inplace: + return out + else: + return self.join_columns(data) + + def join_columns(self, data): if self.is_sparse: # creating csr directly would need plenty of manual work which # would probably slow down the process - conversion coo to csr # is fast + coo_data = [] + coo_col = [] + coo_row = [] + for i, col_array in enumerate(data): + coo_data.append(col_array.data) + coo_col.append(np.full(len(col_array.data), i)) + coo_row.append(col_array.indices) # row indices should be same + n_rows = col_array.shape[0] # pylint: disable=undefined-loop-variable out = sp.coo_matrix( - (np.hstack(data), (np.hstack(sp_row), np.hstack(sp_col))), + (np.hstack(coo_data), (np.hstack(coo_row), np.hstack(coo_col))), shape=(n_rows, len(self.src_cols)), dtype=self.dtype ) - out = out.tocsr() + return out.tocsr() - return out + def join_partial_results(self, parts): + if self.is_sparse: + return sp.vstack(parts) + else: + return parts + + def init_partial_results(self, n_rows): + if not self.results_inplace: + return [] # list to store partial results + else: # a dense numpy array + # F-order enables faster writing to the array while accessing and + # matrix operations work with same speed (e.g. dot) + return np.zeros((n_rows, len(self.src_cols)), + order="F", dtype=self.dtype) + + def add_partial_result(self, parts, part): + if not self.results_inplace: + parts.append(part) class _FromTableConversion: + max_rows_at_once = 5000 + def __init__(self, source, destination): conversion = DomainConversion(source, destination) @@ -339,19 +401,78 @@ def __init__(self, source, destination): else: self.subarray.append(part) + def convert(self, source, row_indices, clear_cache_after_part): + n_rows = _selection_length(row_indices, len(source)) + + res = {} + + for array_conv in self.subarray: + out = array_conv.get_subarray(source, row_indices) + res[array_conv.target] = out + + parts = {} + + for array_conv in self.columnwise: + parts[array_conv.target] = array_conv.init_partial_results(n_rows) + + if n_rows <= self.max_rows_at_once: + for array_conv in self.columnwise: + out = array_conv.get_columns(source, row_indices, + parts[array_conv.target], + ...) + res[array_conv.target] = out + else: + i_done = 0 + + while i_done < n_rows: + target_indices = slice(i_done, min(n_rows, i_done + self.max_rows_at_once)) + source_indices = _select_from_selection(row_indices, target_indices, + len(source)) + + for array_conv in self.columnwise: + # dense arrays are populated in-place + out = array_conv.get_columns(source, source_indices, + parts[array_conv.target], + target_indices) + array_conv.add_partial_result(parts[array_conv.target], out) + + i_done += self.max_rows_at_once + + # clear cache after a part is done + if clear_cache_after_part: + _thread_local.conversion_cache.clear() + + for array_conv in self.columnwise: + res[array_conv.target] = \ + array_conv.join_partial_results(parts[array_conv.target]) + + return res["X"], res["Y"], res["metas"] + # noinspection PyPep8Naming class Table(Sequence, Storage): + + LOCKING = None + """ If the class attribute LOCKING is True, tables will throw exceptions + on in-place modifications unless unlocked explicitly. LOCKING is supposed + to be set to True for testing to help us find bugs. If set to False + or None, no safeguards are in place. Two different values are used for + the same behaviour to distinguish the unchanged default (None) form + explicit deactivation (False) that some add-ons might need. """ + __file__ = None name = "untitled" domain = Domain([]) - X = _Y = metas = W = np.zeros((0, 0)) - X.setflags(write=False) + _X = _Y = _metas = _W = np.zeros((0, 0)) # pylint: disable=invalid-name ids = np.zeros(0) ids.setflags(write=False) attributes = frozendict() + _Unlocked_X_val, _Unlocked_Y_val, _Unlocked_metas_val, _Unlocked_W_val = 1, 2, 4, 8 + _Unlocked_X_ref, _Unlocked_Y_ref, _Unlocked_metas_ref, _Unlocked_W_ref = 16, 32, 64, 128 + _unlocked = 0xff # pylint: disable=invalid-name + @property def columns(self): """ @@ -365,21 +486,211 @@ def columns(self): _next_instance_id = 0 _next_instance_lock = Lock() + def _check_unlocked(self, partflag): + if not self._unlocked & partflag: + raise ValueError("Table is read-only unless unlocked") + + @property + def X(self): # pylint: disable=invalid-name + return self._X + + @X.setter + def X(self, value): + self._check_unlocked(self._Unlocked_X_ref) + self._X = _dereferenced(value) + self._update_locks() + @property - def Y(self): - if self._Y.shape[1] == 1: - return self._Y[:, 0] + def Y(self): # pylint: disable=invalid-name return self._Y @Y.setter def Y(self, value): - if len(value.shape) == 1: - value = value[:, None] + self._check_unlocked(self._Unlocked_Y_ref) if sp.issparse(value) and len(self) != value.shape[0]: value = value.T if sp.issparse(value): - value = value.toarray() + value = _dereferenced(value.toarray()) + if value.ndim == 2 and value.shape[1] == 1: + value = value[:, 0].copy() # no views! self._Y = value + self._update_locks() + + @property + def metas(self): + return self._metas + + @metas.setter + def metas(self, value): + self._check_unlocked(self._Unlocked_metas_ref) + self._metas = _dereferenced(value) + self._update_locks() + + @property + def W(self): # pylint: disable=invalid-name + return self._W + + @W.setter + def W(self, value): + self._check_unlocked(self._Unlocked_W_ref) + self._W = value + self._update_locks() + + def __setstate__(self, state): + # Backward compatibility with pickles before table locking + + def no_view(x): + # Some arrays can be unpickled as views; ensure they are not + if isinstance(x, np.ndarray) and x.base is not None: + return x.copy() + return x + + self._initialize_unlocked() # __dict__ seems to be cleared before calling __setstate__ + with self.unlocked_reference(): + for k in ("X", "W", "metas"): + if k in state: + setattr(self, k, no_view(state.pop(k))) + if "_Y" in state: + setattr(self, "Y", no_view(state.pop("_Y"))) # state["_Y"] is a 2d array + self.__dict__.update(state) + + self._init_ids(self) + + def __getstate__(self): + # Compatibility with pickles before table locking: + # return the same state as before table lock + state = self.__dict__.copy() + for k in ["X", "metas", "W"]: + if "_" + k in state: # Check existence; SQL tables do not contain them + state[k] = state.pop("_" + k) + # before locking, _Y was always a 2d array: save it as such + if "_Y" in state: + y = state.pop("_Y") + y2d = y.reshape(-1, 1) if y.ndim == 1 else y + state["_Y"] = y2d + state.pop("_unlocked", None) + return state + + def _lock_parts_val(self): + return ((self._X, self._Unlocked_X_val, "X"), + (self._Y, self._Unlocked_Y_val, "Y"), + (self._metas, self._Unlocked_metas_val, "metas"), + (self._W, self._Unlocked_W_val, "weights")) + + def _lock_parts_ref(self): + return ((self._X, self._Unlocked_X_ref, "X"), + (self._Y, self._Unlocked_Y_ref, "Y"), + (self._metas, self._Unlocked_metas_ref, "metas"), + (self._W, self._Unlocked_W_ref, "weights")) + + def _initialize_unlocked(self): + if Table.LOCKING: + self._unlocked = 0 + else: + self._unlocked = sum(f for _, f, _ in (self._lock_parts_val() + self._lock_parts_ref())) + + def _update_locks(self, force=False, lock_bases=()): + if not Table.LOCKING: + return + + def sync(*xs): + for x in xs: + # no need to make empty arrays writable, as nothing can get written + if writeable and x.size == 0: + continue + try: + undo_on_fail.append((x, x.flags.writeable)) + x.flags.writeable = writeable + except ValueError: + if force \ + and writeable \ + and x.base is not None \ + and not x.base.flags.writeable: + x.base.flags.writeable = writeable + x.flags.writeable = writeable + forced_bases.append(x.base) + else: + raise + + forced_bases = [] + undo_on_fail = [] + for base in lock_bases: + base.flags.writeable = False + try: + for part, flag, _ in self._lock_parts_val(): + if part is None: + continue + writeable = bool(self._unlocked & flag) + if sp.isspmatrix_csr(part) or sp.isspmatrix_csc(part): + sync(part.data, part.indices, part.indptr) + elif sp.isspmatrix_coo(part): + sync(part.data, part.row, part.col) + elif sp.issparse(part): + raise ValueError("Unsupported sparse data type") + else: + sync(part) + except: + for part, flag in undo_on_fail: + part.flags.writeable = flag + raise + return tuple(forced_bases) + + def __unlocked(self, *parts, force=False, reference_only=False): + prev_state = self._unlocked + if reference_only: + lock_parts = self._lock_parts_ref() + else: + lock_parts = self._lock_parts_val() + self._lock_parts_ref() + for part, flag, _ in lock_parts: + if not parts or any(ppart is part for ppart in parts): + self._unlocked |= flag + try: + forced_bases = self._update_locks(force) + yield + finally: + self._unlocked = prev_state + self._update_locks(lock_bases=forced_bases) + + def force_unlocked(self, *parts): + """ + Unlocking without any checks. + + Use with extreme caution. This is meant primarily for 3rd party + functions in Cython that expect read-write buffer, but do not + actually modify it. the given parts (default: all parts) of the table. + + The function will still fail to unlock and raise an exception if the + table contains view to another table. + """ + return contextmanager(self.__unlocked)(*parts, force=True) + + def unlocked_reference(self, *parts): + """ + Unlock references to the given parts (default: all parts) of the table. + + The caller must ensure that the table is safe to modify. + """ + return contextmanager(self.__unlocked)(*parts, reference_only=True) + + def unlocked(self, *parts): + """ + Unlock the given parts (default: all parts) of the table. + + The caller must ensure that the table is safe to modify. The function + will raise an exception if the table contains view to other table. + """ + def can_unlock(x): + if sp.issparse(x): + return can_unlock(x.data) + return x.flags.writeable or x.flags.owndata or x.size == 0 + + for part, flag, name in self._lock_parts_val(): + if not flag & self._unlocked \ + and (not parts or any(ppart is part for ppart in parts)) \ + and part is not None and not can_unlock(part): + raise ValueError(f"'{name}' is a view into another table " + "and cannot be unlocked") + return contextmanager(self.__unlocked)(*parts) def __new__(cls, *args, **kwargs): def warn_deprecated(method): @@ -426,9 +737,9 @@ def warn_deprecated(method): return cls.from_numpy(domain, *args, **kwargs) - def __init__(self, *args, **kwargs): - # So subclasses can expect to call super without breakage; noop - pass + def __init__(self, *args, **kwargs): # pylint: disable=unused-argument + self._initialize_unlocked() + self._update_locks() @classmethod def from_domain(cls, domain, n_rows=0, weights=False): @@ -448,15 +759,19 @@ def from_domain(cls, domain, n_rows=0, weights=False): self = cls() self.domain = domain self.n_rows = n_rows - self.X = np.zeros((n_rows, len(domain.attributes))) - self.Y = np.zeros((n_rows, len(domain.class_vars))) - if weights: - self.W = np.ones(n_rows) - else: - self.W = np.empty((n_rows, 0)) - self.metas = np.empty((n_rows, len(self.domain.metas)), object) - cls._init_ids(self) - self.attributes = {} + with self.unlocked(): + self.X = np.zeros((n_rows, len(domain.attributes))) + if len(domain.class_vars) != 1: + self.Y = np.zeros((n_rows, len(domain.class_vars))) + else: + self.Y = np.zeros(n_rows) + if weights: + self.W = np.ones(n_rows) + else: + self.W = np.empty((n_rows, 0)) + self.metas = np.empty((n_rows, len(self.domain.metas)), object) + cls._init_ids(self) + self.attributes = {} return self @classmethod @@ -478,113 +793,54 @@ def from_table(cls, domain, source, row_indices=...): :return: a new table :rtype: Orange.data.Table """ - - PART = 5000 + if domain is source.domain: + table = cls.from_table_rows(source, row_indices) + # assure resulting domain is the instance passed on input + table.domain = domain + # since sparse flags are not considered when checking for + # domain equality, fix manually. + with table.unlocked_reference(): + table = assure_domain_conversion_sparsity(table, source) + return table new_cache = _thread_local.conversion_cache is None try: if new_cache: - _thread_local.conversion_cache = {} - _thread_local.domain_cache = {} + _thread_local.conversion_cache = IDWeakrefCache({}) + _thread_local.domain_cache = IDWeakrefCache({}) else: - cached = _idcache_restore(_thread_local.conversion_cache, (domain, source)) - if cached is not None: - return cached - if domain is source.domain: - table = cls.from_table_rows(source, row_indices) - # assure resulting domain is the instance passed on input - table.domain = domain - # since sparse flags are not considered when checking for - # domain equality, fix manually. - table = assure_domain_conversion_sparsity(table, source) - return table + try: + return _thread_local.conversion_cache[(domain, source)] + except KeyError: + pass - if row_indices is ...: - n_rows = len(source) - elif isinstance(row_indices, slice): - row_indices_range = range(*row_indices.indices(source.X.shape[0])) - n_rows = len(row_indices_range) - else: - n_rows = len(row_indices) + # avoid boolean indices; also convert to slices if possible + row_indices = _optimize_indices(row_indices, len(source)) self = cls() self.domain = domain - table_conversion = \ - _idcache_restore(_thread_local.domain_cache, (domain, source.domain)) - if table_conversion is None: + try: + table_conversion = \ + _thread_local.domain_cache[(domain, source.domain)] + except KeyError: table_conversion = _FromTableConversion(source.domain, domain) - _idcache_save(_thread_local.domain_cache, (domain, source.domain), - table_conversion) + _thread_local.domain_cache[(domain, source.domain)] = table_conversion # if an array can be a subarray of the input table, this needs to be done # on the whole table, because this avoids needless copies of contents - for array_conv in table_conversion.subarray: - out = array_conv.get_subarray(source, row_indices, n_rows) - setattr(self, array_conv.target, out) - - parts = {} - - for array_conv in table_conversion.columnwise: - if array_conv.is_sparse: - parts[array_conv.target] = [] - else: - # F-order enables faster writing to the array while accessing and - # matrix operations work with same speed (e.g. dot) - parts[array_conv.target] = \ - np.zeros((n_rows, len(array_conv.src_cols)), - order="F", dtype=array_conv.dtype) - - if n_rows <= PART: - for array_conv in table_conversion.columnwise: - out = array_conv.get_columns(source, row_indices, n_rows, - parts[array_conv.target], - ...) - setattr(self, array_conv.target, out) - else: - i_done = 0 - - while i_done < n_rows: - target_indices = slice(i_done, min(n_rows, i_done + PART)) - if row_indices is ...: - source_indices = target_indices - elif isinstance(row_indices, slice): - r = row_indices_range[target_indices] - source_indices = slice(r.start, r.stop, r.step) - else: - source_indices = row_indices[target_indices] - part_rows = min(n_rows, i_done+PART) - i_done - - for array_conv in table_conversion.columnwise: - out = array_conv.get_columns(source, source_indices, part_rows, - parts[array_conv.target], - target_indices) - if array_conv.is_sparse: # dense arrays are populated in-place - parts[array_conv.target].append(out) - - i_done += PART - - # clear cache after a part is done - if new_cache: - _thread_local.conversion_cache = {} - - for array_conv in table_conversion.columnwise: - cparts = parts[array_conv.target] - out = cparts if not array_conv.is_sparse else sp.vstack(cparts) - setattr(self, array_conv.target, out) - - if source.has_weights(): + with self.unlocked_reference(): + self.X, self.Y, self.metas = \ + table_conversion.convert(source, row_indices, + clear_cache_after_part=new_cache) self.W = source.W[row_indices] - else: - self.W = np.empty((n_rows, 0)) - self.name = getattr(source, 'name', '') - if hasattr(source, 'ids'): + self.name = getattr(source, 'name', '') self.ids = source.ids[row_indices] - else: - cls._init_ids(self) - self.attributes = getattr(source, 'attributes', {}) - _idcache_save(_thread_local.conversion_cache, (domain, source), self) + self.attributes = getattr(source, 'attributes', {}) + if new_cache: # only deepcopy attributes for the outermost transformation + self.attributes = deepcopy(self.attributes) + _thread_local.conversion_cache[(domain, source)] = self return self finally: if new_cache: @@ -628,19 +884,23 @@ def from_table_rows(cls, source, row_indices): :return: a new table :rtype: Orange.data.Table """ + is_outermost_transformation = _thread_local.conversion_cache is None self = cls() self.domain = source.domain - self.X = source.X[row_indices] - if self.X.ndim == 1: - self.X = self.X.reshape(-1, len(self.domain.attributes)) - self.Y = source._Y[row_indices] - self.metas = source.metas[row_indices] - if self.metas.ndim == 1: - self.metas = self.metas.reshape(-1, len(self.domain.metas)) - self.W = source.W[row_indices] - self.name = getattr(source, 'name', '') - self.ids = np.array(source.ids[row_indices]) - self.attributes = getattr(source, 'attributes', {}) + with self.unlocked_reference(): + self.X = source.X[row_indices] + if self.X.ndim == 1: + self.X = self.X.reshape(-1, len(self.domain.attributes)) + self.Y = source.Y[row_indices] + self.metas = source.metas[row_indices] + if self.metas.ndim == 1: + self.metas = self.metas.reshape(-1, len(self.domain.metas)) + self.W = source.W[row_indices] + self.name = getattr(source, 'name', '') + self.ids = source.ids[row_indices] + self.attributes = getattr(source, 'attributes', {}) + if is_outermost_transformation: + self.attributes = deepcopy(self.attributes) return self @classmethod @@ -668,30 +928,37 @@ def from_numpy(cls, domain, X, Y=None, metas=None, W=None, metas, = _check_arrays(metas, dtype=object, shape_1=X.shape[0]) ids, = _check_arrays(ids, dtype=int, shape_1=X.shape[0]) - if Y is not None and Y.ndim == 1: - Y = Y.reshape(Y.shape[0], 1) if domain is None: domain = Domain.from_numpy(X, Y, metas) if Y is None: - if sp.issparse(X): + if not domain.class_vars or sp.issparse(X): Y = np.empty((X.shape[0], 0), dtype=np.float64) else: + own_data = X.flags.owndata and X.base is None Y = X[:, len(domain.attributes):] X = X[:, :len(domain.attributes)] + if own_data: + Y = Y.copy() + X = X.copy() if metas is None: metas = np.empty((X.shape[0], 0), object) if W is None or W.size == 0: W = np.empty((X.shape[0], 0)) - else: - W = W.reshape(W.size) + elif W.shape != (W.size, ): + W = W.reshape(W.size).copy() if X.shape[1] != len(domain.attributes): raise ValueError( "Invalid number of variable columns ({} != {})".format( X.shape[1], len(domain.attributes)) ) - if Y.shape[1] != len(domain.class_vars): + if Y.ndim == 1: + if not domain.class_var: + raise ValueError( + "Invalid number of class columns " + f"(1 != {len(domain.class_vars)})") + elif Y.shape[1] != len(domain.class_vars): raise ValueError( "Invalid number of class columns ({} != {})".format( Y.shape[1], len(domain.class_vars)) @@ -706,17 +973,18 @@ def from_numpy(cls, domain, X, Y=None, metas=None, W=None, "Parts of data contain different numbers of rows.") self = cls() - self.domain = domain - self.X = X - self.Y = Y - self.metas = metas - self.W = W - self.n_rows = self.X.shape[0] - if ids is None: - cls._init_ids(self) - else: - self.ids = ids - self.attributes = {} if attributes is None else attributes + with self.unlocked_reference(): + self.domain = domain + self.X = X + self.Y = Y + self.metas = metas + self.W = W + self.n_rows = self.X.shape[0] + if ids is None: + cls._init_ids(self) + else: + self.ids = ids + self.attributes = {} if attributes is None else attributes return self @classmethod @@ -724,28 +992,36 @@ def from_list(cls, domain, rows, weights=None): if weights is not None and len(rows) != len(weights): raise ValueError("mismatching number of instances and weights") self = cls.from_domain(domain, len(rows), weights is not None) - attrs, classes = domain.attributes, domain.class_vars - metas = domain.metas - nattrs, ncls = len(domain.attributes), len(domain.class_vars) - for i, row in enumerate(rows): - if isinstance(row, Instance): - row = row.list - for j, (var, val) in enumerate(zip(attrs, row)): - self.X[i, j] = var.to_val(val) - for j, (var, val) in enumerate(zip(classes, row[nattrs:])): - self._Y[i, j] = var.to_val(val) - for j, (var, val) in enumerate(zip(metas, row[nattrs + ncls:])): - self.metas[i, j] = var.to_val(val) - if weights is not None: - self.W = np.array(weights) - self.attributes = {} + all_vars = domain.variables + domain.metas + nattrs = len(domain.attributes) + nattrscls = len(domain.variables) + with self.unlocked(): + for i, row in enumerate(rows): + if isinstance(row, Instance): + row = row.list + vals = [var.to_val(val) for var, val in zip(all_vars, row)] + if self.X.size: + self.X[i] = vals[:nattrs] + if self.Y.size: + if self._Y.ndim == 1: + self._Y[i] = vals[nattrs] if nattrs < len(vals) else np.nan + else: + self._Y[i] = vals[nattrs:nattrscls] + # for backward compatibility: allow omittine some (or all) metas + if self.metas.size: + self.metas[i, :len(vals) - nattrscls] = vals[nattrscls:] + if weights is not None: + self.W = np.array(weights) + self.attributes = {} return self @classmethod def _init_ids(cls, obj): + length = int(obj.X.shape[0]) with cls._next_instance_lock: - obj.ids = np.array(range(cls._next_instance_id, cls._next_instance_id + obj.X.shape[0])) - cls._next_instance_id += obj.X.shape[0] + nid = cls._next_instance_id + cls._next_instance_id += length + obj.ids = np.arange(nid, nid + length, dtype=int) @classmethod def new_id(cls): @@ -835,11 +1111,6 @@ def from_file(cls, filename, sheet=None): reader.select_sheet(sheet) data = reader.read() - # Readers return plain table. Make sure to cast it to appropriate - # (subclass) type - if cls != data.__class__: - data = cls(data) - # no need to call _init_ids as fuctions from .io already # construct a table with .ids @@ -851,23 +1122,21 @@ def from_url(cls, url): from Orange.data.io import UrlReader reader = UrlReader(url) data = reader.read() - if cls != data.__class__: - data = cls(data) return data # Helper function for __setitem__: # Set the row of table data matrices # noinspection PyProtectedMember def _set_row(self, example, row): + # pylint: disable=protected-access domain = self.domain if isinstance(example, Instance): if example.domain == domain: - if isinstance(example, RowInstance): - self.X[row] = example._x - self._Y[row] = example._y + self.X[row] = example._x + if self._Y.ndim == 1: + self._Y[row] = float(example._y) else: - self.X[row] = example._x - self._Y[row] = example._y + self._Y[row] = np.atleast_1d(example._y) self.metas[row] = example._metas return @@ -881,13 +1150,20 @@ def _set_row(self, example, row): type(self)._next_instance_id += 1 else: - self.X[row] = [var.to_val(val) - for var, val in zip(domain.attributes, example)] - self._Y[row] = [var.to_val(val) - for var, val in - zip(domain.class_vars, - example[len(domain.attributes):])] - self.metas[row] = np.array([var.Unknown for var in domain.metas], + attrs = domain.attributes + if len(example) != len(domain.variables): + raise ValueError("invalid length") + if self._X.size: + self._X[row] = [var.to_val(val) for var, val in zip(attrs, example)] + if self._Y.size: + if self._Y.ndim == 1: + self._Y[row] = domain.class_var.to_val(example[len(attrs)]) + else: + self._Y[row] = [var.to_val(val) + for var, val in zip(domain.class_vars, + example[len(attrs):])] + if self._metas.size: + self.metas[row] = np.array([var.Unknown for var in domain.metas], dtype=object) def _check_all_dense(self): @@ -911,6 +1187,8 @@ def __getitem__(self, key): var = self.domain[col_idx] if 0 <= col_idx < len(self.domain.attributes): val = self.X[row_idx, col_idx] + elif col_idx == len(self.domain.attributes) and self._Y.ndim == 1: + val = self._Y[row_idx] elif col_idx >= len(self.domain.attributes): val = self._Y[row_idx, col_idx - len(self.domain.attributes)] @@ -981,6 +1259,8 @@ def __setitem__(self, key, value): if col_idx >= 0: if col_idx < self.X.shape[1]: self.X[row_idx, col_idx] = val + elif self._Y.ndim == 1 and col_idx == self.X.shape[1]: + self._Y[row_idx] = val else: self._Y[row_idx, col_idx - self.X.shape[1]] = val else: @@ -995,12 +1275,16 @@ def __setitem__(self, key, value): if not attributes: attributes = self.domain.attributes for var, col in zip(attributes, col_indices): + val = var.to_val(value) if 0 <= col < n_attrs: - self.X[row_idx, col] = var.to_val(value) + self.X[row_idx, col] = val elif col >= n_attrs: - self._Y[row_idx, col - n_attrs] = var.to_val(value) + if self._Y.ndim == 1 and col == n_attrs: + self._Y[row_idx] = val + else: + self._Y[row_idx, col - n_attrs] = val else: - self.metas[row_idx, -1 - col] = var.to_val(value) + self.metas[row_idx, -1 - col] = val else: attr_cols = np.fromiter( (col for col in col_indices if 0 <= col < n_attrs), int) @@ -1016,15 +1300,28 @@ def __setitem__(self, key, value): raise TypeError( "Ordinary attributes can only have primitive values") if len(attr_cols): - self.X[row_idx, attr_cols] = value + if self.X.size: + self.X[row_idx, attr_cols] = value if len(class_cols): - self._Y[row_idx, class_cols] = value + if self._Y.size: + if self._Y.ndim == 1 and np.all(class_cols == 0): + if isinstance(value, np.ndarray): + yshape = self._Y[row_idx].shape + if value.shape != yshape: + value = value.reshape(yshape) + self._Y[row_idx] = value + else: + self._Y[row_idx, class_cols] = value if len(meta_cols): - self.metas[row_idx, meta_cols] = value + if self._metas.size: + self.metas[row_idx, meta_cols] = value def __len__(self): return self.X.shape[0] + def __bool__(self): + return bool(self.X.size or self._Y.size or self.metas.size) + def __str__(self): return "[" + ",\n ".join(str(ex) for ex in self) + "]" @@ -1039,7 +1336,7 @@ def __repr__(self): return s @classmethod - def concatenate(cls, tables, axis=0): + def concatenate(cls, tables, axis=0, *, ignore_domains=None): """ Concatenate tables into a new table, either vertically or horizontally. @@ -1053,21 +1350,22 @@ def concatenate(cls, tables, axis=0): appears in multiple dictionaries, the earlier are used. Args: - tables (Table): tables to be joined + tables (list of Table): tables to be joined Returns: table (Table) """ if axis not in (0, 1): raise ValueError("invalid axis") + if ignore_domains is not None and axis != 0: + raise ValueError("'ignore_domains' is incompatible with 'axis=1'") if not tables: raise ValueError('need at least one table to concatenate') if len(tables) == 1: return tables[0].copy() - if axis == 0: - conc = cls._concatenate_vertical(tables) + conc = cls._concatenate_vertical(tables, bool(ignore_domains)) else: conc = cls._concatenate_horizontal(tables) @@ -1082,7 +1380,7 @@ def concatenate(cls, tables, axis=0): return conc @classmethod - def _concatenate_vertical(cls, tables): + def _concatenate_vertical(cls, tables, ignore_domains=False): def vstack(arrs): return [np, sp][any(sp.issparse(arr) for arr in arrs)].vstack(arrs) @@ -1101,7 +1399,8 @@ def collect(attr): return [getattr(arr, attr) for arr in tables] domain = tables[0].domain - if any(table.domain != domain for table in tables): + if not ignore_domains \ + and any(table.domain != domain for table in tables): raise ValueError('concatenated tables must have the same domain') conc = cls.from_numpy( @@ -1156,39 +1455,23 @@ def add_column(self, variable, data, to_metas=None): table (Table): a new table with the additional column """ dom = self.domain - attrs, classes, metas = dom.attributes, dom.class_vars, dom.metas - if to_metas or not variable.is_primitive(): - metas += (variable, ) + attrs, classes, metavars = dom.attributes, dom.class_vars, dom.metas + to_metas = to_metas or not variable.is_primitive() + if to_metas: + metavars += (variable, ) else: attrs += (variable, ) - domain = Domain(attrs, classes, metas) + domain = Domain(attrs, classes, metavars) new_table = self.transform(domain) - new_table.get_column_view(variable)[0][:] = data + with new_table.unlocked(new_table.metas if to_metas else new_table.X): + new_table.set_column(variable, data) return new_table - def is_view(self): - """ - Return `True` if all arrays represent a view referring to another table - """ - return ((not self.X.shape[-1] or self.X.base is not None) and - (not self._Y.shape[-1] or self._Y.base is not None) and - (not self.metas.shape[-1] or self.metas.base is not None) and - (not self._weights.shape[-1] or self.W.base is not None)) - - def is_copy(self): - """ - Return `True` if the table owns its data - """ - return ((not self.X.shape[-1] or self.X.base is None) and - (self._Y.base is None) and - (self.metas.base is None) and - (self.W.base is None)) - def is_sparse(self): """ Return `True` if the table stores data in sparse format """ - return any(sp.issparse(i) for i in [self.X, self.Y, self.metas]) + return any(sp.issparse(i) for i in [self._X, self._Y, self._metas]) def ensure_copy(self): """ @@ -1196,18 +1479,21 @@ def ensure_copy(self): """ def is_view(x): - # Sparse matrices don't have views like numpy arrays. Since indexing on - # them creates copies in constructor we can skip this check here. - return not sp.issparse(x) and x.base is not None + if not sp.issparse(x): + return x.base is not None + else: + return x.data.base is not None - if is_view(self.X): - self.X = self.X.copy() + if is_view(self._X): + self._X = self._X.copy() if is_view(self._Y): self._Y = self._Y.copy() - if is_view(self.metas): - self.metas = self.metas.copy() - if is_view(self.W): - self.W = self.W.copy() + if is_view(self._metas): + self._metas = self._metas.copy() + if is_view(self._W): + self._W = self._W.copy() + if is_view(self.ids): + self.ids = self.ids.copy() def copy(self): """ @@ -1275,26 +1561,58 @@ def has_missing_class(self): """Return `True` if there are any missing class values.""" return bn.anynan(self._Y) - def get_nan_frequency_attribute(self): - if self.X.size == 0: + @staticmethod + def __get_nan_count(data): + if data.size == 0: return 0 - return np.isnan(self.X).sum() / self.X.size + dense = data if not sp.issparse(data) else data.data + return np.isnan(dense).sum() + + @classmethod + def __get_nan_frequency(cls, data): + return cls.__get_nan_count(data) / (np.prod(data.shape) or 1) + + def get_nan_count_attribute(self): + return self.__get_nan_count(self.X) + + def get_nan_count_class(self): + return self.__get_nan_count(self.Y) + + def get_nan_count_metas(self): + if self.metas.dtype != object: + return self.__get_nan_count(self.metas) + + data = self.metas + if sp.issparse(data): + data = data.tocsc() + + count = 0 + for i, attr in enumerate(self.domain.metas): + col = data[:, i] + missing = np.isnan(col.astype(float)) \ + if not isinstance(attr, StringVariable) else data == "" + count += np.sum(missing) + return count + + def get_nan_frequency_attribute(self): + return self.__get_nan_frequency(self.X) def get_nan_frequency_class(self): - if self.Y.size == 0: - return 0 - return np.isnan(self._Y).sum() / self._Y.size + return self.__get_nan_frequency(self.Y) + + def get_nan_frequency_metas(self): + return self.get_nan_count_metas() / (np.prod(self.metas.shape) or 1) def checksum(self, include_metas=True): # TODO: zlib.adler32 does not work for numpy arrays with dtype object # (after pickling and unpickling such arrays, checksum changes) # Why, and should we fix it or remove it? """Return a checksum over X, Y, metas and W.""" - cs = zlib.adler32(np.ascontiguousarray(self.X)) + cs = zlib.adler32(np.ascontiguousarray(self._X)) cs = zlib.adler32(np.ascontiguousarray(self._Y), cs) if include_metas: - cs = zlib.adler32(np.ascontiguousarray(self.metas), cs) - cs = zlib.adler32(np.ascontiguousarray(self.W), cs) + cs = zlib.adler32(np.ascontiguousarray(self._metas), cs) + cs = zlib.adler32(np.ascontiguousarray(self._W), cs) return cs def shuffle(self): @@ -1307,42 +1625,121 @@ def shuffle(self): self._Y = self._Y[ind] self.metas = self.metas[ind] self.W = self.W[ind] + self.ids = self.ids[ind] - def get_column_view(self, index): + @deprecated("Table.get_column (or Table.set_column if you must)") + def get_column_view(self, index: Union[Integral, Variable]) -> np.ndarray: """ - Return a vector - as a view, not a copy - with a column of the table, - and a bool flag telling whether this column is sparse. Note that - vertical slicing of sparse matrices is inefficient. + An obsolete function that was supposed to return a view with a column + of the table, and a bool flag telling whether this column is sparse. + + The function *sometimes* returns a copy. This happens if the variable + is computed or if values of discrete attribute need to be remapped due + to different encoding. + + Note that vertical slicing of sparse matrices is inefficient. :param index: the index of the column :type index: int, str or Orange.data.Variable :return: (one-dimensional numpy array, sparse) """ - - def rx(M): - if sp.issparse(M): - return np.asarray(M.todense())[:, 0], True - else: - return M, False - if isinstance(index, Integral): col_index = index else: col_index = self.domain.index(index) - if col_index >= 0: - if col_index < self.X.shape[1]: - col = rx(self.X[:, col_index]) - else: - col = rx(self._Y[:, col_index - self.X.shape[1]]) - else: - col = rx(self.metas[:, -1 - col_index]) + col = self._get_column_view(col_index) + + sparse = sp.issparse(col) + if sparse: + # `index` below can be integer or a Variable + warnings.warn("get_column_view is returning a dense copy column " + f"{index}") + col = np.asarray(col.todense())[:, 0] if isinstance(index, DiscreteVariable) \ and index.values != self.domain[col_index].values: - col = index.get_mapper_from(self.domain[col_index])(col[0]), col[1] - col[0].flags.writeable = False + col = index.get_mapper_from(self.domain[col_index])(col) + col.flags.writeable = False + warnings.warn("get_column_view is returning a mapped copy of " + f"column {index.name}") + return col, sparse + + def _get_column_view(self, index: Integral) -> np.ndarray: + if index >= 0: + if index < self.X.shape[1]: + return self.X[:, index] + elif self._Y.ndim == 1 and index == self._X.shape[1]: + return self._Y + else: + return self._Y[:, index - self.X.shape[1]] + else: + return self.metas[:, -1 - index] + + def get_column(self, index, copy=False): + """ + Return a column with values of `index`. + + If `index` is an instance of variable that does not exist in the domain + but has `compute_value`, `get_column` calls `compute_value`. Otherwise, + it returns a view into the table unless `copy` is set to `True`. + + Args: + index (int or str or Variable): attribute + copy (bool): if set to True, ensure the result is a copy, not a view + + Returns: + column (np.array): data column + """ + if isinstance(index, Variable) and index not in self.domain: + if index.compute_value is None: + raise ValueError(f"variable {index.name} is not in domain") + return _compute_column(index.compute_value, self) + + mapper = None + if not isinstance(index, Integral): + if isinstance(index, DiscreteVariable) \ + and index.values != self.domain[index].values: + mapper = index.get_mapper_from(self.domain[index]) + index = self.domain.index(index) + + col = self._get_column_view(index) + if sp.issparse(col): + col = col.toarray().reshape(-1) + if col.dtype == object and self.domain[index].is_primitive(): + col = col.astype(np.float64) + if mapper is not None: + col = mapper(col) + if copy and col.base is not None: + col = col.copy() return col + def set_column(self, index: Union[int, str, Variable], data): + """ + Set the values in the given column do `data`. + + This function may be useful, but try avoiding it. + + Table (or the corresponding + part must be unlocked). If variable is discrete, its encoding must + match the variable in the domain. + + Args: + index (int, str, Variable): index of a column + data (object): a single value or 1d array of length len(self) + """ + if not isinstance(index, Integral): + if isinstance(index, DiscreteVariable) \ + and self.domain[index].values != index.values: + raise ValueError(f"cannot set data for variable {index.name} " + "with different encoding") + index = self.domain.index(index) + # Zero-sized arrays cannot be made writeable, yet the below + # assignment would fail despite doing nothing. + if len(self) > 0: + self._get_column_view(index)[:] = data + else: + assert len(self) == len(data) + def _filter_is_defined(self, columns=None, negate=False): # structure of function is obvious; pylint: disable=too-many-branches def _sp_anynan(a): @@ -1356,7 +1753,10 @@ def _sp_anynan(a): if sp.issparse(self._Y): remove += _sp_anynan(self._Y) else: - remove += bn.anynan(self._Y, axis=1) + if self._Y.ndim == 1: + remove += np.isnan(self._Y) + else: + remove += bn.anynan(self._Y, axis=1) if sp.issparse(self.metas): remove += _sp_anynan(self._metas) else: @@ -1369,10 +1769,8 @@ def _sp_anynan(a): else: remove = np.zeros(len(self), dtype=bool) for column in columns: - col, sparse = self.get_column_view(column) - if sparse: - remove += col == 0 - elif self.domain[column].is_primitive(): + col = self.get_column(column) + if self.domain[column].is_primitive(): remove += bn.anynan([col.astype(float)], axis=0) else: remove += col.astype(bool) @@ -1388,7 +1786,10 @@ def _filter_has_class(self, negate=False): retain = (self._Y.indptr[1:] == self._Y.indptr[-1:] + self._Y.shape[1]) else: - retain = bn.anynan(self._Y, axis=1) + if self._Y.ndim == 1: + retain = np.isnan(self._Y) + else: + retain = bn.anynan(self._Y, axis=1) if not negate: retain = np.logical_not(retain) return self.from_table_rows(self, retain) @@ -1396,7 +1797,7 @@ def _filter_has_class(self, negate=False): def _filter_same_value(self, column, value, negate=False): if not isinstance(value, Real): value = self.domain[column].to_val(value) - sel = self.get_column_view(column)[0] == value + sel = self.get_column(column) == value if negate: sel = np.logical_not(sel) return self.from_table_rows(self, sel) @@ -1482,7 +1883,7 @@ def get_col_indices(): raise TypeError("Invalid filter") def col_filter(col_idx): - col = self.get_column_view(col_idx)[0] + col = self.get_column(col_idx) if isinstance(filter, IsDefined): if self.domain[col_idx].is_primitive(): return ~np.isnan(col.astype(float)) @@ -1572,6 +1973,8 @@ def _string_filter_to_indicator(self, filter, col): """ if filter.oper == filter.IsDefined: return col.astype(bool) + if filter.oper == filter.NotIsDefined: + return ~col.astype(bool) col = col.astype(str) fmin = filter.min or "" @@ -1586,12 +1989,21 @@ def _string_filter_to_indicator(self, filter, col): if filter.oper == filter.Contains: return np.fromiter((fmin in e for e in col), dtype=bool) + if filter.oper == filter.NotContain: + return np.fromiter((fmin not in e for e in col), + dtype=bool) if filter.oper == filter.StartsWith: return np.fromiter((e.startswith(fmin) for e in col), dtype=bool) + if filter.oper == filter.NotStartsWith: + return np.fromiter((not e.startswith(fmin) for e in col), + dtype=bool) if filter.oper == filter.EndsWith: return np.fromiter((e.endswith(fmin) for e in col), dtype=bool) + if filter.oper == filter.NotEndsWith: + return np.fromiter((not e.endswith(fmin) for e in col), + dtype=bool) return self._range_filter_to_indicator(filter, col, fmin, fmax) @@ -1619,19 +2031,19 @@ def _range_filter_to_indicator(filter, col, fmin, fmax): def _compute_basic_stats(self, columns=None, include_metas=False, compute_variance=False): - if compute_variance: - raise NotImplementedError("computation of variance is " - "not implemented yet") - W = self.W if self.has_weights() else None + W = self._W if self.has_weights() else None rr = [] stats = [] if not columns: if self.domain.attributes: - rr.append(fast_stats(self.X, W)) + rr.append(fast_stats(self._X, W, + compute_variance=compute_variance)) if self.domain.class_vars: - rr.append(fast_stats(self._Y, W)) + rr.append(fast_stats(self._Y, W, + compute_variance=compute_variance)) if include_metas and self.domain.metas: - rr.append(fast_stats(self.metas, W)) + rr.append(fast_stats(self.metas, W, + compute_variance=compute_variance)) if len(rr): stats = np.vstack(tuple(rr)) else: @@ -1639,11 +2051,18 @@ def _compute_basic_stats(self, columns=None, for column in columns: c = self.domain.index(column) if 0 <= c < nattrs: - S = fast_stats(self.X[:, [c]], W and W[:, [c]]) + S = fast_stats(self._X[:, [c]], W and W[:, [c]], + compute_variance=compute_variance) elif c >= nattrs: - S = fast_stats(self._Y[:, [c - nattrs]], W and W[:, [c - nattrs]]) + if self._Y.ndim == 1 and c == nattrs: + S = fast_stats(self._Y[:, None], W and W[:, None], + compute_variance=compute_variance) + else: + S = fast_stats(self._Y[:, [c - nattrs]], W and W[:, [c - nattrs]], + compute_variance=compute_variance) else: - S = fast_stats(self.metas[:, [-1 - c]], W and W[:, [-1 - c]]) + S = fast_stats(self._metas[:, [-1 - c]], W and W[:, [-1 - c]], + compute_variance=compute_variance) stats.append(S[0]) return stats @@ -1654,8 +2073,10 @@ def _compute_distributions(self, columns=None): columns = [self.domain.index(var) for var in columns] distributions = [] - if sp.issparse(self.X): - self.X = self.X.tocsc() + X = self.X + if sp.issparse(X): + X = X.tocsc() + W = self.W.ravel() if self.has_weights() else None @@ -1663,14 +2084,16 @@ def _compute_distributions(self, columns=None): variable = self.domain[col] # Select the correct data column from X, Y or metas - if 0 <= col < self.X.shape[1]: - x = self.X[:, col] + if 0 <= col < X.shape[1]: + x = X[:, col] elif col < 0: x = self.metas[:, col * (-1) - 1] if np.issubdtype(x.dtype, np.dtype(object)): x = x.astype(float) + elif self._Y.ndim == 1 and col == X.shape[1]: + x = self._Y else: - x = self._Y[:, col - self.X.shape[1]] + x = self._Y[:, col - X.shape[1]] if variable.is_discrete: dist, unknowns = bincount(x, weights=W, max_val=len(variable.values) - 1) @@ -1729,6 +2152,8 @@ def _compute_contingency(self, col_vars=None, row_var=None): row_data = self.X[:, row_indi] elif row_indi < 0: row_data = self.metas[:, -1 - row_indi] + elif self._Y.ndim == 1 and row_indi == n_atts: + row_data = self._Y else: row_data = self._Y[:, row_indi - n_atts] @@ -1774,8 +2199,9 @@ def _compute_contingency(self, col_vars=None, row_var=None): nans_rows[arr_i], nans[arr_i]) else: for col_i, arr_i, var in disc_vars: + col = arr if arr.ndim == 1 else arr[:, arr_i] contingencies[col_i] = contingency( - arr[:, arr_i].astype(float), + col.astype(float), row_data, len(var.values) - 1, n_rows - 1, W) cont_vars = [v for v in vars if v[2].is_continuous] @@ -1842,107 +2268,109 @@ def transpose(cls, table, feature_names_column="", # attributes # - classes and metas to attributes of attributes # - arbitrary meta column to feature names - self.X = table.X.T - if attr_index is not None: - self.X = np.delete(self.X, attr_index, 0) - if feature_names_column: - names = [str(row[feature_names_column]) for row in table] - progress_callback(0.1) - names = get_unique_names_duplicates(names) - progress_callback(0.3) - attributes = [ContinuousVariable(name) for name in names] - else: - places = int(np.ceil(np.log10(n_cols))) if n_cols else 1 - attributes = [ContinuousVariable(f"{feature_name} {i:0{places}}") - for i in range(1, n_cols + 1)] - progress_callback(0.4) - - if old_domain is not None and feature_names_column: - for i, _ in enumerate(attributes): - if attributes[i].name in old_domain: - var = old_domain[attributes[i].name] - attr = ContinuousVariable(var.name) if var.is_continuous \ - else DiscreteVariable(var.name, var.values) - attr.attributes = var.attributes.copy() - attributes[i] = attr - - def set_attributes_of_attributes(_vars, _table): - for i, variable in enumerate(_vars): - if variable.name == feature_names_column: - continue - for j, row in enumerate(_table): - value = variable.repr_val(row) if np.isscalar(row) \ - else row[i] if isinstance(row[i], str) \ - else variable.repr_val(row[i]) - - if value not in MISSING_VALUES: - attributes[j].attributes[variable.name] = value - - set_attributes_of_attributes(table.domain.class_vars, table.Y) - progress_callback(0.5) - set_attributes_of_attributes(table.domain.metas, table.metas) - - # weights - self.W = np.empty((self.n_rows, 0)) - - def get_table_from_attributes_of_attributes(_vars, _dtype=float): - T = np.empty((self.n_rows, len(_vars)), dtype=_dtype) - for i, _attr in enumerate(table_domain_attributes): - for j, _var in enumerate(_vars): - val = str(_attr.attributes.get(_var.name, "")) - if not _var.is_string: - val = np.nan if val in MISSING_VALUES else \ - _var.values.index(val) if \ - _var.is_discrete else float(val) - T[i, j] = val - return T - - # class_vars - attributes of attributes to class - from old domain - class_vars = [] - if old_domain is not None: - class_vars = old_domain.class_vars - self.Y = get_table_from_attributes_of_attributes(class_vars) - - # metas - # - feature names and attributes of attributes to metas - self.metas, metas = np.empty((self.n_rows, 0), dtype=object), [] - if meta_attr_name not in [m.name for m in table.domain.metas] and \ - table_domain_attributes: - self.metas = np.array([[a.name] for a in table_domain_attributes], - dtype=object) - metas.append(StringVariable(meta_attr_name)) - - names = chain.from_iterable(list(attr.attributes) - for attr in table_domain_attributes) - names = sorted(set(names) - {var.name for var in class_vars}) - progress_callback(0.6) - - def guessed_var(i, var_name): - orig_vals = M[:, i] - val_map, vals, var_type = Orange.data.io.guess_data_type(orig_vals) - values, variable = Orange.data.io.sanitize_variable( - val_map, vals, orig_vals, var_type, {}, name=var_name) - M[:, i] = values - return variable - - _metas = [StringVariable(n) for n in names] - if old_domain is not None: - _metas = [m for m in old_domain.metas if m.name != meta_attr_name] - M = get_table_from_attributes_of_attributes(_metas, _dtype=object) - progress_callback(0.7) - if old_domain is None: - _metas = [guessed_var(i, m.name) for i, m in enumerate(_metas)] - if _metas: - self.metas = np.hstack((self.metas, M)) - metas.extend(_metas) - - self.domain = Domain(attributes, class_vars, metas) - progress_callback(0.9) - cls._init_ids(self) - self.attributes = table.attributes.copy() - self.attributes["old_domain"] = table.domain - progress_callback(1) - return self + with self.unlocked_reference(): + self.X = table.X.T + if attr_index is not None: + self.X = np.delete(self.X, attr_index, 0) + if feature_names_column: + names = [str(row[feature_names_column]) for row in table] + progress_callback(0.1) + names = get_unique_names_duplicates(names) + progress_callback(0.3) + attributes = [ContinuousVariable(name) for name in names] + else: + places = int(np.ceil(np.log10(n_cols))) if n_cols else 1 + attributes = [ContinuousVariable(f"{feature_name} {i:0{places}}") + for i in range(1, n_cols + 1)] + progress_callback(0.4) + + if old_domain is not None and feature_names_column: + for i, _ in enumerate(attributes): + if attributes[i].name in old_domain: + var = old_domain[attributes[i].name] + attr = ContinuousVariable(var.name) if var.is_continuous \ + else DiscreteVariable(var.name, var.values) + attr.attributes = var.attributes.copy() + attributes[i] = attr + + def set_attributes_of_attributes(_vars, _table): + for i, variable in enumerate(_vars): + if variable.name == feature_names_column: + continue + for j, row in enumerate(_table): + value = variable.repr_val(row) if np.isscalar(row) \ + else row[i] if isinstance(row[i], str) \ + else variable.repr_val(row[i]) + + if value not in MISSING_VALUES: + attributes[j].attributes[variable.name] = value + + set_attributes_of_attributes(table.domain.class_vars, table.Y) + progress_callback(0.5) + set_attributes_of_attributes(table.domain.metas, table.metas) + + # weights + self.W = np.empty((self.n_rows, 0)) + + def get_table_from_attributes_of_attributes(_vars, _dtype=float): + T = np.empty((self.n_rows, len(_vars)), dtype=_dtype) + for i, _attr in enumerate(table_domain_attributes): + for j, _var in enumerate(_vars): + val = str(_attr.attributes.get(_var.name, "")) + if not _var.is_string: + val = np.nan if val in MISSING_VALUES else \ + _var.values.index(val) if \ + _var.is_discrete else float(val) + T[i, j] = val + return T + + # class_vars - attributes of attributes to class - from old domain + class_vars = [] + if old_domain is not None: + class_vars = old_domain.class_vars + self.Y = get_table_from_attributes_of_attributes(class_vars) + + # metas + # - feature names and attributes of attributes to metas + self.metas, metas = np.empty((self.n_rows, 0), dtype=object), [] + if meta_attr_name not in [m.name for m in table.domain.metas] and \ + table_domain_attributes: + self.metas = np.array([[a.name] for a in table_domain_attributes], + dtype=object) + metas.append(StringVariable(meta_attr_name)) + + names = chain.from_iterable(list(attr.attributes) + for attr in table_domain_attributes) + names = sorted(set(names) - {var.name for var in class_vars}) + progress_callback(0.6) + + def guessed_var(i, var_name): + orig_vals = M[:, i] + val_map, vals, var_type = Orange.data.io.guess_data_type(orig_vals) + values, variable = Orange.data.io.sanitize_variable( + val_map, vals, orig_vals, var_type, {}, name=var_name) + M[:, i] = values + return variable + + _metas = [StringVariable(n) for n in names] + if old_domain is not None: + _metas = [m for m in old_domain.metas if m.name != meta_attr_name] + M = get_table_from_attributes_of_attributes(_metas, _dtype=object) + progress_callback(0.7) + if old_domain is None: + _metas = [guessed_var(i, m.name) for i, m in enumerate(_metas)] + if _metas: + self.metas = np.hstack((self.metas, M)) + metas.extend(_metas) + + self.domain = Domain(attributes, class_vars, metas) + progress_callback(0.9) + cls._init_ids(self) + self.attributes = deepcopy(table.attributes) + self.attributes["old_domain"] = table.domain + self.name = table.name + progress_callback(1) + return self def to_sparse(self, sparse_attributes=True, sparse_class=False, sparse_metas=False): @@ -1978,6 +2406,36 @@ def densify(features): t.ids = self.ids # preserve indices return t + def groupby(self, columns: List[Variable]) -> "OrangeTableGroupBy": + """ + Group Table by variables defined in the columns list. Behaviour is + similar to Pandas groupby. + + Parameters + ---------- + columns + List of variables used to determine the groups + + Returns + ------- + GroupBy object of type OrangeTableGroupBy which holds information about + groups. + """ + return Orange.data.aggregate.OrangeTableGroupBy(self, columns) + + +def _dereferenced(array): + # CSR and CSC matrices are constructed so that array.data is a + # view to a base, which prevents unlocking them. Therefore, if + # sparse matrix doesn't own its data, but its base array is + # referenced only by this matrix, we copy it. This doesn't + # increase memory use, but allows unlocking. + if sp.issparse(array) \ + and array.data.base is not None \ + and sys.getrefcount(array.data.base) == 2: # 2 = 1 real + 1 for arg + array.data = array.data.copy() + return array + def _check_arrays(*arrays, dtype=None, shape_1=None): checked = [] @@ -2006,6 +2464,7 @@ def ninstances(array): if not (sp.isspmatrix_csr(array) or sp.isspmatrix_csc(array)): array = array.tocsr() array.data = np.asarray(array.data) + array = _dereferenced(array) has_inf = _check_inf(array.data) else: if dtype is not None: @@ -2029,16 +2488,26 @@ def _check_inf(array): def _subarray(arr, rows, cols): rows = _optimize_indices(rows, arr.shape[0]) + if arr.ndim == 1: + return arr[rows] cols = _optimize_indices(cols, arr.shape[1]) - return arr[_rxc_ix(rows, cols)] + if isinstance(rows, slice) or isinstance(cols, slice): + return arr[rows, cols] + else: + # rows and columns are independent selectors, + # so they need to be reshaped to produce an open mesh + return arr[np.ix_(rows, cols)] -def _optimize_indices(indices, maxlen): +def _optimize_indices(indices, size): """ - Convert integer indices to slice if possible. It only converts increasing - integer ranges with positive steps and valid starts and ends. - Only convert valid ends so that invalid ranges will still raise - an exception. + Convert boolean indices to integer indices and convert these to a slice + if possible. + + A slice is created from only from indices with positive steps and + valid starts and ends (so that invalid ranges will still raise an + exception. An IndexError is raised if boolean indices do not conform + to input size. Allows numpy to reuse the data array, because it defaults to copying if given indices. @@ -2046,6 +2515,7 @@ def _optimize_indices(indices, maxlen): Parameters ---------- indices : 1D sequence, slice or Ellipsis + size : int """ if isinstance(indices, slice): return indices @@ -2062,59 +2532,65 @@ def _optimize_indices(indices, maxlen): if len(indices) >= 1: indices = np.asarray(indices) - if indices.dtype != bool: - begin = indices[0] - end = indices[-1] - steps = np.diff(indices) if len(indices) > 1 else np.array([1]) - step = steps[0] + if indices.dtype == bool: + if len(indices) == size: + indices = np.nonzero(indices)[0] + else: + # raise an exception that numpy would if boolean indices were used + raise IndexError("boolean indices did not match dimension") - # continuous ranges with constant step and valid start and stop index can be slices - if np.all(steps == step) and step > 0 and begin >= 0 and end < maxlen: - return slice(begin, end + step, step) + if len(indices) >= 1: # conversion from boolean indices could result in an empty array + begin = indices[0] + end = indices[-1] + steps = np.diff(indices) if len(indices) > 1 else np.array([1]) + step = steps[0] + + # continuous ranges with constant step and valid start and stop index can be slices + if np.all(steps == step) and step > 0 and begin >= 0 and end < size: + return slice(begin, end + step, step) return indices -def _rxc_ix(rows, cols): +def _selection_length(indices, maxlen): + """ Return the selection length. + Args: + indices: 1D sequence, slice or Ellipsis + maxlen: maximum length of the sequence """ - Construct an index object to index the `rows` x `cols` cross product. - - Rows and columns can be a 1d bool or int sequence, or a slice. - The later is a convenience and is interpreted the same - as `slice(None, None, -1)` + if indices is ...: + return maxlen + elif isinstance(indices, slice): + return len(range(*indices.indices(maxlen))) + else: + return len(indices) - Parameters - ---------- - rows : 1D sequence, slice - Row indices. - cols : 1D sequence, slice - Column indices. - - See Also - -------- - numpy.ix_ - - Examples - -------- - >>> import numpy as np - >>> a = np.arange(10).reshape(2, 5) - >>> a[_rxc_ix([0, 1], [3, 4])] - array([[3, 4], - [8, 9]]) - >>> a[_rxc_ix([False, True], slice(None, None, 1))] - array([[5, 6, 7, 8, 9]]) +def _select_from_selection(source_indices, selection_indices, maxlen): + """ + Create efficient selection indices from a previous selection. + Try to keep slices as slices. + Args: + source_indices: 1D sequence, slice or Ellipsis + selection_indices: slice + maxlen: maximum length of the sequence """ - isslice = (isinstance(rows, slice), isinstance(cols, slice)) - if isslice == (True, True): - return rows, cols - elif isslice == (True, False): - return rows, np.asarray(np.ix_(cols), int).ravel() - elif isslice == (False, True): - return np.asarray(np.ix_(rows), int).ravel(), cols + if source_indices is ...: + return selection_indices + elif isinstance(source_indices, slice): + assert isinstance(selection_indices, slice) + r = range(*source_indices.indices(maxlen))[selection_indices] + assert min(list(r)) >= 0 + # .indices always returns valid non-negative integers + # when the reversed order is used r.stop can be negative, for example, + # range(1, -1, -1)), which is [1, 0], but this negative indexing + # is problematic with slices + stop = r.stop + if stop < 0: + stop = None + return slice(r.start, stop, r.step) else: - r, c = np.ix_(rows, cols) - return np.asarray(r, int), np.asarray(c, int) + return source_indices[selection_indices] def assure_domain_conversion_sparsity(target, source): diff --git a/Orange/data/tests/test_aggregate.py b/Orange/data/tests/test_aggregate.py new file mode 100644 index 00000000000..7ebaa6a85dd --- /dev/null +++ b/Orange/data/tests/test_aggregate.py @@ -0,0 +1,201 @@ +import unittest +from unittest.mock import Mock + +import numpy as np +import pandas as pd + +from Orange.data import ( + DiscreteVariable, + ContinuousVariable, + Domain, + StringVariable, + Table, + table_to_frame, +) + + +def create_sample_data(): + domain = Domain( + [ + ContinuousVariable("a"), + ContinuousVariable("b"), + ContinuousVariable("cvar"), + DiscreteVariable("dvar", values=["val1", "val2"]), + ], + metas=[StringVariable("svar")], + ) + return Table.from_numpy( + domain, + np.array( + [ + [1, 1, 0.1, 0], + [1, 1, 0.2, 1], + [1, 2, np.nan, np.nan], + [1, 2, 0.3, 1], + [1, 3, 0.3, 0], + [1, 3, 0.4, 1], + [1, 3, 0.6, 0], + [2, 1, 1.0, 1], + [2, 1, 2.0, 0], + [2, 2, 3.0, 1], + [2, 2, -4.0, 0], + [2, 3, 5.0, 1], + [2, 3, 5.0, 0], + ] + ), + metas=np.array( + [ + ["sval1"], + ["sval2"], + [""], + ["sval2"], + ["sval1"], + ["sval2"], + ["sval1"], + ["sval2"], + ["sval1"], + ["sval2"], + ["sval1"], + ["sval2"], + ["sval1"], + ] + ), + ) + + +# pylint: disable=abstract-method +class AlternativeTable(Table): + pass + + +class DomainTest(unittest.TestCase): + def setUp(self) -> None: + self.data = create_sample_data() + + def test_simple_aggregation(self): + """Test aggregation results""" + d = self.data.domain + gb = self.data.groupby([d["a"]]) + output = gb.aggregate({d["a"]: ["mean"], d["b"]: ["mean"]}) + + np.testing.assert_array_almost_equal(output.X, [[1, 2.143], [2, 2]], decimal=3) + np.testing.assert_array_almost_equal(output.metas, [[1], [2]], decimal=3) + self.assertListEqual( + ["a - mean", "b - mean"], [d.name for d in output.domain.attributes] + ) + self.assertListEqual(["a"], [d.name for d in output.domain.metas]) + + def test_aggregation(self): + d = self.data.domain + gb = self.data.groupby([self.data.domain["a"], self.data.domain["b"]]) + output = gb.aggregate( + { + d["cvar"]: [("Mean", "mean"), ("Median", "median"), ("Mean1", np.mean)], + d["dvar"]: [("Count defined", "count"), ("Count", "size")], + d["svar"]: [("Concatenate", "".join)], + } + ) + + expected_columns = [ + "cvar - Mean", + "cvar - Median", + "cvar - Mean1", + "dvar - Count defined", + "dvar - Count", + "svar - Concatenate", + "a", # groupby variables are last two in metas + "b", + ] + + exp_df = pd.DataFrame( + [ + [0.15, 0.15, 0.15, 2, 2, "sval1sval2", 1, 1], + [0.3, 0.3, 0.3, 1, 2, "sval2", 1, 2], + [0.433, 0.4, 0.433, 3, 3, "sval1sval2sval1", 1, 3], + [1.5, 1.5, 1.5, 2, 2, "sval2sval1", 2, 1], + [-0.5, -0.5, -0.5, 2, 2, "sval2sval1", 2, 2], + [5, 5, 5, 2, 2, "sval2sval1", 2, 3], + ], + columns=expected_columns, + ) + + out_df = table_to_frame(output, include_metas=True) + + pd.testing.assert_frame_equal( + out_df, + exp_df, + check_dtype=False, + check_column_type=False, + check_categorical=False, + atol=1e-3, + ) + + def test_preserve_table_class(self): + """ + Test whether result table has the same type than the imnput table, + e.g. if input table corpus the resulting table must be corpus too. + """ + data = AlternativeTable.from_table(self.data.domain, self.data) + gb = data.groupby([data.domain["a"]]) + output = gb.aggregate({data.domain["a"]: ["mean"]}) + self.assertIsInstance(output, AlternativeTable) + + def test_preserve_variables(self): + a, _, _, dvar = self.data.domain.attributes + gb = self.data.groupby([a]) + + a.attributes = {"foo": "bar"} + dvar.attributes = {"foo": "baz"} + + a.copy = Mock(side_effect=a.copy) + a.make = Mock(side_effect=a.make) + + def f(*_): + return 0 + + output = gb.aggregate( + {a: [("copy", f, True), + ("make", f, False), + ("auto", f, None), + ("string", f, StringVariable), + ("number", f, ContinuousVariable)], + dvar: [("copy", f, True), + ("make", f, False), + ("auto", f, None), + ("string", f, StringVariable), + ("discrete", f, DiscreteVariable)]} + ) + self.assertIsInstance(output.domain["a - copy"], ContinuousVariable) + a.copy.assert_called_once() + self.assertEqual(output.domain["a - copy"].attributes, {"foo": "bar"}) + + self.assertIsInstance(output.domain["a - make"], ContinuousVariable) + a.make.assert_called_once() + self.assertNotEqual(output.domain["a - make"].attributes, {"foo": "bar"}) + + self.assertIsInstance(output.domain["a - auto"], ContinuousVariable) + self.assertNotEqual(output.domain["a - auto"].attributes, {"foo": "bar"}) + + self.assertIsInstance(output.domain["a - string"], StringVariable) + + self.assertIsInstance(output.domain["a - number"], ContinuousVariable) + self.assertNotEqual(output.domain["a - number"].attributes, {"foo": "bar"}) + + self.assertIsInstance(output.domain["dvar - copy"], DiscreteVariable) + self.assertEqual(output.domain["dvar - copy"].attributes, {"foo": "baz"}) + + self.assertIsInstance(output.domain["dvar - make"], DiscreteVariable) + self.assertNotEqual(output.domain["dvar - make"].attributes, {"foo": "baz"}) + + # f returns 0, so the column looks numeric! Let's test that it is + # converted to numeric. + self.assertIsInstance(output.domain["dvar - auto"], ContinuousVariable) + + self.assertIsInstance(output.domain["dvar - string"], StringVariable) + + self.assertIsInstance(output.domain["dvar - discrete"], DiscreteVariable) + self.assertNotEqual(output.domain["dvar - discrete"].attributes, {"foo": "baz"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/data/tests/test_io.py b/Orange/data/tests/test_io.py index a4beac61ac6..01187f26b30 100644 --- a/Orange/data/tests/test_io.py +++ b/Orange/data/tests/test_io.py @@ -1,8 +1,12 @@ +import os import unittest +from tempfile import NamedTemporaryFile + import numpy as np from Orange.data import ContinuousVariable, DiscreteVariable, StringVariable, \ - TimeVariable + TimeVariable, Domain, Table +from Orange.data.io import TabReader, ExcelReader from Orange.data.io_util import guess_data_type from Orange.misc.collections import natural_sorted @@ -108,6 +112,49 @@ def test_guess_data_type_values_order(self): self.assertEqual(DiscreteVariable, coltype) self.assertListEqual(res, valuemap) +class TestWriters(unittest.TestCase): + def setUp(self): + self.domain = Domain([DiscreteVariable("a", values=tuple("xyz")), + ContinuousVariable("b", number_of_decimals=3)], + ContinuousVariable("c", number_of_decimals=0), + [StringVariable("d")]) + self.data = Table.from_numpy( + self.domain, + np.array([[1, 0.5], [2, np.nan], [np.nan, 1.0625]]), + np.array([3, 1, 7]), + np.array([["foo", "bar", np.nan]], dtype=object).T + ) + + def test_write_tab(self): + with NamedTemporaryFile(suffix=".tab", delete=False) as f: + fname = f.name + try: + TabReader.write(fname, self.data) + with open(fname, encoding="utf-8") as f: + self.assertEqual(f.read().strip(), """ +c\td\ta\tb +continuous\tstring\tx y z\tcontinuous +class\tmeta\t\t +3\tfoo\ty\t0.500 +1\tbar\tz\t +7\t\t\t1.06250""".strip()) + finally: + os.remove(fname) + + def test_roundtrip_xlsx(self): + with NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + fname = f.name + try: + ExcelReader.write(fname, self.data) + data = ExcelReader(fname).read() + np.testing.assert_equal(data.X, self.data.X) + np.testing.assert_equal(data.Y, self.data.Y) + np.testing.assert_equal(data.metas[:2], self.data.metas[:2]) + self.assertEqual(data.metas[2, 0], "") + np.testing.assert_equal(data.domain, self.data.domain) + finally: + os.remove(fname) + if __name__ == "__main__": unittest.main() diff --git a/Orange/data/tests/test_io_base.py b/Orange/data/tests/test_io_base.py index 737e353deab..f0912051deb 100644 --- a/Orange/data/tests/test_io_base.py +++ b/Orange/data/tests/test_io_base.py @@ -23,6 +23,13 @@ def setUpClass(cls): ["red", "0.5", "0.0", "0.0", "aa", "a"], ["red", "0.1", "1.0", "1.0", "b", "b"], ["green", "0.0", "2.0", "2.0", "c", "c"]] + cls.header1_flags2 = [["D#a1", "D#a2", "cD#a3", "C#a4", "S#a5", + "mS#a6", "T#a7", "mT#a8", "T#a9"], + ["", "0", "", "0", "a", "a", + "2024-01-01", "2024-01-01", ""], + ["", "1", "", "1", "b", "b", + "2024-01-01", "2024-01-01", ""], + ["green", "0.0", "2.0", "2.0", "c", "c"]] cls.header3 = [["a", "b", "c", "d", "w", "e", "f", "g"], ["d", "c", "c", "c", "c", "d", "s", "yes no"], ["meta", "class", "meta", "", "weight", "i", "", ""], @@ -53,6 +60,28 @@ def test_get_header_data_1_flags(self): self.assertListEqual(types, ["", "c", "", "", "", ""]) self.assertListEqual(flags, ["m", "c", "m", "", "i", ""]) + def test_get_header_data_1_flags2(self): + names, types, flags = _TableHeader.create_header_data( + self.header1_flags2[:1]) + names_ = ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9"] + types_ = ["d", "d", "d", "c", "s", "s", "t", "t", "t"] + flags_ = ["", "", "c", "", "", "m", "", "m", ""] + self.assertListEqual(names, names_) + self.assertListEqual(types, types_) + self.assertListEqual(flags, flags_) + + def test_get_header_data_1_hashes(self): + names, types, flags = _TableHeader.create_header_data( + [["Some long text#and here", "vd#Invalid spec", "C#Valid spec", + "m#Meta", "cD#Discrete class", "Si#Ignored string"]]) + names_ = ["Some long text#and here", "vd#Invalid spec", "Valid spec", + "Meta", "Discrete class", "Ignored string"] + types_ = ["", "", "c", "", "d", "s"] + flags_ = ["", "", "", "m", "c", "i"] + self.assertListEqual(names, names_) + self.assertListEqual(types, types_) + self.assertListEqual(flags, flags_) + def test_get_header_data_3(self): names, types, flags = _TableHeader.create_header_data(self.header3[:3]) self.assertListEqual(names, ["a", "b", "c", "d", "w", "e", "f", "g"]) diff --git a/Orange/data/tests/test_io_util.py b/Orange/data/tests/test_io_util.py index 683132da8c5..ad38fdf1033 100644 --- a/Orange/data/tests/test_io_util.py +++ b/Orange/data/tests/test_io_util.py @@ -1,6 +1,20 @@ +import os.path import unittest +from datetime import datetime +from tempfile import TemporaryDirectory -from Orange.data import ContinuousVariable, guess_data_type +import numpy as np +from numpy.testing import assert_array_equal + +from Orange.data import ( + ContinuousVariable, + guess_data_type, + Table, + Domain, + StringVariable, + DiscreteVariable, +) +from Orange.data.io_util import update_origin, array_strptime, to_datetime class TestIoUtil(unittest.TestCase): @@ -9,6 +23,161 @@ def test_guess_continuous_w_nans(self): guess_data_type(["9", "", "98", "?", "98", "98", "98"])[2], ContinuousVariable) + def test_array_strptime(self): + a = array_strptime(["NaT", "2015"], "%Y").astype(object) + assert_array_equal(a, [None, datetime(2015, 1, 1)]) + a = array_strptime(["nan", "1402-04-06"], "%Y-%d-%m").astype(object) + assert_array_equal(a, [None, datetime(1402, 6, 4)]) + with self.assertRaises(ValueError): + array_strptime(["this is not a date"], "%Y") + assert_array_equal( + array_strptime(["this is not a date"], "%Y", errors="coerce").astype(object), + [None] + ) + + def test_to_datetime(self): + def assert_equal(a, b): + # convert to array of datetime objects + a = a.astype(object) + assert_array_equal(a, b) + assert_equal( + to_datetime(["1/1/2020", "2/1/2020"]), + [datetime(2020, 1, 1), datetime(2020, 2, 1)] + ) + + assert_equal( + to_datetime(["NaT", "1/1/2020", "2/1/2020"]), + [None, datetime(2020, 1, 1), datetime(2020, 2, 1)] + ) + + assert_equal( + to_datetime(["1/1/2020", "2/1/2020", "1/1/1400"]), + [datetime(2020, 1, 1), datetime(2020, 2, 1), datetime(1400, 1, 1)] + ) + + assert_equal( + to_datetime(["1|12|7000"], format="%d|%m|%Y"), + [datetime(7000, 12, 1)] + ) + + with self.assertRaises(ValueError): + to_datetime(["1012l0awd7"], format="%d|%m|%Y", errors="raise"), + + assert_equal( + to_datetime(["1012l0awd7"], format="%d|%m|%Y", errors="coerce"), + [None] + ) + + +class TestUpdateOrigin(unittest.TestCase): + FILE_NAMES = ["file1.txt", "file2.txt", "file3.txt"] + + def setUp(self) -> None: + self.alt_dir = TemporaryDirectory() # pylint: disable=consider-using-with + + self.var_string = var = StringVariable("Files") + files = self.FILE_NAMES + [var.Unknown] + self.table_string = Table.from_list( + Domain([], metas=[var]), np.array(files).reshape((-1, 1)) + ) + self.var_discrete = var = DiscreteVariable("Files", values=self.FILE_NAMES) + files = self.FILE_NAMES + [var.Unknown] + self.table_discrete = Table.from_list( + Domain([], metas=[var]), np.array(files).reshape((-1, 1)) + ) + + def tearDown(self) -> None: + self.alt_dir.cleanup() + + def __create_files(self): + for f in self.FILE_NAMES: + f = os.path.join(self.alt_dir.name, f) + with open(f, "w", encoding="utf8"): + pass + self.assertTrue(os.path.exists(f)) + + def test_origin_not_changed(self): + """ + Origin exist; keep it unchanged, even though dataset path also includes + files from column. + """ + with TemporaryDirectory() as dir_name: + self.var_string.attributes["origin"] = dir_name + update_origin(self.table_string, self.alt_dir.name) + self.assertEqual( + self.table_string.domain[self.var_string].attributes["origin"], dir_name + ) + + def test_origin_subdir(self): + """ + Origin is wrong but last dir in origin exit in the dataset file's path + """ + images_dir = os.path.join(self.alt_dir.name, "subdir") + os.mkdir(images_dir) + + self.var_string.attributes["origin"] = "/a/b/subdir" + update_origin(self.table_string, os.path.join(self.alt_dir.name, "data.csv")) + self.assertEqual( + self.table_string.domain[self.var_string].attributes["origin"], images_dir + ) + + def test_origin_parents_subdir(self): + """ + Origin is wrong but last dir in origin exit in the dataset file + parent's directory + """ + # make the dir where dataset is placed + images_dir = os.path.join(self.alt_dir.name, "subdir") + os.mkdir(images_dir) + + self.var_string.attributes["origin"] = "/a/b/subdir" + update_origin(self.table_string, os.path.join(images_dir, "data.csv")) + self.assertEqual( + self.table_string.domain[self.var_string].attributes["origin"], images_dir + ) + + def test_column_paths_subdir(self): + """ + Origin dir not exiting but paths from column exist in dataset's dir + """ + self.__create_files() + + self.var_string.attributes["origin"] = "/a/b/non-exiting-dir" + update_origin(self.table_string, os.path.join(self.alt_dir.name, "data.csv")) + self.assertEqual( + self.table_string.domain[self.var_string].attributes["origin"], + self.alt_dir.name, + ) + + self.var_discrete.attributes["origin"] = "/a/b/non-exiting-dir" + update_origin(self.table_discrete, os.path.join(self.alt_dir.name, "data.csv")) + self.assertEqual( + self.table_discrete.domain[self.var_discrete].attributes["origin"], + self.alt_dir.name, + ) + + def test_column_paths_parents_subdir(self): + """ + Origin dir not exiting but paths from column exist in dataset parent's dir + """ + # make the dir where dataset is placed + dataset_dir = os.path.join(self.alt_dir.name, "subdir") + self.__create_files() + + self.var_string.attributes["origin"] = "/a/b/non-exiting-dir" + update_origin(self.table_string, os.path.join(dataset_dir, "data.csv")) + self.assertEqual( + self.table_string.domain[self.var_string].attributes["origin"], + self.alt_dir.name, + ) + + self.var_discrete.attributes["origin"] = "/a/b/non-exiting-dir" + update_origin(self.table_discrete, os.path.join(dataset_dir, "data.csv")) + self.assertEqual( + self.table_discrete.domain[self.var_discrete].attributes["origin"], + self.alt_dir.name, + ) + if __name__ == '__main__': unittest.main() diff --git a/Orange/data/tests/test_pandas.py b/Orange/data/tests/test_pandas_compat.py similarity index 54% rename from Orange/data/tests/test_pandas.py rename to Orange/data/tests/test_pandas_compat.py index 2bf394ae085..9e625e993e4 100644 --- a/Orange/data/tests/test_pandas.py +++ b/Orange/data/tests/test_pandas_compat.py @@ -1,25 +1,30 @@ # pylint: disable=import-outside-toplevel - import unittest +from datetime import date, datetime, timezone, timedelta +from unittest import skipIf + import numpy as np +import pandas as pd from scipy.sparse import csr_matrix import scipy.sparse as sp from Orange.data import ContinuousVariable, DiscreteVariable, TimeVariable, Table, Domain, \ StringVariable -from Orange.data.pandas_compat import OrangeDataFrame - -try: - import pandas as pd -except ImportError: - pd = None +from Orange.data.pandas_compat import OrangeDataFrame, table_from_frame +from Orange.data.tests.test_variable import TestTimeVariable +if pd.__version__ < "2": + import pytz +else: + pytz = None -@unittest.skipIf(pd is None, "Missing package 'pandas'") class TestPandasCompat(unittest.TestCase): - def test_table_from_frame(self): - from Orange.data.pandas_compat import table_from_frame + def test_patch_for_to_dense(self): + if pd.__version__ >= "3" and "dev" not in pd.__version__: + self.fail("Try removing the patch for to_dense in pandas_compat.\n" + "If successful, remove this test.") + def test_table_from_frame(self): nan = np.nan df = pd.DataFrame([['a', 1, pd.Timestamp('2017-12-19')], ['b', 0, pd.Timestamp('1724-12-20')], @@ -31,10 +36,9 @@ def test_table_from_frame(self): [0, pd.Timestamp('1724-12-20').timestamp()], [0, pd.Timestamp('1724-12-20').timestamp()], [nan, nan]]) - np.testing.assert_equal(table.metas.tolist(), [['a'], - ['b'], - ['c'], - [nan]]) + np.testing.assert_equal( + table.metas.tolist(), [["a"], ["b"], ["c"], [StringVariable.Unknown]] + ) names = [var.name for var in table.domain.attributes] types = [type(var) for var in table.domain.attributes] self.assertEqual(names, ['1', '2']) @@ -52,6 +56,24 @@ def test_table_from_frame(self): self.assertEqual(names, ['0', '1', '2']) self.assertEqual(types, [DiscreteVariable, ContinuousVariable, TimeVariable]) + # Specify (some) variables + dvar = DiscreteVariable('x', values=tuple("dacb")) + cvar = ContinuousVariable('y') + table = table_from_frame(df, variables=[dvar, cvar, None]) + self.assertIs(table.domain[0], dvar) + self.assertIs(table.domain[1], cvar) + self.assertIsInstance(table.domain[2], TimeVariable) + + table = table_from_frame(df, + variables=[None, None, None], + force_nominal=True) + self.assertIsInstance(table.domain[0], DiscreteVariable) + self.assertIsInstance(table.domain[1], ContinuousVariable) + self.assertIsInstance(table.domain[2], TimeVariable) + + self.assertRaises(AssertionError, + table_from_frame, df, variables=[None, None]) + # Include index df.index = list('abaa') table = table_from_frame(df) @@ -60,10 +82,9 @@ def test_table_from_frame(self): [1, 0, pd.Timestamp('1724-12-20').timestamp()], [0, 0, pd.Timestamp('1724-12-20').timestamp()], [0, nan, nan]]) - np.testing.assert_equal(table.metas.tolist(), [['a'], - ['b'], - ['c'], - [nan]]) + np.testing.assert_equal( + table.metas.tolist(), [["a"], ["b"], ["c"], [StringVariable.Unknown]] + ) names = [var.name for var in table.domain.attributes] types = [type(var) for var in table.domain.attributes] self.assertEqual(names, ['index', '1', '2']) @@ -71,7 +92,6 @@ def test_table_from_frame(self): def test_table_from_frame_keep_ids(self): """ Test if indices are correctly transferred to Table""" - from Orange.data.pandas_compat import table_from_frame df = OrangeDataFrame(Table('iris')[:6]) df.index = [1, "_oa", "_o", "1", "_o20", "_o30"] table = table_from_frame(df) @@ -164,6 +184,285 @@ def test_not_orangedf(self): for v1, v2 in zip(vars1, vars2): self.assertEqual(type(v1), type(v2)) + def test_table_from_frame_date(self): + df = pd.DataFrame( + [[pd.Timestamp("2017-12-19")], [pd.Timestamp("1724-12-20")], [np.nan]] + ) + table = table_from_frame(df) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19").timestamp()], + [pd.Timestamp("1724-12-20").timestamp()], + [np.nan], + ], + ) + self.assertEqual(table.domain.variables[0].have_time, 0) + self.assertEqual(table.domain.variables[0].have_date, 1) + + df = pd.DataFrame([["2017-12-19"], ["1724-12-20"], [np.nan]]) + table = table_from_frame(df) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19").timestamp()], + [pd.Timestamp("1724-12-20").timestamp()], + [np.nan], + ], + ) + self.assertEqual(table.domain.variables[0].have_time, 0) + self.assertEqual(table.domain.variables[0].have_date, 1) + + df = pd.DataFrame([[date(2017, 12, 19)], [date(1724, 12, 20)], [np.nan]]) + table = table_from_frame(df) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19").timestamp()], + [pd.Timestamp("1724-12-20").timestamp()], + [np.nan], + ], + ) + self.assertEqual(table.domain.variables[0].have_time, 0) + self.assertEqual(table.domain.variables[0].have_date, 1) + + @skipIf( + pd.__version__.split(".")[:2] == ["2", "0"], + "Skipping because of pandas issue in version 2.0.*", + ) + # https://github.com/pandas-dev/pandas/issues/53134#issuecomment-1546011517 + def test_table_from_frame_time(self): + df = pd.DataFrame( + [[pd.Timestamp("00:00:00.25")], [pd.Timestamp("20:20:20.30")], [np.nan]] + ) + table = table_from_frame(df) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("1970-01-01 00:00:00.25").timestamp()], + [pd.Timestamp("1970-01-01 20:20:20.30").timestamp()], + [np.nan], + ], + ) + self.assertEqual(table.domain.variables[0].have_time, 1) + self.assertEqual(table.domain.variables[0].have_date, 0) + + df = pd.DataFrame([["00:00:00.25"], ["20:20:20.30"], [np.nan]]) + table = table_from_frame(df) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("1970-01-01 00:00:00.25").timestamp()], + [pd.Timestamp("1970-01-01 20:20:20.30").timestamp()], + [np.nan], + ], + ) + self.assertEqual(table.domain.variables[0].have_time, 1) + self.assertEqual(table.domain.variables[0].have_date, 0) + + def test_table_from_frame_datetime(self): + df = pd.DataFrame( + [ + [pd.Timestamp("2017-12-19 00:00:00.50")], + [pd.Timestamp("1724-12-20 20:20:20.30")], + [np.nan], + ] + ) + table = table_from_frame(df) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19 00:00:00.50").timestamp()], + [pd.Timestamp("1724-12-20 20:20:20.30").timestamp()], + [np.nan], + ], + ) + self.assertEqual(table.domain.variables[0].have_time, 1) + self.assertEqual(table.domain.variables[0].have_date, 1) + + df = pd.DataFrame( + [["2017-12-19 00:00:00.50"], ["1724-12-20 20:20:20.30"], [np.nan]] + ) + table = table_from_frame(df) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19 00:00:00.50").timestamp()], + [pd.Timestamp("1724-12-20 20:20:20.30").timestamp()], + [np.nan], + ], + ) + self.assertEqual(table.domain.variables[0].have_time, 1) + self.assertEqual(table.domain.variables[0].have_date, 1) + + df = pd.DataFrame( + [ + [datetime(2017, 12, 19, 0, 0, 0, 500000)], + [datetime(1724, 12, 20, 20, 20, 20, 300000)], + [np.nan], + ] + ) + table = table_from_frame(df) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19 00:00:00.50").timestamp()], + [pd.Timestamp("1724-12-20 20:20:20.30").timestamp()], + [np.nan], + ], + ) + self.assertEqual(table.domain.variables[0].have_time, 1) + self.assertEqual(table.domain.variables[0].have_date, 1) + + def test_table_from_frame_timezones(self): + df = pd.DataFrame( + [ + [pd.Timestamp("2017-12-19 00:00:00")], + [pd.Timestamp("1724-12-20 20:20:20")], + [np.nan], + ] + ) + table = table_from_frame(df) + self.assertEqual(table.domain.variables[0].timezone, timezone.utc) + + df = pd.DataFrame( + [ + [pd.Timestamp("2017-12-19 00:00:00Z")], + [pd.Timestamp("1724-12-20 20:20:20Z")], + [np.nan], + ] + ) + table = table_from_frame(df) + tz = pytz.utc if pytz is not None else timezone.utc + self.assertEqual(tz, table.domain.variables[0].timezone) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19 00:00:00").timestamp()], + [pd.Timestamp("1724-12-20 20:20:20").timestamp()], + [np.nan], + ], + ) + + df = pd.DataFrame( + [ + [pd.Timestamp("2017-12-19 00:00:00+1")], + [pd.Timestamp("1724-12-20 20:20:20+1")], + [np.nan], + ] + ) + table = table_from_frame(df) + tz = pytz.FixedOffset(60) if pytz is not None else \ + timezone(timedelta(seconds=3600)) + self.assertEqual(tz, table.domain.variables[0].timezone) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19 00:00:00+1").timestamp()], + [pd.Timestamp("1724-12-20 20:20:20+1").timestamp()], + [np.nan], + ], + ) + + df = pd.DataFrame( + [ + [pd.Timestamp("2017-12-19 00:00:00", tz="CET")], + [pd.Timestamp("1724-12-20 20:20:20", tz="CET")], + [np.nan], + ] + ) + + df = pd.DataFrame( + [ + [pd.Timestamp("2017-12-19 00:00:00", tz="CET")], + [pd.Timestamp("1724-12-20 20:20:20")], + [np.nan], + ] + ) + table = table_from_frame(df) + tz = pytz.utc if pytz is not None else timezone.utc + self.assertEqual(tz, table.domain.variables[0].timezone) + np.testing.assert_equal( + table.X, + [ + [pd.Timestamp("2017-12-19 00:00:00+1").timestamp()], + [pd.Timestamp("1724-12-20 20:20:20").timestamp()], + [np.nan], + ], + ) + + def test_table_from_frame_no_datetime(self): + """ + In case when dtype of column is object and column contains numbers only, + column could be recognized as a TimeVarialbe since pd.to_datetime can parse + numbers as datetime. That column must be result either in StringVariable + or DiscreteVariable since it's dtype is object. + """ + df = pd.DataFrame([[1], [2], [3]], dtype="object") + table = table_from_frame(df) + # check if exactly ContinuousVariable and not subtype TimeVariable + self.assertIsInstance(table.domain.metas[0], StringVariable) + + df = pd.DataFrame([[1], [2], [2]], dtype="object") + table = table_from_frame(df) + # check if exactly ContinuousVariable and not subtype TimeVariable + self.assertIsInstance(table.domain.attributes[0], DiscreteVariable) + + def testa_table_from_frame_string(self): + """ + Test if string-like variables are handled correctly and nans are replaced + with String.Unknown + """ + # s1 contains nan and s2 contains pd.Na + df = pd.DataFrame( + [["a", "b"], ["c", "d"], ["e", "f"], [5, "c"], [np.nan, np.nan]], + columns=["s1", "s2"], + ).astype({"s1": "object", "s2": "string"}) + table = table_from_frame(df) + np.testing.assert_array_equal(np.empty((5, 0)), table.X) + np.testing.assert_array_equal( + np.array( + [ + ["a", "b"], + ["c", "d"], + ["e", "f"], + ["5", "c"], + [StringVariable.Unknown, StringVariable.Unknown], + ] + ), + table.metas, + ) + self.assertTrue(all(isinstance(v, StringVariable) for v in table.domain.metas)) + + @skipIf( + pd.__version__.split(".")[:2] == ["2", "0"], + "Skipping because of pandas issue in version 2.0.*", + ) + # https://github.com/pandas-dev/pandas/issues/53134#issuecomment-1546011517 + def test_time_variable_compatible(self): + def to_df(val): + return pd.DataFrame([[pd.Timestamp(val)]]) + + for datestr, timestamp, outstr in TestTimeVariable.TESTS: + if datestr == "010101.01": + # 010101.01 parses as Jan 1 year 1 which isn't wrong since we do + # not provide format (and pandas does as it does in this case) + continue + var = TimeVariable("time") + var_parse = var.to_val(datestr) + try: + pandas_parse = table_from_frame(to_df(datestr)).X[0, 0] + except ValueError: + # pandas cannot parse some formats in the list skip them + continue + if not (np.isnan(var_parse) and np.isnan(pandas_parse)): + # nan == nan => False + self.assertEqual(var_parse, pandas_parse) + self.assertEqual(pandas_parse, timestamp) + + self.assertEqual(var.repr_val(var_parse), var.repr_val(var_parse)) + self.assertEqual(outstr, var.repr_val(var_parse)) + @unittest.skip("Convert all Orange demo dataset. It takes about 5s which is way to slow") def test_table_to_frame_on_all_orange_dataset(self): from os import listdir @@ -187,6 +486,68 @@ def _get_orange_demo_datasets(): self.assertEqual(len(df), len(table), assert_message) self.assertEqual(len(df.columns), len(table.domain.variables), assert_message) + def test_table_from_frames(self): + table = Table("brown-selected") # dataset with all X, Y and metas + table.ids = np.arange(100, len(table) + 100, 1, dtype=int) + + x, y, m = table.to_pandas_dfs() + new_table = Table.from_pandas_dfs(x, y, m) + + np.testing.assert_array_equal(table.X, new_table.X) + np.testing.assert_array_equal(table.Y, new_table.Y) + np.testing.assert_array_equal(table.metas, new_table.metas) + np.testing.assert_array_equal(table.ids, new_table.ids) + self.assertTupleEqual(table.domain.attributes, new_table.domain.attributes) + self.assertTupleEqual(table.domain.metas, new_table.domain.metas) + self.assertEqual(table.domain.class_var, new_table.domain.class_var) + + def test_table_from_frames_not_orange_dataframe(self): + x = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=["x1", "x2", "x3"]) + y = pd.DataFrame([[5], [6]], columns=["y"]) + m = pd.DataFrame([[1, 2], [4, 5]], columns=["m1", "m2"]) + new_table = Table.from_pandas_dfs(x, y, m) + + np.testing.assert_array_equal(x, new_table.X) + np.testing.assert_array_equal(y.values.flatten(), new_table.Y) + np.testing.assert_array_equal(m, new_table.metas) + d = new_table.domain + self.assertListEqual(x.columns.tolist(), [a.name for a in d.attributes]) + self.assertEqual(y.columns[0], d.class_var.name) + self.assertListEqual(m.columns.tolist(), [a.name for a in d.metas]) + + def test_table_from_frames_same_index(self): + """ + Test that index column is placed in metas. Function should fail + with ValueError when indexes are different + """ + index = np.array(["a", "b"]) + x = pd.DataFrame( + [[1, 2, 3], [4, 5, 6]], columns=["x1", "x2", "x3"], index=index + ) + y = pd.DataFrame([[5], [6]], columns=["y"], index=index) + m = pd.DataFrame([[1, 2], [4, 5]], columns=["m1", "m2"], index=index) + new_table = Table.from_pandas_dfs(x, y, m) + + # index should be placed in metas + np.testing.assert_array_equal(x, new_table.X) + np.testing.assert_array_equal(y.values.flatten(), new_table.Y) + np.testing.assert_array_equal( + np.hstack((index[:, None], m.values.astype("object"))), new_table.metas + ) + d = new_table.domain + self.assertListEqual(x.columns.tolist(), [a.name for a in d.attributes]) + self.assertEqual(y.columns[0], d.class_var.name) + self.assertListEqual(["index"] + m.columns.tolist(), [a.name for a in d.metas]) + + index2 = np.array(["a", "c"]) + x = pd.DataFrame( + [[1, 2, 3], [4, 5, 6]], columns=["x1", "x2", "x3"], index=index + ) + y = pd.DataFrame([[5], [6]], columns=["y"], index=index2) + m = pd.DataFrame([[1, 2], [4, 5]], columns=["m1", "m2"], index=index) + with self.assertRaises(ValueError): + Table.from_pandas_dfs(x, y, m) + class TestTablePandas(unittest.TestCase): def setUp(self): @@ -316,14 +677,14 @@ def test_merge(self): [1, 23]]) ) - df = self.table.X_df + df = self.table.X_df.astype(float) df2 = table2.X_df df3 = pd.merge(df, df2, on='c2') table2 = df.to_orange_table() table3 = df3.to_orange_table() self.assertEqual(len(table2), len(table3)) - self.assertFalse(any(table3.W)) + self.assertEqual(0, table3.W.size) self.assertEqual(self.table.attributes, table3.attributes) d1 = table2.domain @@ -394,7 +755,7 @@ def setUp(self): [0, 1, 0, 1, 1, 2, 1] + [0, 0, 0, 0, 4, 1, 1] + "a b c d e f g".split() + - list("ABCDEF") + [""], dtype=object).reshape(-1, 7).T + list("ABCDEF") + [""], dtype=object).reshape(-1, 7).T.copy() self.table = Table.from_numpy( self.domain, np.array( @@ -428,8 +789,6 @@ def test_contiguous_y(self): self.assertTrue(np.shares_memory(df.values, table.Y)) self.assertTrue(np.shares_memory(df.values, table2.Y)) - @unittest.skipUnless(pd.__version__ >= '1.3.0', - 'pandas-dev/pandas#39263') def test_contiguous_metas(self): table = self.table df = table.metas_df @@ -450,15 +809,17 @@ def test_to_dfs(self): ), 1) def test_amend(self): - df = self.table.X_df - df.iloc[0][0] = 0 + with self.table.unlocked(): + df = self.table.X_df + df.iloc[0, 0] = 0 X = self.table.X - self.table.X_df = df + with self.table.unlocked(): + self.table.X_df = df self.assertTrue(np.shares_memory(df.values, X)) def test_amend_dimension_mismatch(self): df = self.table.X_df - df = df.append([0, 1]) + df = pd.concat([df, df.iloc[:2]]) try: self.table.X_df = df except ValueError as e: @@ -467,6 +828,14 @@ def test_amend_dimension_mismatch(self): else: self.fail() + def test_array_copy(self): + df = OrangeDataFrame(self.table) # by default array not copied + self.assertTrue(np.shares_memory(df.values, self.table.X)) + df = OrangeDataFrame(self.table, copy=False) + self.assertTrue(np.shares_memory(df.values, self.table.X)) + df = OrangeDataFrame(self.table, copy=True) + self.assertFalse(np.shares_memory(df.values, self.table.X)) + class TestSparseTablePandas(TestTablePandas): features = ( diff --git a/Orange/data/tests/test_table.py b/Orange/data/tests/test_table.py index 836110276b3..50ffcb104c9 100644 --- a/Orange/data/tests/test_table.py +++ b/Orange/data/tests/test_table.py @@ -1,4 +1,7 @@ +import pickle import unittest +import os +import warnings import numpy as np import scipy.sparse as sp @@ -38,7 +41,7 @@ def test_from_numpy(self): Y = np.arange(5) % 2 metas = np.array(list("abcde")).reshape(5, 1) W = np.arange(5) / 5 - ids = np.arange(100, 105, dtype=np.int) + ids = np.arange(100, 105, dtype=int) attributes = dict(a=5, b="foo") dom = Domain([ContinuousVariable(x) for x in "abcd"], @@ -162,10 +165,12 @@ def test_concatenate_horizontal(self): tab1 = self._new_table((a, b), (c, ), (), 0) tab1.attributes = dict(a=5, b=7) tab2 = self._new_table((d, ), (e, ), (), 1000) - tab2.W = np.arange(5) + with tab2.unlocked(): + tab2.W = np.arange(5) tab3 = self._new_table((f, g), (), (), 2000) tab3.attributes = dict(a=1, c=4) - tab3.W = np.arange(5, 10) + with tab3.unlocked(): + tab3.W = np.arange(5, 10) joined = Table.concatenate((tab1, tab2, tab3), axis=1) domain = joined.domain self.assertEqual(domain.attributes, (a, b, d, f, g)) @@ -195,6 +200,16 @@ def test_concatenate_names(self): joined = Table.concatenate((tab1, tab2, tab3), axis=1) self.assertEqual(joined.name, "tab2") + def test_concatenate_check_domain(self): + a, b, c, d, e, f = map(ContinuousVariable, "abcdef") + tables = (self._new_table((a, b), (c, ), (d, e), 5), + self._new_table((a, b), (c, ), (d, e), 5), + self._new_table((a, b), (f, ), (d, e), 5)) + + with self.assertRaises(ValueError): + Table.concatenate(tables, axis=0) + Table.concatenate(tables, axis=0, ignore_domains=True) + def test_with_column(self): a, b, c, d, e, f, g = map(ContinuousVariable, "abcdefg") col = np.arange(9, 14) @@ -248,6 +263,184 @@ def test_with_column(self): tabw.metas, np.hstack((tab.metas, np.array(list("abcde")).reshape(5, -1)))) + def test_add_column_empty(self): + a, b = ContinuousVariable("a"), ContinuousVariable("b") + table = Table.from_list(Domain([a]), []) + + new_table = table.add_column(b, [], to_metas=True) + self.assertTupleEqual(new_table.domain.attributes, (a,)) + self.assertTupleEqual(new_table.domain.metas, (b,)) + self.assertTupleEqual((0, 1), new_table.X.shape) + self.assertTupleEqual((0, 1), new_table.metas.shape) + + new_table = table.add_column(ContinuousVariable("b"), [], to_metas=False) + self.assertTupleEqual(new_table.domain.attributes, (a, b)) + self.assertTupleEqual(new_table.domain.metas, ()) + self.assertTupleEqual((0, 2), new_table.X.shape) + self.assertTupleEqual((0, 0), new_table.metas.shape) + + def test_copy(self): + domain = Domain([ContinuousVariable("x")], + ContinuousVariable("y"), + [ContinuousVariable("z")]) + data1 = Table.from_list(domain, [[1, 2, 3]], weights=[4]) + data1.ids[0]= 5 + data2 = data1.copy() + with data2.unlocked(): + data2.X += 1 + data2.Y += 1 + data2.metas += 1 + data2.W += 1 + data2.ids += 1 + self.assertEqual(data1.X, [[1]]) + self.assertEqual(data1.Y, [[2]]) + self.assertEqual(data1.metas, [[3]]) + self.assertEqual(data1.W, [[4]]) + self.assertEqual(data1.ids, [[5]]) + + +class TestTableLocking(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.orig_locking = Table.LOCKING + if os.getenv("CI"): + assert Table.LOCKING + else: + Table.LOCKING = True + + @classmethod + def tearDownClass(cls): + Table.LOCKING = cls.orig_locking + + def setUp(self): + a, b, c, d, e, f, g = map(ContinuousVariable, "abcdefg") + domain = Domain([a, b, c], d, [e, f]) + self.table = Table.from_numpy( + domain, + np.random.random((5, 3)), + np.random.random(5), + np.random.random((5, 2))) + + def test_tables_are_locked(self): + tab = self.table + + with self.assertRaises(ValueError): + tab.X[0, 0] = 0 + with self.assertRaises(ValueError): + tab.Y[0] = 0 + with self.assertRaises(ValueError): + tab.metas[0, 0] = 0 + with self.assertRaises(ValueError): + tab.W[0] = 0 + + with self.assertRaises(ValueError): + tab.X = np.random.random((5, 3)) + with self.assertRaises(ValueError): + tab.Y = np.random.random(5) + with self.assertRaises(ValueError): + tab.metas = np.random.random((5, 2)) + with self.assertRaises(ValueError): + tab.W = np.random.random(5) + + def test_unlocking(self): + tab = self.table + with tab.unlocked(): + tab.X[0, 0] = 0 + tab.Y[0] = 0 + tab.metas[0, 0] = 0 + + tab.X = np.random.random((5, 3)) + tab.Y = np.random.random(5) + tab.metas = np.random.random((5, 2)) + tab.W = np.random.random(5) + + with tab.unlocked(tab.Y): + tab.Y[0] = 0 + with self.assertRaises(ValueError): + tab.X[0, 0] = 0 + with tab.unlocked(): + tab.X[0, 0] = 0 + with self.assertRaises(ValueError): + tab.X[0, 0] = 0 + + def test_force_unlocking(self): + tab = self.table + with tab.unlocked(): + tab.Y = np.arange(10)[:5] + + # tab.Y is now a view and can't be unlocked + with self.assertRaises(ValueError): + with tab.unlocked(tab.X, tab.Y): + pass + # Tets that tab.X was not left unlocked + with self.assertRaises(ValueError): + tab.X[0, 0] = 0 + + # This is not how force unlocking should be used! Force unlocking is + # meant primarily for passing tables to Cython code that does not + # properly define ndarrays as const. They should not modify the table; + # modification here is meant only for testing. + with tab.force_unlocked(tab.X, tab.Y): + tab.X[0, 0] = 0 + tab.Y[0] = 0 + + def test_locking_flag(self): + try: + default = Table.LOCKING + Table.LOCKING = False + self.setUp() + self.table.X[0, 0] = 0 + finally: + Table.LOCKING = default + + def test_unpickled_empty_weights(self): + # ensure that unpickled empty arrays could be unlocked + self.assertEqual(0, self.table.W.size) + unpickled = pickle.loads(pickle.dumps(self.table)) + with unpickled.unlocked(): + pass + + def test_unpickling_resets_locks(self): + default = Table.LOCKING + try: + self.setUp() + pickled_locked = pickle.dumps(self.table) + Table.LOCKING = False + tab = pickle.loads(pickled_locked) + tab.X[0, 0] = 1 + Table.LOCKING = True + tab = pickle.loads(pickled_locked) + with self.assertRaises(ValueError): + tab.X[0, 0] = 1 + finally: + Table.LOCKING = default + + def test_unpickled_owns_data(self): + try: + default = Table.LOCKING + Table.LOCKING = False + self.setUp() + table = self.table + table.X = table.X.view() + finally: + Table.LOCKING = default + + unpickled = pickle.loads(pickle.dumps(table)) + self.assertTrue(all(ar.base is None + for ar in (unpickled.X, unpickled.Y, unpickled.W, unpickled.metas))) + with unpickled.unlocked(): + unpickled.X[0, 0] = 42 + + @staticmethod + def test_unlock_table_derived(): + # pylint: disable=abstract-method + class ExtendedTable(Table): + pass + + t = ExtendedTable.from_file("iris") + with t.unlocked(): + pass + class TestTableFilters(unittest.TestCase): def setUp(self): @@ -265,7 +458,7 @@ def setUp(self): [0, 1, 0, 1, 1, np.nan, 1] + [0, 0, 0, 0, np.nan, 1, 1] + "a b c d e f g".split() + - list("ABCDEF") + [""], dtype=object).reshape(-1, 7).T + list("ABCDEF") + [""], dtype=object).reshape(-1, 7).T.copy() self.table = Table.from_numpy( self.domain, np.array( @@ -331,7 +524,8 @@ def test_row_filter_continuous(self): self.assertEqual(list(filtered.metas[:, -2].flatten()), ["a"]) def test_row_filter_string(self): - self.table.metas[:, -1] = self.table.metas[::-1, -2] + with self.table.unlocked(): + self.table.metas[:, -1] = self.table.metas[::-1, -2] val_filter = Values([ FilterString(None, FilterString.Between, "c", "e")]) filtered = val_filter(self.table) @@ -359,5 +553,268 @@ def test_is_defined(self): self.assertEqual(list(filtered.metas[:, -2].flatten()), list("abcdeg")) +class TableColumnViewTests(unittest.TestCase): + def setUp(self) -> None: + y = ContinuousVariable("y") + d = DiscreteVariable("d", values=("a", "b")) + t = ContinuousVariable("t") + m = StringVariable("m") + self.data = Table.from_numpy( + Domain([y, d], t, [m]), + np.array([[1, 2, 3], [0, 0, 1]]).T, + np.array([100, 200, 200]), + np.array(["abc def ghi".split()]).T + ) + self.y2 = ContinuousVariable( + "y2", compute_value=lambda data: 2 * data[:, y].X[:, 0]) + + +class TestTableGetColumn(TableColumnViewTests): + def test_get_column_proper_view(self): + data, y = self.data, self.data.domain["y"] + + col = data.get_column(y) + np.testing.assert_equal(col, data.X[:, 0]) + self.assertIs(col.base, data.X) + + col = data.get_column(y, copy=True) + np.testing.assert_equal(col, data.X[:, 0]) + self.assertIsNone(col.base) + + def test_get_column_computed(self): + data, y2 = self.data, self.y2 + + col2 = data.get_column(y2) + np.testing.assert_equal(col2, [2, 4, 6]) + self.assertIsNone(col2.base) + + col2 = data.get_column(y2, copy=True) + np.testing.assert_equal(col2, [2, 4, 6]) + self.assertIsNone(col2.base) + + def test_get_column_discrete(self): + data, d = self.data, self.data.domain["d"] + + col = data.get_column(d) + np.testing.assert_equal(col, [0, 0, 1]) + self.assertIs(col.base, data.X) + + col = data.get_column(d, copy=True) + np.testing.assert_equal(col, [0, 0, 1]) + self.assertIsNone(col.base) + + e = DiscreteVariable("d", values=("a", "b")) + assert e == d + col = data.get_column(e) + np.testing.assert_equal(col, [0, 0, 1]) + self.assertIs(col.base, data.X) + + e = DiscreteVariable("d", values=("a", "b", "c")) + assert e == d # because that's how Variable mapping works + col = data.get_column(e) + np.testing.assert_equal(col, [0, 0, 1]) + + e = DiscreteVariable("d", values=("a", "c", "b")) + assert e == d # because that's how Variable mapping works + col = data.get_column(e) + np.testing.assert_equal(col, [0, 0, 2]) + + with data.unlocked(data.X): + data.X = sp.csr_matrix(data.X) + e = DiscreteVariable("d", values=("a", "c", "b")) + assert e == d # because that's how Variable mapping works + col = data.get_column(e) + np.testing.assert_equal(col, [0, 0, 2]) + + + def test_sparse(self): + data, y = self.data, self.data.domain["y"] + with data.unlocked(data.X): + orig_y = data.X[:, 0] + data.X = sp.csr_matrix(data.X) + + col = data.get_column(y) + self.assertFalse(sp.issparse(col)) + np.testing.assert_equal(col, orig_y) + + col = data.get_column(y, copy=True) + self.assertFalse(sp.issparse(col)) + np.testing.assert_equal(col, orig_y) + + def test_get_column_no_variable(self): + self.assertRaises(ValueError, self.data.get_column, + ContinuousVariable("y3")) + + def test_index_by_int(self): + data = self.data + + col = data.get_column(0) + np.testing.assert_equal(col, data.X[:, 0]) + self.assertIs(col.base, data.X) + + col = data.get_column(0, copy=True) + np.testing.assert_equal(col, data.X[:, 0]) + self.assertIsNone(col.base) + + col = data.get_column(2) + np.testing.assert_equal(col, data.Y) + self.assertIs(col, data.Y) + + col = data.get_column(-1) + np.testing.assert_equal(col, data.metas[:, 0]) + self.assertIs(col.base, data.metas) + + def test_index_by_str(self): + data = self.data + + col = data.get_column("y") + np.testing.assert_equal(col, data.X[:, 0]) + self.assertIs(col.base, data.X) + + col = data.get_column("y", copy=True) + np.testing.assert_equal(col, data.X[:, 0]) + self.assertIsNone(col.base) + + col = data.get_column("t") + np.testing.assert_equal(col, data.Y) + self.assertIs(col, data.Y) + + col = data.get_column("m") + np.testing.assert_equal(col, data.metas[:, 0]) + self.assertIs(col.base, data.metas) + + +class TestTableGetColumnView(TableColumnViewTests): + def test_get_column_view_by_var(self): + data = self.data + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view(self.data.domain["y"]) + np.testing.assert_equal(col, data.X[:, 0]) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view(self.data.domain["t"]) + np.testing.assert_equal(col, data.Y) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view(self.data.domain["m"]) + np.testing.assert_equal(col, data.metas[:, 0]) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + self.assertRaises(ValueError, data.get_column_view, self.y2) + + def test_get_column_view_by_name(self): + data = self.data + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view("y") + np.testing.assert_equal(col, data.X[:, 0]) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view("t") + np.testing.assert_equal(col, data.Y) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view("m") + np.testing.assert_equal(col, data.metas[:, 0]) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + self.assertRaises(ValueError, data.get_column_view, "y2") + + def test_get_column_view_by_index(self): + data = self.data + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view(0) + np.testing.assert_equal(col, data.X[:, 0]) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view(2) + np.testing.assert_equal(col, data.Y) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + col, sparse = data.get_column_view(-1) + np.testing.assert_equal(col, data.metas[:, 0]) + self.assertFalse(sparse) + + with self.assertWarns(OrangeDeprecationWarning): + self.assertRaises(ValueError, data.get_column_view, "y2") + + def test_sparse(self): + warnings.simplefilter("ignore", OrangeDeprecationWarning) + + data, y = self.data, self.data.domain["y"] + with data.unlocked(data.X): + orig_y = data.X[:, 0] + data.X = sp.csr_matrix(data.X) + + # self.assertWarns does not work with multiple warnings + warnings.filterwarnings("error", ".*dense copy.*") + self.assertRaises(UserWarning, data.get_column_view, y) + + warnings.filterwarnings("ignore", ".*dense copy.*") + col, sparse = data.get_column_view(y) + np.testing.assert_equal(col, orig_y) + self.assertTrue(sparse) + + def test_mapped(self): + warnings.simplefilter("ignore", OrangeDeprecationWarning) + data, d = self.data, self.data.domain["d"] + + e = DiscreteVariable("d", values=("a", "b")) + assert e == d + col, _ = data.get_column_view(e) + np.testing.assert_equal(col, [0, 0, 1]) + + e = DiscreteVariable("d", values=("a", "b", "c")) + assert e == d # because that's how Variable mapping works + warnings.filterwarnings("error", ".*mapped copy.*") + self.assertRaises(UserWarning, data.get_column_view, e) + + warnings.filterwarnings("ignore", ".*mapped copy.*") + col, _ = data.get_column_view(e) + np.testing.assert_equal(col, [0, 0, 1]) + + e = DiscreteVariable("d", values=("a", "c", "b")) + assert e == d # because that's how Variable mapping works + col, _ = data.get_column_view(e) + np.testing.assert_equal(col, [0, 0, 2]) + + def test_meta_is_float(self): + data = Table.from_list( + Domain([], None, [ContinuousVariable("x"), + DiscreteVariable("y", values=["a", "b"])]), + [[0, 0]]) + self.assertEqual(data.get_column("x").dtype, float) + self.assertEqual(data.get_column("y").dtype, float) + + +class TestRowInstance(unittest.TestCase): + def test_multiclass_set(self): + foo = ContinuousVariable("Foo") + cont = ContinuousVariable("Cont Var") + disc = DiscreteVariable("Disc Var", values=("0", "1")) + domain = Domain([foo], [cont, disc], []) + + X = np.array([[1], [2]]) + Y = np.array([ + [float("nan"), float("nan")], + [float("nan"), float("nan")] + ], dtype=float) + data = Table(domain, X, Y, None) + row = data[1] + with data.unlocked(): + row[1] = 4 + np.testing.assert_equal(row._y, [4, np.nan]) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/data/tests/test_util.py b/Orange/data/tests/test_util.py index 4d9911e4778..eb4104a00c5 100644 --- a/Orange/data/tests/test_util.py +++ b/Orange/data/tests/test_util.py @@ -3,7 +3,7 @@ from Orange.data import Domain, ContinuousVariable from Orange.data.util import get_unique_names, get_unique_names_duplicates, \ - get_unique_names_domain, one_hot + get_unique_names_domain, one_hot, sanitized_name, redefines_eq_and_hash class TestGetUniqueNames(unittest.TestCase): @@ -120,6 +120,47 @@ def test_get_unique_names_not_equal(self): ["foo (1)", "bar (1)", "baz (4)"] ) + def test_get_unique_names_duplicated_proposals(self): + names = ["foo", "bar", "baz", "baz (3)"] + + self.assertEqual( + get_unique_names(names, ["foo", "boo", "boo"]), + ['foo (1)', 'boo (1)', 'boo (2)'] + ) + self.assertEqual( + get_unique_names(names, ["foo", "boo", "boo", "baz"]), + ['foo (4)', 'boo (4)', 'boo (5)', 'baz (4)'] + ) + self.assertEqual( + get_unique_names([], ["foo", "boo", "boo", "baz"]), + ['foo', 'boo (1)', 'boo (2)', 'baz'] + ) + self.assertEqual( + get_unique_names(["foo", "bong"], ["foo", "boo", "boo", "baz"]), + ['foo (1)', 'boo (1)', 'boo (2)', 'baz'] + ) + + self.assertEqual( + get_unique_names(names, ["foo", "boo", "boo"], + equal_numbers=False), + ['foo (1)', 'boo (1)', 'boo (2)'] + ) + self.assertEqual( + get_unique_names(names, ["foo", "boo", "boo", "baz"], + equal_numbers=False), + ['foo (1)', 'boo (1)', 'boo (2)', 'baz (4)'] + ) + self.assertEqual( + get_unique_names([], ["foo", "boo", "boo", "baz"], + equal_numbers=False), + ['foo', 'boo (1)', 'boo (2)', 'baz'] + ) + self.assertEqual( + get_unique_names(["foo", "bong"], ["foo", "boo", "boo", "baz"], + equal_numbers=False), + ['foo (1)', 'boo (1)', 'boo (2)', 'baz'] + ) + def test_get_unique_names_from_duplicates(self): self.assertEqual( get_unique_names_duplicates(["foo", "bar", "baz"]), @@ -260,5 +301,47 @@ def test_dim_too_low(self): one_hot(self.values, dim=2) +class TestSanitizedName(unittest.TestCase): + def test_sanitized_name(self): + self.assertEqual(sanitized_name("Foo"), "Foo") + self.assertEqual(sanitized_name("Foo Bar"), "Foo_Bar") + self.assertEqual(sanitized_name("0Foo"), "_0Foo") + self.assertEqual(sanitized_name("1 Foo Bar"), "_1_Foo_Bar") + + +class TestRedefinesEqAndHash(unittest.TestCase): + + class Valid: + def __eq__(self, other): + pass + + def __hash__(self): + pass + + class Subclass(Valid): + pass + + class OnlyEq: + def __eq__(self, other): + pass + + class OnlyHash: + def __hash__(self): + pass + + def test_valid(self): + self.assertTrue(redefines_eq_and_hash(self.Valid)) + self.assertTrue(redefines_eq_and_hash(self.Valid())) + + def test_subclass(self): + self.assertFalse(redefines_eq_and_hash(self.Subclass)) + + def test_only_eq(self): + self.assertFalse(redefines_eq_and_hash(self.OnlyEq)) + + def test_only_hash(self): + self.assertFalse(redefines_eq_and_hash(self.OnlyHash)) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/data/tests/test_variable.py b/Orange/data/tests/test_variable.py index e26cfdeb5b9..1b04f4f7925 100644 --- a/Orange/data/tests/test_variable.py +++ b/Orange/data/tests/test_variable.py @@ -1,6 +1,7 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring # pylint: disable=protected-access +import csv import os import sys import math @@ -10,12 +11,12 @@ import warnings from datetime import datetime, timezone -from io import StringIO +from tempfile import NamedTemporaryFile, TemporaryDirectory import numpy as np +import pandas as pd import scipy.sparse as sp -import Orange from Orange.data import Variable, ContinuousVariable, DiscreteVariable, \ StringVariable, TimeVariable, Unknown, Value, Table from Orange.data.io import CSVReader @@ -235,6 +236,61 @@ def test_hash_eq(self): self.assertEqual(hash(a), hash(a1)) self.assertEqual(hash(c1), hash(c2)) + def test_compute_value_eq_warning(self): + with warnings.catch_warnings(record=True) as warns: + ContinuousVariable("x") + self.assertEqual(warns, []) + ContinuousVariable("x", compute_value=lambda *_: 42) + self.assertEqual(warns, []) + + class Valid: + def __eq__(self, other): + return self is other + + def __hash__(self): + return super().__hash__(self) + + ContinuousVariable("x", compute_value=Valid()) + self.assertEqual(warns, []) + + class AlsoValid: + InheritEq = True + + ContinuousVariable("x", compute_value=AlsoValid()) + self.assertEqual(warns, []) + + class Invalid: + pass + + ContinuousVariable("x", compute_value=Invalid()) + self.assertNotEqual(warns, []) + + with warnings.catch_warnings(record=True) as warns: + + class InheritEqInherited(AlsoValid): + pass + + ContinuousVariable("x", compute_value=InheritEqInherited()) + self.assertNotEqual(warns, []) + + with warnings.catch_warnings(record=True) as warns: + + class MissingHash: + def __eq__(self, other): + return self is other + + ContinuousVariable("x", compute_value=MissingHash()) + self.assertNotEqual(warns, []) + + with warnings.catch_warnings(record=True) as warns: + + class MissingEq: + def __hash__(self): + return super().__hash__(self) + + ContinuousVariable("x", compute_value=MissingEq()) + self.assertNotEqual(warns, []) + def variabletest(varcls): def decorate(cls): @@ -304,6 +360,10 @@ def test_no_duplicated_values(self): self.assertEqual(list(a.values), ["a", "b", "c"]) self.assertEqual(list(a._value_index), ["a", "b", "c"]) + def test_no_duplicates_in_constructor(self): + self.assertRaises(ValueError, DiscreteVariable, + "foo", values=("a", "b", "a")) + def test_unpickle(self): d1 = DiscreteVariable("A", values=("two", "one")) s = pickle.dumps(d1) @@ -578,6 +638,8 @@ def test_val(self): self.assertEqual(a.str_val(""), "?") self.assertEqual(a.str_val(Value(a, "")), "?") self.assertEqual(a.repr_val(Value(a, "foo")), '"foo"') + self.assertEqual(a.str_val(np.nan), "?") + self.assertEqual(a.str_val(None), "?") @variabletest(TimeVariable) @@ -631,6 +693,10 @@ def test_parse_utc(self): ts2 = var.parse(datestr) self.assertEqual(var.repr_val(ts2), datestr) self.assertEqual(var.repr_val(ts1), '2015-10-18 20:48:20') + # TZ is reset to UTC. + datestr, offset = '2015-10-18T22:48:20', '+02:00' + ts3 = var.parse(datestr + offset) + self.assertEqual(var.repr_val(ts3), '2015-10-18 20:48:20') def test_parse_timestamp(self): var = TimeVariable("time") @@ -655,33 +721,46 @@ def test_no_date_no_time(self): self.assertEqual(TimeVariable('relative time').repr_val(1.6), '1.6') def test_readwrite_timevariable(self): - output_csv = StringIO() - input_csv = StringIO("""\ -Date,Feature -time,continuous -, -1920-12-12,1.0 -1920-12-13,3.0 -1920-12-14,5.5 -""") - for stream in (output_csv, input_csv): - stream.close = lambda: None # HACK: Prevent closing of streams - - table = CSVReader(input_csv).read() - self.assertIsInstance(table.domain['Date'], TimeVariable) - self.assertEqual(table[0, 'Date'], '1920-12-12') + content = [ + ("Date", "Feature"), + ("time", "continuous"), + ("", ""), + ("1920-12-12", 1.0), + ("1920-12-13", 3.0), + ("1920-12-14", 5.5), + ] + with NamedTemporaryFile( + mode="w", delete=False, newline="", encoding="utf-8" + ) as input_csv: + csv.writer(input_csv, delimiter=",").writerows(content) + + table = CSVReader(input_csv.name).read() + self.assertIsInstance(table.domain["Date"], TimeVariable) + self.assertEqual(table[0, "Date"], "1920-12-12") # Dates before 1970 are negative - self.assertTrue(all(inst['Date'] < 0 for inst in table)) + self.assertTrue(all(inst["Date"] < 0 for inst in table)) - CSVReader.write_file(output_csv, table) - self.assertEqual(input_csv.getvalue().splitlines(), - output_csv.getvalue().splitlines()) + with NamedTemporaryFile(mode="w", delete=False) as output_csv: + pass + CSVReader.write_file(output_csv.name, table) + + with open(input_csv.name, encoding="utf-8") as in_f: + with open(output_csv.name, encoding="utf-8") as out_f: + self.assertEqual(in_f.read(), out_f.read()) + + os.unlink(input_csv.name) + os.unlink(output_csv.name) def test_repr_value(self): # https://github.com/biolab/orange3/pull/1760 var = TimeVariable('time') self.assertEqual(var.repr_val(Value(var, 416.3)), '416.3') + def test_repr_value_out_of_bounds(self): + var = TimeVariable("T", have_date=True, have_time=True) + self.assertEqual(var.repr_val(1e300), "?") + self.assertEqual(var.repr_val(-1e300), "?") + def test_have_date_have_time_in_construct(self): """Test if have_time and have_date is correctly set""" var = TimeVariable('time', have_date=1) @@ -695,6 +774,117 @@ def varcls_modified(self, name): var.have_time = 1 return var + def test_additional_formats(self): + expected_date = datetime(2022, 2, 7) + dates = { + "2021-11-25": ("2022-02-07",), + "25.11.2021": ("07.02.2022", "07. 02. 2022", "7.2.2022", "7. 2. 2022"), + "25.11.21": ("07.02.22", "07. 02. 22", "7.2.22", "7. 2. 22"), + "11/25/2021": ("02/07/2022", "2/7/2022"), + "11/25/21": ("02/07/22", "2/7/22"), + "20211125": ("20220207",), + } + expected_date_time = datetime(2022, 2, 7, 10, 11, 12) + date_times = { + "2021-11-25 00:00:00": ( + "2022-02-07 10:11:12", + "2022-02-07 10:11:12.00", + ), + "25.11.2021 00:00:00": ( + "07.02.2022 10:11:12", + "07. 02. 2022 10:11:12", + "7.2.2022 10:11:12", + "7. 2. 2022 10:11:12", + "07.02.2022 10:11:12.00", + "07. 02. 2022 10:11:12.00", + "7.2.2022 10:11:12.00", + "7. 2. 2022 10:11:12.00", + ), + "25.11.21 00:00:00": ( + "07.02.22 10:11:12", + "07. 02. 22 10:11:12", + "7.2.22 10:11:12", + "7. 2. 22 10:11:12", + "07.02.22 10:11:12.00", + "07. 02. 22 10:11:12.00", + "7.2.22 10:11:12.00", + "7. 2. 22 10:11:12.00", + ), + "11/25/2021 00:00:00": ( + "02/07/2022 10:11:12", + "2/7/2022 10:11:12", + "02/07/2022 10:11:12.00", + "2/7/2022 10:11:12.00", + ), + "11/25/21 00:00:00": ( + "02/07/22 10:11:12", + "2/7/22 10:11:12", + "02/07/22 10:11:12.00", + "2/7/22 10:11:12.00", + ), + "20211125000000": ("20220207101112", "20220207101112.00"), + } + # times without seconds + expected_date_time2 = datetime(2022, 2, 7, 10, 11, 0) + date_times2 = { + "2021-11-25 00:00:00": ("2022-02-07 10:11",), + "25.11.2021 00:00:00": ( + "07.02.2022 10:11", + "07. 02. 2022 10:11", + "7.2.2022 10:11", + "7. 2. 2022 10:11", + ), + "25.11.21 00:00:00": ( + "07.02.22 10:11", + "07. 02. 22 10:11", + "7.2.22 10:11", + "7. 2. 22 10:11", + ), + "11/25/2021 00:00:00": ("02/07/2022 10:11", "2/7/2022 10:11"), + "11/25/21 00:00:00": ("02/07/22 10:11", "2/7/22 10:11"), + "20211125000000": ("202202071011",), + } + # datetime defaults to 1900, 01, 01 + expected_time = datetime(1900, 1, 1, 10, 11, 12) + times = { + "00:00:00": ("10:11:12", "10:11:12.00"), + "000000": ("101112", "101112.00"), + } + expected_time2 = datetime(1900, 1, 1, 10, 11, 0) + times2 = { + "00:00:00": ("10:11",), + } + expected_year = datetime(2022, 1, 1) + years = { + "2021": (2022,), + } + expected_day = datetime(1900, 2, 7) + days = { + "11-25": ("02-07",), + "25.11.": ("07.02.", "07. 02.", "7.2.", "7. 2."), + "11/25": ("02/07", "2/7"), + } + data = ( + (expected_date, dates), + (expected_date_time, date_times), + (expected_date_time2, date_times2), + (expected_time, times), + (expected_time2, times2), + (expected_year, years), + (expected_day, days), + ) + for expected, dts in data: + for k, dt in dts.items(): + for t in dt: + parsed = [ + pd.to_datetime(t, format=f, errors="coerce") + for f in TimeVariable.ADDITIONAL_FORMATS[k][0] + ] + # test any equal to expected + self.assertTrue(any(d == expected for d in parsed)) + # test that no other equal to any other date - only nan or expected + self.assertTrue(any(d == expected or pd.isnull(d) for d in parsed)) + PickleContinuousVariable = create_pickling_tests( "PickleContinuousVariable", diff --git a/Orange/data/util.py b/Orange/data/util.py index c0245e58d52..b5af7c47297 100644 --- a/Orange/data/util.py +++ b/Orange/data/util.py @@ -2,6 +2,8 @@ Data-manipulation utilities. """ import re +import types +import warnings from collections import Counter from itertools import chain, count from typing import Callable, Union, List, Type @@ -48,7 +50,7 @@ def scale(values, min=0, max=1): """Return values scaled to [min, max]""" if len(values) == 0: return np.array([]) - minval = np.float_(bn.nanmin(values)) + minval = np.float64(bn.nanmin(values)) ptp = bn.nanmax(values) - minval if ptp == 0: return np.clip(values, min, max) @@ -72,6 +74,14 @@ class SharedComputeValue: def __init__(self, compute_shared, variable=None): self.compute_shared = compute_shared self.variable = variable + if compute_shared is not None \ + and not isinstance(compute_shared, (types.BuiltinFunctionType, + types.FunctionType)) \ + and not redefines_eq_and_hash(compute_shared) \ + and not type(compute_shared).__dict__.get("InheritEq", False): + warnings.warn(f"{type(compute_shared).__name__} should define " + f"__eq__ and __hash__ to be used for compute_shared", + stacklevel=2) def __call__(self, data, shared_data=None): """Fallback if common parts are not passed.""" @@ -85,6 +95,14 @@ def compute(self, data, shared_data): Subclasses need to implement this function.""" raise NotImplementedError + def __eq__(self, other): + return type(self) is type(other) \ + and self.compute_shared == other.compute_shared \ + and self.variable == other.variable + + def __hash__(self): + return hash((type(self), self.compute_shared, self.variable)) + def vstack(arrays): """vstack that supports sparse and dense arrays @@ -138,7 +156,7 @@ def assure_array_sparse(a, sparse_class: Callable = sp.csc_matrix): if not sp.issparse(a): # since x can be a list, cast to np.array # since x can come from metas with string, cast to float - a = np.asarray(a).astype(np.float) + a = np.asarray(a).astype(float) return sparse_class(a) @@ -213,8 +231,31 @@ def get_unique_names(names, proposed, equal_numbers=True): return get_unique_names(names, [proposed])[0] indices = {name: get_indices(names, name) for name in proposed} indices = {name: max(ind) + 1 for name, ind in indices.items() if ind} + + duplicated_proposed = {name for name, count in Counter(proposed).items() + if count > 1} + if duplicated_proposed: + # This could be merged with the code below, but it would make it slower + # because it can't be done within list comprehension + if equal_numbers: + max_index = max(indices.values(), default=1) + indices = {name: max_index + for name in chain(indices, duplicated_proposed)} + else: + indices.update({name: 1 + for name in duplicated_proposed - set(indices)}) + names = [] + for name in proposed: + if name in indices: + names.append(f"{name} ({indices[name]})") + indices[name] += 1 + else: + names.append(name) + return names + if not (set(proposed) & set(names) or indices): return proposed + if equal_numbers: max_index = max(indices.values()) return [f"{name} ({max_index})" for name in proposed] @@ -268,3 +309,36 @@ def get_unique_names_domain(attributes, class_vars=(), metas=()): for old, new in zip(all_names, unique_names) if new != old)) return (attributes, class_vars, metas), renamed + + +def sanitized_name(name: str) -> str: + """ + Replace non-alphanumeric characters and leading zero with `_`. + + Args: + name (str): proposed name + + Returns: + name (str): new name + """ + sanitized = re.sub(r"\W", "_", name) + if sanitized[0].isdigit(): + sanitized = "_" + sanitized + return sanitized + + +def redefines_eq_and_hash(this): + """ + Check if the passed object (or class) redefines __eq__ and __hash__. + + Args: + this: class or object + """ + if not isinstance(this, type): + this = type(this) + + # if only __eq__ is defined, __hash__ is set to None + if this.__hash__ is None: + return False + + return "__hash__" in this.__dict__ and "__eq__" in this.__dict__ diff --git a/Orange/data/variable.py b/Orange/data/variable.py index b538ef082fc..ebac4568606 100644 --- a/Orange/data/variable.py +++ b/Orange/data/variable.py @@ -1,6 +1,8 @@ import re +import types import warnings from collections.abc import Iterable +from typing import Sequence from datetime import datetime, timedelta, timezone from numbers import Number, Real, Integral @@ -8,9 +10,11 @@ from pickle import PickleError import numpy as np +import pandas import scipy.sparse as sp from Orange.data import _variable +from Orange.data.util import redefines_eq_and_hash from Orange.util import Registry, Reprable, OrangeDeprecationWarning @@ -157,6 +161,8 @@ def __new__(cls, variable, value=Unknown): :param value: value """ if variable.is_primitive(): + if isinstance(variable, DiscreteVariable) and isinstance(value, str): + value = variable.to_val(value) self = super().__new__(cls, value) self.variable = variable self._value = None @@ -168,6 +174,44 @@ def __new__(cls, variable, value=Unknown): self._value = value return self + @staticmethod + def _as_values_primitive(variable, data) -> Sequence['Value']: + assert variable.is_primitive() + _Value = Value + _float_new = float.__new__ + res = [Value(variable, np.nan)] * len(data) + for i, v in enumerate(data): + v = _float_new(_Value, v) + v.variable = variable + res[i] = v + return res + + @staticmethod + def _as_values_non_primitive(variable, data) -> Sequence['Value']: + assert not variable.is_primitive() + _Value = Value + _float_new = float.__new__ + data_arr = np.array(data, dtype=object) + NA = data_arr == variable.Unknown + fdata = np.full(len(data), np.finfo(float).min) + fdata[NA] = np.nan + res = [Value(variable, Variable.Unknown)] * len(data) + for i, (v, fval) in enumerate(zip(data, fdata)): + val = _float_new(_Value, fval) + val.variable = variable + val._value = v + res[i] = val + return res + + @staticmethod + def _as_values(variable, data): + """Equivalent but faster then `[Value(variable, v) for v in data] + """ + if variable.is_primitive(): + return Value._as_values_primitive(variable, data) + else: + return Value._as_values_non_primitive(variable, data) + def __init__(self, _, __=Unknown): # __new__ does the job, pylint: disable=super-init-not-called pass @@ -328,6 +372,17 @@ def __init__(self, name="", compute_value=None, *, sparse=False): warnings.warn("Variable must have a name", OrangeDeprecationWarning, stacklevel=3) self._name = name + + if compute_value is not None \ + and not isinstance(compute_value, (types.BuiltinFunctionType, + types.FunctionType)) \ + and not redefines_eq_and_hash(compute_value) \ + and not type(compute_value).__dict__.get("InheritEq", False): + warnings.warn(f"{type(compute_value).__name__} should define " + "__eq__ and __hash__ to be used for compute_value\n" + "or set InheritEq = True if inherited methods suffice", + stacklevel=3) + self._compute_value = compute_value self.unknown_str = MISSING_VALUES self.source_variable = None @@ -579,7 +634,7 @@ def val_from_str_add(self, s): """ return _variable.val_from_str_add_cont(self, s) - def repr_val(self, val): + def repr_val(self, val: float): """ Return the value as a string with the prescribed number of decimals. """ @@ -635,6 +690,8 @@ def __init__( values = tuple(values) # some people (including me) pass a generator if not all(isinstance(value, str) for value in values): raise TypeError("values of DiscreteVariables must be strings") + if len(set(values)) < len(values): + raise ValueError("Duplicate values in DiscreteVariable") super().__init__(name, compute_value, sparse=sparse) self._values = values @@ -851,6 +908,8 @@ def str_val(val): if not val.value: return "?" val = val.value + if pandas.isnull(val): + return "?" return str(val) def repr_val(self, val): @@ -870,7 +929,7 @@ class TimeVariable(ContinuousVariable): If time is specified without a date, Unix epoch is assumed. - If time is specified wihout an UTC offset, localtime is assumed. + If time is specified without an UTC offset, localtime is assumed. """ _all_vars = {} TYPE_HEADERS = ('time', 't') @@ -923,25 +982,119 @@ class TimeVariable(ContinuousVariable): r'\d{1,4}(-?\d{2,3})?' r')$') + ADDITIONAL_FORMATS = { + "2021-11-25": (("%Y-%m-%d",), 1, 0), + "25.11.2021": (("%d.%m.%Y", "%d. %m. %Y"), 1, 0), + "25.11.21": (("%d.%m.%y", "%d. %m. %y"), 1, 0), + "11/25/2021": (("%m/%d/%Y",), 1, 0), + "11/25/21": (("%m/%d/%y",), 1, 0), + "20211125": (("%Y%m%d",), 1, 0), + # it would be too many options if we also include all time formats with + # with lengths up to minutes, up to seconds and up to milliseconds, + # joining all tree options under 00:00:00 + "2021-11-25 00:00:00": ( + ( + "%Y-%m-%d %H:%M", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M:%S.%f", + # times with timezone offsets + "%Y-%m-%d %H:%M%z", + "%Y-%m-%d %H:%M:%S%z", + "%Y-%m-%d %H:%M:%S.%f%z", + ), + 1, + 1, + ), + "25.11.2021 00:00:00": ( + ( + "%d.%m.%Y %H:%M", + "%d. %m. %Y %H:%M", + "%d.%m.%Y %H:%M:%S", + "%d. %m. %Y %H:%M:%S", + "%d.%m.%Y %H:%M:%S.%f", + "%d. %m. %Y %H:%M:%S.%f", + ), + 1, + 1, + ), + "25.11.21 00:00:00": ( + ( + "%d.%m.%y %H:%M", + "%d. %m. %y %H:%M", + "%d.%m.%y %H:%M:%S", + "%d. %m. %y %H:%M:%S", + "%d.%m.%y %H:%M:%S.%f", + "%d. %m. %y %H:%M:%S.%f", + ), + 1, + 1, + ), + "11/25/2021 00:00:00": ( + ( + "%m/%d/%Y %H:%M", + "%m/%d/%Y %H:%M:%S", + "%m/%d/%Y %H:%M:%S.%f", + ), + 1, + 1, + ), + "11/25/21 00:00:00": ( + ( + "%m/%d/%y %H:%M", + "%m/%d/%y %H:%M:%S", + "%m/%d/%y %H:%M:%S.%f", + ), + 1, + 1, + ), + "20211125000000": (("%Y%m%d%H%M", "%Y%m%d%H%M%S", "%Y%m%d%H%M%S.%f"), 1, 1), + "00:00:00": (("%H:%M", "%H:%M:%S", "%H:%M:%S.%f"), 0, 1), + "000000": (("%H%M", "%H%M%S", "%H%M%S.%f"), 0, 1), + "2021": (("%Y",), 1, 0), + "11-25": (("%m-%d",), 1, 0), + "25.11.": (("%d.%m.", "%d. %m."), 1, 0), + "11/25": (("%m/%d",), 1, 0), + "1125": (("%m%d",), 1, 0), + } + class InvalidDateTimeFormatError(ValueError): def __init__(self, date_string): super().__init__( - "Invalid datetime format '{}'. " - "Only ISO 8601 supported.".format(date_string)) + f"Invalid datetime format '{date_string}'. Only ISO 8601 supported." + ) _matches_iso_format = re.compile(REGEX).match - # UTC offset and associated timezone. If parsed datetime values provide an - # offset, it is used for display. If not all values have the same offset, - # +0000 (=UTC) timezone is used and utc_offset is set to False. - utc_offset = None - timezone = timezone.utc + # If parsed datetime values provide an offset or timzone, it is used for display. + # If not all values have the same offset, +0000 (=UTC) timezone is used + _timezone = None def __init__(self, *args, have_date=0, have_time=0, **kwargs): super().__init__(*args, **kwargs) self.have_date = have_date self.have_time = have_time + @property + def timezone(self): + if self._timezone is None or self._timezone == "different timezones": + return timezone.utc + else: + return self._timezone + + @timezone.setter + def timezone(self, tz): + """ + Set timezone value: + - if self._timezone is None set it to new timezone + - if current timezone is different that new indicate that TimeVariable + have two date-times with different timezones + - if timezones are same keep it + """ + if self._timezone is None: + self._timezone = tz + elif tz != self.timezone: + self._timezone = "different timezones" + def copy(self, compute_value=Variable._CopyComputeValue, *, name=None, **_): return super().copy(compute_value=compute_value, name=name, have_date=self.have_date, have_time=self.have_time) @@ -959,14 +1112,26 @@ def repr_val(self, val): return str(val.value) if isinstance(val, Value) else str(val) # If you know how to simplify this, be my guest + # first, round to 6 decimals. By skipping this, you risk that + # microseconds would be rounded to 1_000_000 two lines later + val = round(val, 6) seconds = int(val) + # Rounding is needed to avoid rounding down; it will never be rounded + # to 1_000_000 because of the round we have above microseconds = int(round((val - seconds) * 1e6)) + # If you know how to simplify this, be my guest if val < 0: if microseconds: seconds, microseconds = seconds - 1, int(1e6) + microseconds - date = datetime.fromtimestamp(0, tz=self.timezone) + timedelta(seconds=seconds) + try: + date = datetime.fromtimestamp(0, tz=self.timezone) + timedelta(seconds=seconds) + except (OverflowError, ValueError): + return "?" else: - date = datetime.fromtimestamp(seconds, tz=self.timezone) + try: + date = datetime.fromtimestamp(seconds, tz=self.timezone) + except (OverflowError, ValueError): + return "?" date = str(date.replace(microsecond=microseconds)) if self.have_date and not self.have_time: @@ -992,7 +1157,9 @@ def parse(self, datestr): """ if datestr in MISSING_VALUES: return Unknown + datestr = datestr.strip().rstrip('Z') + datestr = self._tzre_sub(datestr) if not self._matches_iso_format(datestr): try: @@ -1027,16 +1194,8 @@ def parse(self, datestr): else: raise self.InvalidDateTimeFormatError(datestr) - # Remember UTC offset. If not all parsed values share the same offset, - # remember none of it. offset = dt.utcoffset() - if self.utc_offset is not False: - if offset and self.utc_offset is None: - self.utc_offset = offset - self.timezone = timezone(offset) - elif self.utc_offset != offset: - self.utc_offset = False - self.timezone = timezone.utc + self.timezone = timezone(offset) if offset is not None else None # Convert time to UTC timezone. In dates without timezone, # localtime is assumed. See also: diff --git a/Orange/distance/base.py b/Orange/distance/base.py index 7205e1cbce9..02ebb96d755 100644 --- a/Orange/distance/base.py +++ b/Orange/distance/base.py @@ -10,9 +10,6 @@ from Orange.statistics import util -# TODO: When we upgrade to numpy 1.13, change use argument copy=False in -# nan_to_num instead of assignment - # TODO this *private* function is called from several widgets to prepare # data for calling the below classes. After we (mostly) stopped relying # on sklearn.metrics, this is (mostly) unnecessary @@ -34,22 +31,29 @@ def _preprocess(table, impute=True): # TODO I have put this function here as a substitute the above `_preprocess`. # None of them really belongs here; (re?)move them, eventually. -def remove_discrete_features(data): +def remove_discrete_features(data, to_metas=False): """Remove discrete columns from the data.""" new_domain = Domain( [a for a in data.domain.attributes if a.is_continuous], data.domain.class_vars, - data.domain.metas) + data.domain.metas + + (() if not to_metas + else tuple(a for a in data.domain.attributes if not a.is_continuous)) + ) return data.transform(new_domain) -def remove_nonbinary_features(data): +def remove_nonbinary_features(data, to_metas=False): """Remove non-binary columns from the data.""" new_domain = Domain( [a for a in data.domain.attributes if a.is_discrete and len(a.values) == 2], data.domain.class_vars, - data.domain.metas) + data.domain.metas + + (() if not to_metas + else tuple(a for a in data.domain.attributes + if not (a.is_discrete and len(a.values) == 2)) + if to_metas else ())) return data.transform(new_domain) def impute(data): @@ -118,6 +122,9 @@ class Distance: are replaced with zeros, and infs with very large numbers. callback (callable or None): callback function + similarity (bool): + if `True` (default is `False`) the class will compute similarities + instead of distances Attributes: axis (int): @@ -153,11 +160,15 @@ class Distance: sparse data. Currently, all classes that do handle it rely on fallbacks to SKL metrics. These, however, do not support discrete data and missing values, and will fail silently. + + Class attribute `supports_similarity` indicates whether the class can also + compute similarities. """ supports_sparse = False supports_discrete = False supports_normalization = False supports_missing = True + supports_similarity = False # Predefined here to silence pylint, which doesn't look into __new__ normalize = False @@ -165,10 +176,15 @@ class Distance: impute = False def __new__(cls, e1=None, e2=None, axis=1, impute=False, - callback=None, **kwargs): + callback=None, *, similarity=False, **kwargs): + + if similarity and not cls.supports_similarity: + raise ValueError(f"{cls.__name__} does not compute similarity") + self = super().__new__(cls) self.axis = axis self.impute = impute + self.similarity = similarity self.callback = callback # Ugly, but needed to allow allow setting subclass-specific parameters # (such as normalize) when `e1` is not `None` and the `__new__` in the @@ -185,7 +201,11 @@ def __new__(cls, e1=None, e2=None, axis=1, impute=False, fallback = getattr(self, "fallback", None) if fallback is not None: # pylint: disable=not-callable - return fallback(e1, e2, axis, impute) + dist = fallback(e1, e2, axis, impute) + if self.similarity: + assert fallback.metric == "cosine" + return 1 - dist + return dist # Magic constructor model = self.fit(e1) @@ -233,10 +253,11 @@ class DistanceModel: callback function """ - def __init__(self, axis, impute=False, callback=None): + def __init__(self, axis, impute=False, callback=None, *, similarity=False): self._axis = axis self.impute = impute self.callback = callback + self.similarity = similarity @property def axis(self): @@ -300,8 +321,8 @@ class FittedDistanceModel(DistanceModel): if `True` (default is `False`) continuous columns are normalized callback (callable or None): callback function """ - def __init__(self, attributes, axis=1, impute=False, callback=None): - super().__init__(axis, impute, callback) + def __init__(self, attributes, axis=1, impute=False, callback=None, **kwargs): + super().__init__(axis, impute, callback, **kwargs) self.attributes = attributes self.discrete = None self.continuous = None diff --git a/Orange/distance/distance.py b/Orange/distance/distance.py index aca1679d4a8..f5294207e50 100644 --- a/Orange/distance/distance.py +++ b/Orange/distance/distance.py @@ -1,3 +1,4 @@ +from itertools import count from typing import Callable import warnings from unittest.mock import patch @@ -393,6 +394,7 @@ def fit_cols(self, attributes, x, n_vals): class Cosine(FittedDistance): supports_sparse = True # via fallback supports_discrete = False + supports_similarity = True fallback = SklDistance('cosine') @staticmethod @@ -413,7 +415,8 @@ def fit_rows(self, attributes, x, n_vals): means = util.nanmean(x, axis=0) means = np.nan_to_num(means) return self.CosineModel(attributes, self.axis, self.impute, - discrete, means, self.callback) + discrete, means, self.callback, + similarity=self.similarity) fit_cols = fit_rows @@ -424,8 +427,10 @@ def get_continuous_stats(self, column): class CosineModel(FittedDistanceModel): """Model for computation of cosine distances across rows and columns. All non-zero discrete values are treated as 1.""" - def __init__(self, attributes, axis, impute, discrete, means, callback): - super().__init__(attributes, axis, impute, callback) + def __init__(self, attributes, axis, impute, discrete, means, callback, + *, similarity=False): + super().__init__(attributes, axis, impute, callback, + similarity=similarity) self.discrete = discrete self.means = means @@ -457,7 +462,7 @@ def prepare_data(x): if x2 is None: diag = np.diag_indices_from(dist) dist[diag] = np.where(np.isnan(dist[diag]), np.nan, 1.0) - return 1 - dist + return dist if self.similarity else 1 - dist class JaccardModel(FittedDistanceModel): @@ -465,15 +470,18 @@ class JaccardModel(FittedDistanceModel): Model for computation of cosine distances across rows and columns. All non-zero values are treated as 1. """ - def __init__(self, attributes, axis, impute, ps, callback): - super().__init__(attributes, axis, impute, callback) + def __init__(self, attributes, axis, impute, ps, callback, + *, similarity=False): + super().__init__(attributes, axis, impute, callback, + similarity=similarity) self.ps = ps def compute_distances(self, x1, x2): if sp.issparse(x1): - return self._compute_sparse(x1, x2) + dist = self._compute_sparse(x1, x2) else: - return self._compute_dense(x1, x2) + dist = self._compute_dense(x1, x2) + return 1 - dist if self.similarity else dist def _compute_dense(self, x1, x2): """ @@ -498,7 +506,7 @@ def _compute_dense(self, x1, x2): else: nonzeros2 = np.not_equal(x2, 0).view(np.int8) nans2 = _distance.any_nan_row(x2, callbacks.next()) - return _distance.jaccard_rows( + dist = _distance.jaccard_rows( nonzeros1, nonzeros2, x1, x1 if x2 is None else x2, nans1, nans2, @@ -508,38 +516,50 @@ def _compute_dense(self, x1, x2): else: callbacks = StepwiseCallbacks(self.callback, [10, 90]) nans1 = _distance.any_nan_row(x1.T, callbacks.next()) - return _distance.jaccard_cols( + dist = _distance.jaccard_cols( nonzeros1, x1, nans1, self.ps, callbacks.next()) + return np.array(dist) def _compute_sparse(self, x1, x2=None): callback = self.callback or (lambda x: x) symmetric = x2 is None + mtype = sp.csr_matrix if self.axis == 1 else sp.csc_matrix + x1 = mtype(x1, copy=True) + x1.eliminate_zeros() if symmetric: x2 = x1 - x1 = sp.csr_matrix(x1) - x1.eliminate_zeros() - x2 = sp.csr_matrix(x2) - x2.eliminate_zeros() - n, m = x1.shape[0], x2.shape[0] + else: + x2 = mtype(x2, copy=True) + x2.eliminate_zeros() + if self.axis == 1: + n, m = x1.shape[0], x2.shape[0] + else: + n, m = x1.shape[1], x2.shape[1] matrix = np.zeros((n, m)) - for i in range(n): - callback(i * 100 / n) - xi_ind = set(x1[i].indices) - for j in range(i if symmetric else m): - union = len(xi_ind.union(x2[j].indices)) - if union: - jacc = 1 - len(xi_ind.intersection(x2[j].indices)) / union - else: - jacc = 0 - matrix[i, j] = jacc - if symmetric: - matrix[j, i] = jacc + jlines = np.hstack((np.arange(len(x2.indptr) - 1)[:, None], + x2.indptr[:-1, None], + x2.indptr[1:, None])) + steps = len(x1.indptr) - 1 + for i, i1, i2 in zip(count(), x1.indptr, x1.indptr[1:]): + # For asymmetric case, we've done i / steps of work. + # For symmetric case, the total time is proportional to steps ** 2 + # and the time used so far is proportional to i ** 2. + # Thence the exponent is 1 + symmetric. + callback(100 * (i / steps) ** (1 + symmetric)) + x1_ind = set(x1.indices[i1:i2]) + for j, j1, j2 in jlines[:i if symmetric else m]: + x2_ind = set(x2.indices[j1:j2]) + union = len(x1_ind | x2_ind) + matrix[i, j] = union and 1 - len(x1_ind & x2_ind) / union + if symmetric: + matrix += matrix.T return matrix class Jaccard(FittedDistance): supports_sparse = True supports_discrete = True + supports_similarity = True ModelType = JaccardModel def fit_rows(self, attributes, x, n_vals): @@ -554,7 +574,7 @@ def fit_rows(self, attributes, x, n_vals): (_distance.p_nonzero(x[:, col]) for col in range(len(n_vals))), dtype=np.double, count=len(n_vals)) return JaccardModel(attributes, self.axis, self.impute, - ps, self.callback) + ps, self.callback, similarity=self.similarity) fit_cols = fit_rows @@ -565,16 +585,22 @@ def get_continuous_stats(self, column): class CorrelationDistanceModel(DistanceModel): """Helper class for normal and absolute Pearson and Spearman correlation""" - def __init__(self, absolute, axis=1, impute=False): - super().__init__(axis, impute) + def __init__(self, absolute, axis=1, impute=False, *, similarity=False): + super().__init__(axis, impute, similarity=similarity) self.absolute = absolute def compute_distances(self, x1, x2): rho = self.compute_correlation(x1, x2) - if self.absolute: - return (1. - np.abs(rho)) / 2. + if self.similarity: + if self.absolute: + return np.abs(rho) + else: + return rho else: - return (1. - rho) / 2. + if self.absolute: + return 1. - np.abs(rho) + else: + return 0.5 - rho / 2 def compute_correlation(self, x1, x2): raise NotImplementedError() @@ -699,16 +725,19 @@ def _corrcoef2(a, b, axis=0): class CorrelationDistance(Distance): # pylint: disable=abstract-method supports_missing = False + supports_similarity = True class SpearmanR(CorrelationDistance): def fit(self, _): - return SpearmanModel(False, self.axis, self.impute) + return SpearmanModel(False, self.axis, self.impute, + similarity=self.similarity) class SpearmanRAbsolute(CorrelationDistance): def fit(self, _): - return SpearmanModel(True, self.axis, self.impute) + return SpearmanModel(True, self.axis, self.impute, + similarity=self.similarity) class PearsonModel(CorrelationDistanceModel): @@ -722,12 +751,14 @@ def compute_correlation(self, x1, x2): class PearsonR(CorrelationDistance): def fit(self, _): - return PearsonModel(False, self.axis, self.impute) + return PearsonModel(False, self.axis, self.impute, + similarity=self.similarity) class PearsonRAbsolute(CorrelationDistance): def fit(self, _): - return PearsonModel(True, self.axis, self.impute) + return PearsonModel(True, self.axis, self.impute, + similarity=self.similarity) def _prob_dist(a): diff --git a/Orange/distance/tests/test_distance.py b/Orange/distance/tests/test_distance.py index 2ff10ce5678..c1314a8acae 100644 --- a/Orange/distance/tests/test_distance.py +++ b/Orange/distance/tests/test_distance.py @@ -1,14 +1,25 @@ import unittest -from unittest.mock import patch from math import sqrt import numpy as np from scipy.sparse import csr_matrix -from Orange.data import ContinuousVariable, DiscreteVariable, Domain, Table +from Orange.data import ContinuousVariable, DiscreteVariable, StringVariable,\ + Domain, Table from Orange import distance +class BaseTest(unittest.TestCase): + def test_unsupported_similarity(self): + self.assertRaises(ValueError, distance.Distance, similarity=True) + + class SupportsSimilarity(distance.Distance): + # pylint: disable=abstract-method + supports_similarity = True + + SupportsSimilarity(similarity=True) + + class CommonTests: """Tests applicable to all distance measures""" @@ -40,6 +51,11 @@ def test_sparse(self): dist_sparse = self.Distance(sparse_data) np.testing.assert_allclose(dist_sparse, dist_dense) + if self.Distance.supports_similarity: + dist_dense = self.Distance(dense_data, similarity=True) + dist_sparse = self.Distance(sparse_data, similarity=True) + np.testing.assert_allclose(dist_sparse, dist_dense) + class CommonFittedTests(CommonTests): """Tests applicable to all distances with fitting""" @@ -81,7 +97,8 @@ def is_same(d1, d2, fit1=None, fit2=None): data_const = Table(domain, np.hstack((X, np.ones((n, 1))))) data_nan = Table(domain, np.hstack((X, np.full((n, 1), np.nan)))) data_nan_1 = Table(domain, np.hstack((X, np.full((n, 1), np.nan)))) - data_nan_1.X[0, -1] = 1 + with data_nan_1.unlocked(): + data_nan_1.X[0, -1] = 1 is_same(data, data_const) is_same(data, data_nan) is_same(data, data_nan_1) @@ -176,7 +193,8 @@ def test_euclidean_disc(self): [2, 0, 2], [3, 2, 0]]))) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan model = distance.Euclidean().fit(data) assert_almost_equal(model.dist_missing_disc, [[1/2, 1/2, 1, 1], @@ -186,13 +204,15 @@ def test_euclidean_disc(self): assert_almost_equal(model.dist_missing2_disc, [1 - 2/4, 1 - 3/9, 1 - 5/9]) - dist = model(data) + with data.unlocked(): + dist = model(data) assert_almost_equal(dist, np.sqrt(np.array([[0, 2.5, 3], [2.5, 0, 1.5], [3, 1.5, 0]]))) - data.X[0, 0] = np.nan + with data.unlocked(): + data.X[0, 0] = np.nan model = distance.Euclidean().fit(data) assert_almost_equal(model.dist_missing_disc, [[1, 0, 1, 1], @@ -208,7 +228,8 @@ def test_euclidean_disc(self): [2, 1, 0]]))) data = self.disc_data4 - data.X[:2, 0] = np.nan + with data.unlocked(): + data.X[:2, 0] = np.nan model = distance.Euclidean().fit(data) assert_almost_equal(model.dist_missing_disc, @@ -237,7 +258,8 @@ def test_euclidean_cont(self): [5, 21, 0, 41], [38, 82, 41, 0]]))) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan dist = distance.Euclidean(data, axis=1, normalize=False) assert_almost_equal( dist, @@ -246,7 +268,8 @@ def test_euclidean_cont(self): [2.236067977, 5.385164807, 0, 6.403124237], [6.164414003, 6.480740698, 6.403124237, 0]]) - data.X[0, 0] = np.nan + with data.unlocked(): + data.X[0, 0] = np.nan dist = distance.Euclidean(data, axis=1, normalize=False) assert_almost_equal( dist, @@ -280,7 +303,8 @@ def test_euclidean_cont_normalized(self): [1.146423008, 2.068662631, 0, 1.956673562], [1.621286967, 3.035242727, 1.956673562, 0]]) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan model = distance.Euclidean(axis=1, normalize=True).fit(data) assert_almost_equal(model.means, [3, 2.75, 1.5]) assert_almost_equal(model.vars, [8, 2.1875, 1.25]) @@ -292,7 +316,8 @@ def test_euclidean_cont_normalized(self): [1.146423008, 2.192519751, 0, 2.019547333], [1.696635326, 2.675283697, 2.019547333, 0]]) - data.X[0, 0] = np.nan + with data.unlocked(): + data.X[0, 0] = np.nan model = distance.Euclidean(axis=1, normalize=True).fit(data) assert_almost_equal(model.means, [4, 2.75, 1.5]) assert_almost_equal(model.vars, [9, 2.1875, 1.25]) @@ -315,7 +340,8 @@ def test_euclidean_cols(self): [8.062257748, 0, 5.196152423], [4.242640687, 5.196152423, 0]]) - data.X[1, 1] = np.nan + with data.unlocked(): + data.X[1, 1] = np.nan dist = distance.Euclidean(data, axis=0, normalize=False) assert_almost_equal( dist, @@ -323,7 +349,8 @@ def test_euclidean_cols(self): [6.218252702, 0, 2.581988897], [4.242640687, 2.581988897, 0]]) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan dist = distance.Euclidean(data, axis=0, normalize=False) assert_almost_equal( dist, @@ -342,7 +369,8 @@ def test_euclidean_cols_normalized(self): [2.455273959, 0, 2.473176308], [0.649839392, 2.473176308, 0]]) - data.X[1, 1] = np.nan + with data.unlocked(): + data.X[1, 1] = np.nan dist = distance.Euclidean(data, axis=0, normalize=True) assert_almost_equal( dist, @@ -350,7 +378,8 @@ def test_euclidean_cols_normalized(self): [2, 0, 1.704275472], [0.649839392, 1.704275472, 0]]) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan dist = distance.Euclidean(data, axis=0, normalize=True) assert_almost_equal( dist, @@ -451,7 +480,8 @@ def test_manhattan_disc(self): [2, 0, 2], [3, 2, 0]]) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan model = distance.Manhattan().fit(data) assert_almost_equal(model.dist_missing_disc, [[1/2, 1/2, 1, 1], @@ -466,7 +496,8 @@ def test_manhattan_disc(self): [2.5, 0, 1.5], [3, 1.5, 0]]) - data.X[0, 0] = np.nan + with data.unlocked(): + data.X[0, 0] = np.nan model = distance.Manhattan().fit(data) assert_almost_equal(model.dist_missing_disc, [[1, 0, 1, 1], @@ -482,7 +513,8 @@ def test_manhattan_disc(self): [2, 1, 0]]) data = self.disc_data4 - data.X[:2, 0] = np.nan + with data.unlocked(): + data.X[:2, 0] = np.nan model = distance.Manhattan().fit(data) assert_almost_equal(model.dist_missing_disc, [[1/2, 1/2, 1, 1], @@ -510,7 +542,8 @@ def test_manhattan_cont(self): [6, 5, 0, 13], [9, 16, 13, 0]]) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan dist = distance.Manhattan(data, axis=1, normalize=False) assert_almost_equal( dist, @@ -519,7 +552,8 @@ def test_manhattan_cont(self): [6, 3, 0, 13], [9, 14, 13, 0]]) - data.X[0, 0] = np.nan + with data.unlocked(): + data.X[0, 0] = np.nan dist = distance.Manhattan(data, axis=1, normalize=False) assert_almost_equal( dist, @@ -553,7 +587,8 @@ def test_manhattan_cont_normalized(self): [1.833333333, 1.75, 0, 4.166666667], [3, 5.416666667, 4.166666667, 0]]) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan model = distance.Manhattan(axis=1, normalize=True).fit(data) assert_almost_equal(model.medians, [2, 4.5, 1.5]) assert_almost_equal(model.mads, [1, 2, 1]) @@ -566,7 +601,8 @@ def test_manhattan_cont_normalized(self): [2, 1.25, 0, 5], [4, 5.75, 5, 0]]) - data.X[0, 0] = np.nan + with data.unlocked(): + data.X[0, 0] = np.nan model = distance.Manhattan(axis=1, normalize=True).fit(data) assert_almost_equal(model.medians, [4.5, 4.5, 1.5]) assert_almost_equal(model.mads, [2.5, 2, 1]) @@ -590,7 +626,8 @@ def test_manhattan_cols(self): [20, 0, 15], [7, 15, 0]]) - data.X[1, 1] = np.nan + with data.unlocked(): + data.X[1, 1] = np.nan dist = distance.Manhattan(data, axis=0, normalize=False) assert_almost_equal( dist, @@ -598,7 +635,8 @@ def test_manhattan_cols(self): [19, 0, 14], [7, 14, 0]]) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan dist = distance.Manhattan(data, axis=0, normalize=False) assert_almost_equal( dist, @@ -618,7 +656,8 @@ def test_manhattan_cols_normalized(self): [4.5833333, 0, 4.25], [2, 4.25, 0]]) - data.X[1, 1] = np.nan + with data.unlocked(): + data.X[1, 1] = np.nan dist = distance.Manhattan(data, axis=0, normalize=True) assert_almost_equal( dist, @@ -626,7 +665,8 @@ def test_manhattan_cols_normalized(self): [4.6666667, 0, 4], [2, 4, 0]]) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan dist = distance.Manhattan(data, axis=0, normalize=True) assert_almost_equal( dist, @@ -638,7 +678,8 @@ def test_manhattan_mixed(self): assert_almost_equal = np.testing.assert_almost_equal data = self.mixed_data - data.X[2, 0] = 2 # prevent mads[0] = 0 + with data.unlocked(): + data.X[2, 0] = 2 # prevent mads[0] = 0 model = distance.Manhattan(axis=1, normalize=True).fit(data) assert_almost_equal(model.medians, [1, 3, 1]) assert_almost_equal(model.mads, [1, 2, 1]) @@ -690,18 +731,13 @@ def test_manhattan_mixed_cols(self): class CosineDistanceTest(FittedDistanceTest, CommonFittedTests): Distance = distance.Cosine - def test_no_data(self): - with patch("warnings.warn") as warn: - super().test_no_data() - self.assertEqual(warn.call_args[0], - ("Mean of empty slice", RuntimeWarning)) - def test_cosine_disc(self): assert_almost_equal = np.testing.assert_almost_equal data = self.disc_data - data.X = np.array([[1, 0, 0], - [0, 1, 1], - [1, 3, 0]], dtype=float) + with data.unlocked(): + data.X = np.array([[1, 0, 0], + [0, 1, 1], + [1, 3, 0]], dtype=float) model = distance.Cosine().fit(data) assert_almost_equal(model.means, [2 / 3, 2 / 3, 1 / 3]) @@ -711,7 +747,8 @@ def test_cosine_disc(self): [0, 1, 0.5], [1 / sqrt(2), 0.5, 1]])) - data.X[1, 1] = np.nan + with data.unlocked(): + data.X[1, 1] = np.nan model = distance.Cosine().fit(data) assert_almost_equal(model.means, [2 / 3, 1 / 2, 1 / 3]) dist = model(data) @@ -721,10 +758,11 @@ def test_cosine_disc(self): [0, 1, 0.5 / sqrt(1.25) / sqrt(2)], [1 / sqrt(2), 0.5 / sqrt(1.25) / sqrt(2), 1]])) - data.X = np.array([[1, 0, 0], - [0, np.nan, 1], - [1, np.nan, 1], - [1, 3, 1]]) + with data.unlocked(): + data.X = np.array([[1, 0, 0], + [0, np.nan, 1], + [1, np.nan, 1], + [1, 3, 1]]) model = distance.Cosine().fit(data) dist = model(data) assert_almost_equal(model.means, [0.75, 0.5, 0.75]) @@ -746,7 +784,8 @@ def test_cosine_cont(self): [0.355097978, 0.925279678, 0.12011731, 0]] ) - data.X[1, 0] = np.nan + with data.unlocked(): + data.X[1, 0] = np.nan dist = distance.Cosine(data, axis=1) assert_almost_equal( dist, @@ -755,7 +794,8 @@ def test_cosine_cont(self): [0.0741799, 0.207881966, 0, 0.12011731], [0.355097978, 0.324809395, 0.12011731, 0]]) - data.X[0, 0] = np.nan + with data.unlocked(): + data.X[0, 0] = np.nan dist = distance.Cosine(data, axis=1) assert_almost_equal( dist, @@ -767,9 +807,10 @@ def test_cosine_cont(self): def test_cosine_mixed(self): assert_almost_equal = np.testing.assert_almost_equal data = self.mixed_data - data.X = np.array([[1, 3, 2, 1, 0, 0], - [-1, 5, 0, 0, 1, 1], - [1, 1, 1, 1, 3, 0]], dtype=float) + with data.unlocked(): + data.X = np.array([[1, 3, 2, 1, 0, 0], + [-1, 5, 0, 0, 1, 1], + [1, 1, 1, 1, 3, 0]], dtype=float) model = distance.Cosine(axis=1).fit(data) assert_almost_equal(model.means, [1/3, 3, 1, 2/3, 2/3, 1/3]) @@ -780,10 +821,26 @@ def test_cosine_mixed(self): [0.316869949, 0, 0.577422873], [0.191709623, 0.577422873, 0]]) + def test_cosine_mixed_similarity(self): + assert_almost_equal = np.testing.assert_almost_equal + data = self.mixed_data + with data.unlocked(): + data.X = np.array([[1, 3, 2, 1, 0, 0], + [-1, 5, 0, 0, 1, 1], + [1, 1, 1, 1, 3, 0]], dtype=float) + + dist = distance.Cosine(data, similarity=True) + assert_almost_equal( + 1 - dist, + [[0, 0.316869949, 0.191709623], + [0.316869949, 0, 0.577422873], + [0.191709623, 0.577422873, 0]]) + def test_two_tables(self): assert_almost_equal = np.testing.assert_almost_equal - self.cont_data.X[1, 0] = np.nan - self.cont_data2.X[1, 0] = np.nan + with self.cont_data.unlocked(), self.cont_data2.unlocked(): + self.cont_data.X[1, 0] = np.nan + self.cont_data2.X[1, 0] = np.nan dist = distance.Cosine(self.cont_data, self.cont_data2) assert_almost_equal( @@ -816,7 +873,8 @@ def test_cosine_cols(self): [0.711324865, 0, 0.44365136], [0.11050082, 0.44365136, 0]]) - data.X[1, 1] = np.nan + with data.unlocked(): + data.X[1, 1] = np.nan dist = distance.Cosine(data, axis=0, normalize=False) assert_almost_equal( dist, @@ -824,8 +882,9 @@ def test_cosine_cols(self): [0.47702364, 0, 0.181076975], [0.11050082, 0.181076975, 0]]) - data.X[1, 0] = np.nan - data.X[1, 2] = 2 + with data.unlocked(): + data.X[1, 0] = np.nan + data.X[1, 2] = 2 dist = distance.Cosine(data, axis=0, normalize=False) assert_almost_equal( dist, @@ -845,25 +904,39 @@ def setUp(self): [1, 1, 1], [1, 0, 1], [1, 0, 0]]) + self.sparse_data = Table.from_numpy(self.domain, csr_matrix(self.data.X)) def test_jaccard_rows(self): assert_almost_equal = np.testing.assert_almost_equal - model = distance.Jaccard().fit(self.data) - assert_almost_equal(model.ps, [0.75, 0.5, 0.75]) - assert_almost_equal( - model(self.data), - 1 - np.array([[1, 2/3, 1/3, 0], - [2/3, 1, 2/3, 1/3], - [1/3, 2/3, 1, 1/2], - [0, 1/3, 1/2, 1]])) + for data, name in [(self.data, "dense"), (self.sparse_data, "sparse")]: + with self.subTest(name): + model = distance.Jaccard().fit(data) + if name == "dense": + assert_almost_equal(model.ps, [0.75, 0.5, 0.75]) + assert_almost_equal( + model(data), + 1 - np.array([[1, 2/3, 1/3, 0], + [2/3, 1, 2/3, 1/3], + [1/3, 2/3, 1, 1/2], + [0, 1/3, 1/2, 1]])) + + model = distance.Jaccard(similarity=True).fit(data) + if name == "dense": + assert_almost_equal(model.ps, [0.75, 0.5, 0.75]) + assert_almost_equal( + model(data), + np.array([[1, 2/3, 1/3, 0], + [2/3, 1, 2/3, 1/3], + [1/3, 2/3, 1, 1/2], + [0, 1/3, 1/2, 1]])) X = self.data.X - X[1, 0] = X[2, 0] = X[3, 1] = np.nan + with self.data.unlocked(): + X[1, 0] = X[2, 0] = X[3, 1] = np.nan model = distance.Jaccard().fit(self.data) assert_almost_equal(model.ps, np.array([0.5, 2/3, 0.75])) - # pylint: disable=bad-whitespace assert_almost_equal( model(self.data), 1 - np.array([[ 1, 2 / 2.5, 1 / 2.5, 2/3 / 3], @@ -873,18 +946,32 @@ def test_jaccard_rows(self): def test_jaccard_cols(self): assert_almost_equal = np.testing.assert_almost_equal - model = distance.Jaccard(axis=0).fit(self.data) - assert_almost_equal(model.ps, [0.75, 0.5, 0.75]) - assert_almost_equal( - model(self.data), - 1 - np.array([[1, 1/4, 1/2], - [1/4, 1, 2/3], - [1/2, 2/3, 1]])) - - self.data.X = np.array([[0, 1, 1], - [np.nan, np.nan, 1], - [np.nan, 0, 1], - [1, 1, 0]]) + for data, name in [(self.data, "dense"), (self.sparse_data, "sparse")]: + with self.subTest(name): + model = distance.Jaccard(axis=0).fit(data) + if name == "dense": + assert_almost_equal(model.ps, [0.75, 0.5, 0.75]) + assert_almost_equal( + model(data), + 1 - np.array([[1, 1/4, 1/2], + [1/4, 1, 2/3], + [1/2, 2/3, 1]])) + + assert_almost_equal = np.testing.assert_almost_equal + model = distance.Jaccard(axis=0, similarity=True).fit(data) + if name == "dense": + assert_almost_equal(model.ps, [0.75, 0.5, 0.75]) + assert_almost_equal( + model(data), + np.array([[1, 1/4, 1/2], + [1/4, 1, 2/3], + [1/2, 2/3, 1]])) + + with self.data.unlocked(): + self.data.X = np.array([[0, 1, 1], + [np.nan, np.nan, 1], + [np.nan, 0, 1], + [1, 1, 0]]) model = distance.Jaccard(axis=0).fit(self.data) assert_almost_equal(model.ps, [0.5, 2/3, 0.75]) assert_almost_equal( @@ -902,6 +989,7 @@ def test_zero_instances(self): dist_dense = self.Distance(dense_data) dist_sparse = self.Distance(sparse_data) + # false positive, pylint: disable=unsubscriptable-object self.assertEqual(dist_dense[0][1], 0) self.assertEqual(dist_sparse[0][1], 0) self.assertEqual(dist_dense[0][2], 1) @@ -980,5 +1068,42 @@ def test_interruptable_sqrt_scalar(self): np.testing.assert_array_equal(new_i, np.sqrt(i)) +class TestDataUtilities(unittest.TestCase): + def test_remove_discrete(self): + d1, d2, d3 = (DiscreteVariable(c, values=tuple("123")) for c in "abc") + c1, c2 = (ContinuousVariable(c) for c in "xy") + t = StringVariable("t") + domain = Domain([d1, c1], d2, [c2, d3, t]) + data = Table.from_domain(domain, 5) + + reduced = distance.remove_discrete_features(data) + self.assertEqual(reduced.domain.attributes, (c1, )) + self.assertEqual(reduced.domain.class_var, d2) + self.assertEqual(reduced.domain.metas, (c2, d3, t)) + + reduced = distance.remove_discrete_features(data, to_metas=True) + self.assertEqual(reduced.domain.attributes, (c1, )) + self.assertEqual(reduced.domain.class_var, d2) + self.assertEqual(reduced.domain.metas, (c2, d3, t, d1)) + + def test_remove_non_binary(self): + b1, b2, b3 = (DiscreteVariable(c, values=tuple("12")) for c in "abc") + d1, d2, d3 = (DiscreteVariable(c, values=tuple("123")) for c in "def") + c1, c2 = (ContinuousVariable(c) for c in "xy") + t = StringVariable("t") + domain = Domain([d1, b1, b2, c1], d2, [c2, d3, t, b3]) + data = Table.from_domain(domain, 5) + + reduced = distance.remove_nonbinary_features(data) + self.assertEqual(reduced.domain.attributes, (b1, b2)) + self.assertEqual(reduced.domain.class_var, d2) + self.assertEqual(reduced.domain.metas, (c2, d3, t, b3)) + + reduced = distance.remove_nonbinary_features(data, to_metas=True) + self.assertEqual(reduced.domain.attributes, (b1, b2)) + self.assertEqual(reduced.domain.class_var, d2) + self.assertEqual(reduced.domain.metas, (c2, d3, t, b3, d1, c1)) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/ensembles/ada_boost.py b/Orange/ensembles/ada_boost.py index 9abd8980015..94fa2e03469 100644 --- a/Orange/ensembles/ada_boost.py +++ b/Orange/ensembles/ada_boost.py @@ -1,3 +1,5 @@ +import warnings + import sklearn.ensemble as skl_ensemble from Orange.base import SklLearner @@ -7,6 +9,8 @@ from Orange.regression.base_regression import ( SklLearnerRegression, SklModelRegression ) +from Orange.util import OrangeDeprecationWarning + __all__ = ['SklAdaBoostClassificationLearner', 'SklAdaBoostRegressionLearner'] @@ -18,17 +22,23 @@ class SklAdaBoostClassifier(SklModelClassification): class SklAdaBoostClassificationLearner(SklLearnerClassification): __wraps__ = skl_ensemble.AdaBoostClassifier __returns__ = SklAdaBoostClassifier + supports_weights = True - def __init__(self, base_estimator=None, n_estimators=50, learning_rate=1., - algorithm='SAMME.R', random_state=None, preprocessors=None): + def __init__(self, estimator=None, n_estimators=50, learning_rate=1., + algorithm='deprecated', random_state=None, preprocessors=None): + if algorithm != "deprecated": + warnings.warn( + "`algorithm` is deprecated and has no effect (to be removed in 3.42).", + OrangeDeprecationWarning, stacklevel=2) + del algorithm from Orange.modelling import Fitter # If fitter, get the appropriate Learner instance - if isinstance(base_estimator, Fitter): - base_estimator = base_estimator.get_learner( - base_estimator.CLASSIFICATION) + if isinstance(estimator, Fitter): + estimator = estimator.get_learner( + estimator.CLASSIFICATION) # If sklearn learner, get the underlying sklearn representation - if isinstance(base_estimator, SklLearner): - base_estimator = base_estimator.__wraps__(**base_estimator.params) + if isinstance(estimator, SklLearner): + estimator = estimator.__wraps__(**estimator.params) super().__init__(preprocessors=preprocessors) self.params = vars() @@ -40,16 +50,17 @@ class SklAdaBoostRegressor(SklModelRegression): class SklAdaBoostRegressionLearner(SklLearnerRegression): __wraps__ = skl_ensemble.AdaBoostRegressor __returns__ = SklAdaBoostRegressor + supports_weights = True - def __init__(self, base_estimator=None, n_estimators=50, learning_rate=1., + def __init__(self, estimator=None, n_estimators=50, learning_rate=1., loss='linear', random_state=None, preprocessors=None): from Orange.modelling import Fitter # If fitter, get the appropriate Learner instance - if isinstance(base_estimator, Fitter): - base_estimator = base_estimator.get_learner( - base_estimator.REGRESSION) + if isinstance(estimator, Fitter): + estimator = estimator.get_learner( + estimator.REGRESSION) # If sklearn learner, get the underlying sklearn representation - if isinstance(base_estimator, SklLearner): - base_estimator = base_estimator.__wraps__(**base_estimator.params) + if isinstance(estimator, SklLearner): + estimator = estimator.__wraps__(**estimator.params) super().__init__(preprocessors=preprocessors) self.params = vars() diff --git a/Orange/ensembles/stack.py b/Orange/ensembles/stack.py index 2cf918f66ef..37cb94b610b 100644 --- a/Orange/ensembles/stack.py +++ b/Orange/ensembles/stack.py @@ -30,8 +30,9 @@ def predict_storage(self, data): X = np.column_stack(pred) Y = np.repeat(np.nan, X.shape[0]) stacked_data = data.transform(self.aggregate.domain) - stacked_data.X = X - stacked_data.Y = Y + with stacked_data.unlocked(): + stacked_data.X = X + stacked_data.Y = Y return self.aggregate( stacked_data, Model.ValueProbs if self.use_prob else Model.Value) @@ -80,9 +81,10 @@ def fit_storage(self, data): dom = Domain([ContinuousVariable('f{}'.format(i + 1)) for i in range(X.shape[1])], data.domain.class_var) - stacked_data = data.transform(dom) - stacked_data.X = X - stacked_data.Y = res.actual + stacked_data = Table.from_table(dom, data) + with stacked_data.unlocked_reference(): + stacked_data.X = X + stacked_data.Y = res.actual models = [l(data) for l in self.learners] aggregate_model = self.aggregate(stacked_data) return StackedModel(models, aggregate_model, use_prob=use_prob, diff --git a/Orange/evaluation/clustering.py b/Orange/evaluation/clustering.py index 9d4fbfc52b0..acb81c2a3e8 100644 --- a/Orange/evaluation/clustering.py +++ b/Orange/evaluation/clustering.py @@ -32,6 +32,10 @@ def get_fold(self, fold): class ClusteringScore(Score): considers_actual = False + @staticmethod + def is_compatible(domain) -> bool: + return True + # pylint: disable=arguments-differ def from_predicted(self, results, score_function): # Clustering scores from labels diff --git a/Orange/evaluation/scoring.py b/Orange/evaluation/scoring.py index e2bb763104c..7430aa57144 100644 --- a/Orange/evaluation/scoring.py +++ b/Orange/evaluation/scoring.py @@ -11,16 +11,20 @@ """ import math +import warnings import numpy as np import sklearn.metrics as skl_metrics from sklearn.metrics import confusion_matrix -from Orange.data import DiscreteVariable, ContinuousVariable +from Orange.data import DiscreteVariable, ContinuousVariable, Domain from Orange.misc.wrapper_meta import WrapperMeta +from Orange.util import OrangeDeprecationWarning + __all__ = ["CA", "Precision", "Recall", "F1", "PrecisionRecallFSupport", "AUC", - "MSE", "RMSE", "MAE", "R2", "compute_CD", "graph_ranks", "LogLoss"] + "MSE", "RMSE", "MAE", "MAPE", "SMAPE", "R2", "LogLoss", + "MatthewsCorrCoefficient"] class ScoreMetaType(WrapperMeta): @@ -37,6 +41,7 @@ def __new__(mcs, name, bases, dict_, **kwargs): if not kwargs.get("abstract"): # Don't use inherited names, look into dict_ cls.name = dict_.get("name", name) + cls.long_name = dict_.get("long_name", cls.name) cls.registry[name] = cls else: cls.registry = {} @@ -66,6 +71,9 @@ class Score(metaclass=ScoreMetaType): name = None long_name = None #: A short user-readable name (e.g. a few words) + default_visible = True + priority = 100 + def __new__(cls, results=None, **kwargs): self = super().__new__(cls) if results is not None: @@ -107,24 +115,49 @@ def compute_score(self, results): @staticmethod def from_predicted(results, score_function, **kwargs): + def as_scalar(e): + if np.isscalar(e): + return e + elif len(e) == 1: + return e[0] + else: + raise ValueError("len(e) > 1") + + scores = (score_function(results.actual, predicted, **kwargs) + for predicted in results.predicted) + # np.fromiter needs flat iter of scalars, some scoring function calls + # return array of single element return np.fromiter( - (score_function(results.actual, predicted, **kwargs) - for predicted in results.predicted), + map(as_scalar, scores), dtype=np.float64, count=len(results.predicted)) + @staticmethod + def is_compatible(domain: Domain) -> bool: + raise NotImplementedError + class ClassificationScore(Score, abstract=True): class_types = (DiscreteVariable, ) + @staticmethod + def is_compatible(domain: Domain) -> bool: + return domain.has_discrete_class + class RegressionScore(Score, abstract=True): class_types = (ContinuousVariable, ) + @staticmethod + def is_compatible(domain: Domain) -> bool: + return domain.has_continuous_class + # pylint: disable=invalid-name class CA(ClassificationScore): __wraps__ = skl_metrics.accuracy_score + name = "CA" long_name = "Classification accuracy" + priority = 20 class PrecisionRecallFSupport(ClassificationScore): @@ -173,14 +206,21 @@ def compute_score(self, results, target=None, average='binary'): class Precision(TargetScore): __wraps__ = skl_metrics.precision_score + name = "Prec" + long_name = "Precision" + priority = 40 class Recall(TargetScore): __wraps__ = skl_metrics.recall_score + name = long_name = "Recall" + priority = 50 class F1(TargetScore): __wraps__ = skl_metrics.f1_score + name = long_name = "F1" + priority = 30 class AUC(ClassificationScore): @@ -198,7 +238,9 @@ class AUC(ClassificationScore): __wraps__ = skl_metrics.roc_auc_score separate_folds = True is_binary = True + name = "AUC" long_name = "Area under ROC curve" + priority = 10 @staticmethod def calculate_weights(results): @@ -266,17 +308,29 @@ class LogLoss(ClassificationScore): Examples -------- >>> Orange.evaluation.LogLoss(results) - array([ 0.3...]) + array([0.1...]) """ __wraps__ = skl_metrics.log_loss + priority = 120 + name = "LogLoss" + long_name = "Logistic loss" + default_visible = False - def compute_score(self, results, eps=1e-15, normalize=True, + def compute_score(self, results, eps="auto", normalize=True, sample_weight=None): + if eps != "auto": + # eps argument will be removed in scikit-learn 1.5 + warnings.warn( + ( + "`LogLoss.compute_score`: eps parameter is unused. " + "It will always have value of `np.finfo(y_pred.dtype).eps`." + ), + OrangeDeprecationWarning, + ) return np.fromiter( (skl_metrics.log_loss(results.actual, probabilities, - eps=eps, normalize=normalize, sample_weight=sample_weight) for probabilities in results.probabilities), @@ -285,6 +339,10 @@ def compute_score(self, results, eps=1e-15, normalize=True, class Specificity(ClassificationScore): is_binary = True + priority = 110 + name = "Spec" + long_name = "Specificity" + default_visible = False @staticmethod def calculate_weights(results): @@ -332,323 +390,82 @@ def compute_score(self, results, target=None, average="binary"): elif target is not None: return self.single_class_specificity(results, target) + +class MatthewsCorrCoefficient(ClassificationScore): + __wraps__ = skl_metrics.matthews_corrcoef + name = "MCC" + long_name = "Matthews correlation coefficient" + + # Regression scores class MSE(RegressionScore): __wraps__ = skl_metrics.mean_squared_error + name = "MSE" long_name = "Mean square error" + priority = 20 class RMSE(RegressionScore): + name = "RMSE" long_name = "Root mean square error" def compute_score(self, results): return np.sqrt(MSE(results)) + priority = 30 class MAE(RegressionScore): __wraps__ = skl_metrics.mean_absolute_error + name = "MAE" long_name = "Mean absolute error" + priority = 40 + + +class MAPE(RegressionScore): + name = "MAPE" + long_name = "Mean absolute percentage error" + priority = 45 + + @staticmethod + def __wraps__(actual, predicted): + if np.any(actual == 0): + return np.inf + return np.sum(np.abs((actual - predicted) / actual)) / len(actual) * 100 + + +class SMAPE(RegressionScore): + name = "sMAPE" + long_name = "Symmetric mean absolute percentage error" + priority = 45 + + @staticmethod + def __wraps__(actual, predicted): + diff = np.abs(actual - predicted) + summ = np.abs(actual) + np.abs(predicted) + # To avoid 0 / 0, set divisor to 1; error will be 0, as it should be + summ[summ == 0] = 1.0 + error = diff / summ + return 2 * np.sum(error) / len(actual) * 100 # pylint: disable=invalid-name class R2(RegressionScore): __wraps__ = skl_metrics.r2_score + name = "R2" long_name = "Coefficient of determination" + priority = 50 class CVRMSE(RegressionScore): + name = "CVRMSE" long_name = "Coefficient of variation of the RMSE" + priority = 110 + default_visible = False def compute_score(self, results): mean = np.nanmean(results.actual) if mean < 1e-10: raise ValueError("Mean value is too small") return RMSE(results) / mean * 100 - - -# CD scores and plot - -def compute_CD(avranks, n, alpha="0.05", test="nemenyi"): - """ - Returns critical difference for Nemenyi or Bonferroni-Dunn test - according to given alpha (either alpha="0.05" or alpha="0.1") for average - ranks and number of tested datasets N. Test can be either "nemenyi" for - for Nemenyi two tailed test or "bonferroni-dunn" for Bonferroni-Dunn test. - """ - k = len(avranks) - d = {("nemenyi", "0.05"): [0, 0, 1.959964, 2.343701, 2.569032, 2.727774, - 2.849705, 2.94832, 3.030879, 3.101730, 3.163684, - 3.218654, 3.268004, 3.312739, 3.353618, 3.39123, - 3.426041, 3.458425, 3.488685, 3.517073, - 3.543799], - ("nemenyi", "0.1"): [0, 0, 1.644854, 2.052293, 2.291341, 2.459516, - 2.588521, 2.692732, 2.779884, 2.854606, 2.919889, - 2.977768, 3.029694, 3.076733, 3.119693, 3.159199, - 3.195743, 3.229723, 3.261461, 3.291224, 3.319233], - ("bonferroni-dunn", "0.05"): [0, 0, 1.960, 2.241, 2.394, 2.498, 2.576, - 2.638, 2.690, 2.724, 2.773], - ("bonferroni-dunn", "0.1"): [0, 0, 1.645, 1.960, 2.128, 2.241, 2.326, - 2.394, 2.450, 2.498, 2.539]} - q = d[(test, alpha)] - cd = q[k] * (k * (k + 1) / (6.0 * n)) ** 0.5 - return cd - - -def graph_ranks(avranks, names, cd=None, cdmethod=None, lowv=None, highv=None, - width=6, textspace=1, reverse=False, filename=None, **kwargs): - """ - Draws a CD graph, which is used to display the differences in methods' - performance. See Janez Demsar, Statistical Comparisons of Classifiers over - Multiple Data Sets, 7(Jan):1--30, 2006. - - Needs matplotlib to work. - - The image is ploted on `plt` imported using - `import matplotlib.pyplot as plt`. - - Args: - avranks (list of float): average ranks of methods. - names (list of str): names of methods. - cd (float): Critical difference used for statistically significance of - difference between methods. - cdmethod (int, optional): the method that is compared with other methods - If omitted, show pairwise comparison of methods - lowv (int, optional): the lowest shown rank - highv (int, optional): the highest shown rank - width (int, optional): default width in inches (default: 6) - textspace (int, optional): space on figure sides (in inches) for the - method names (default: 1) - reverse (bool, optional): if set to `True`, the lowest rank is on the - right (default: `False`) - filename (str, optional): output file name (with extension). If not - given, the function does not write a file. - """ - try: - import matplotlib.pyplot as plt - from matplotlib.backends.backend_agg import FigureCanvasAgg - except ImportError: - raise ImportError("Function graph_ranks requires matplotlib.") - - width = float(width) - textspace = float(textspace) - - def nth(l, n): - """ - Returns only nth elemnt in a list. - """ - n = lloc(l, n) - return [a[n] for a in l] - - def lloc(l, n): - """ - List location in list of list structure. - Enable the use of negative locations: - -1 is the last element, -2 second last... - """ - if n < 0: - return len(l[0]) + n - else: - return n - - def mxrange(lr): - """ - Multiple xranges. Can be used to traverse matrices. - This function is very slow due to unknown number of - parameters. - - >>> mxrange([3,5]) - [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)] - - >>> mxrange([[3,5,1],[9,0,-3]]) - [(3, 9), (3, 6), (3, 3), (4, 9), (4, 6), (4, 3)] - - """ - if not len(lr): - yield () - else: - # it can work with single numbers - index = lr[0] - if isinstance(index, int): - index = [index] - for a in range(*index): - for b in mxrange(lr[1:]): - yield tuple([a] + list(b)) - - def print_figure(fig, *args, **kwargs): - canvas = FigureCanvasAgg(fig) - canvas.print_figure(*args, **kwargs) - - sums = avranks - - tempsort = sorted([(a, i) for i, a in enumerate(sums)], reverse=reverse) - ssums = nth(tempsort, 0) - sortidx = nth(tempsort, 1) - nnames = [names[x] for x in sortidx] - - if lowv is None: - lowv = min(1, int(math.floor(min(ssums)))) - if highv is None: - highv = max(len(avranks), int(math.ceil(max(ssums)))) - - cline = 0.4 - - k = len(sums) - - lines = None - - linesblank = 0 - scalewidth = width - 2 * textspace - - def rankpos(rank): - if not reverse: - a = rank - lowv - else: - a = highv - rank - return textspace + scalewidth / (highv - lowv) * a - - distanceh = 0.25 - - if cd and cdmethod is None: - # get pairs of non significant methods - - def get_lines(sums, hsd): - # get all pairs - lsums = len(sums) - allpairs = [(i, j) for i, j in mxrange([[lsums], [lsums]]) if j > i] - # remove not significant - notSig = [(i, j) for i, j in allpairs - if abs(sums[i] - sums[j]) <= hsd] - # keep only longest - - def no_longer(ij_tuple, notSig): - i, j = ij_tuple - for i1, j1 in notSig: - if (i1 <= i and j1 > j) or (i1 < i and j1 >= j): - return False - return True - - longest = [(i, j) for i, j in notSig if no_longer((i, j), notSig)] - - return longest - - lines = get_lines(ssums, cd) - linesblank = 0.2 + 0.2 + (len(lines) - 1) * 0.1 - - # add scale - distanceh = 0.25 - cline += distanceh - - # calculate height needed height of an image - minnotsignificant = max(2 * 0.2, linesblank) - height = cline + ((k + 1) / 2) * 0.2 + minnotsignificant - - fig = plt.figure(figsize=(width, height)) - fig.set_facecolor('white') - ax = fig.add_axes([0, 0, 1, 1]) # reverse y axis - ax.set_axis_off() - - hf = 1. / height # height factor - wf = 1. / width - - def hfl(l): - return [a * hf for a in l] - - def wfl(l): - return [a * wf for a in l] - - - # Upper left corner is (0,0). - ax.plot([0, 1], [0, 1], c="w") - ax.set_xlim(0, 1) - ax.set_ylim(1, 0) - - def line(l, color='k', **kwargs): - """ - Input is a list of pairs of points. - """ - ax.plot(wfl(nth(l, 0)), hfl(nth(l, 1)), color=color, **kwargs) - - def text(x, y, s, *args, **kwargs): - ax.text(wf * x, hf * y, s, *args, **kwargs) - - line([(textspace, cline), (width - textspace, cline)], linewidth=0.7) - - bigtick = 0.1 - smalltick = 0.05 - - tick = None - for a in list(np.arange(lowv, highv, 0.5)) + [highv]: - tick = smalltick - if a == int(a): - tick = bigtick - line([(rankpos(a), cline - tick / 2), - (rankpos(a), cline)], - linewidth=0.7) - - for a in range(lowv, highv + 1): - text(rankpos(a), cline - tick / 2 - 0.05, str(a), - ha="center", va="bottom") - - k = len(ssums) - - for i in range(math.ceil(k / 2)): - chei = cline + minnotsignificant + i * 0.2 - line([(rankpos(ssums[i]), cline), - (rankpos(ssums[i]), chei), - (textspace - 0.1, chei)], - linewidth=0.7) - text(textspace - 0.2, chei, nnames[i], ha="right", va="center") - - for i in range(math.ceil(k / 2), k): - chei = cline + minnotsignificant + (k - i - 1) * 0.2 - line([(rankpos(ssums[i]), cline), - (rankpos(ssums[i]), chei), - (textspace + scalewidth + 0.1, chei)], - linewidth=0.7) - text(textspace + scalewidth + 0.2, chei, nnames[i], - ha="left", va="center") - - if cd and cdmethod is None: - # upper scale - if not reverse: - begin, end = rankpos(lowv), rankpos(lowv + cd) - else: - begin, end = rankpos(highv), rankpos(highv - cd) - - line([(begin, distanceh), (end, distanceh)], linewidth=0.7) - line([(begin, distanceh + bigtick / 2), - (begin, distanceh - bigtick / 2)], - linewidth=0.7) - line([(end, distanceh + bigtick / 2), - (end, distanceh - bigtick / 2)], - linewidth=0.7) - text((begin + end) / 2, distanceh - 0.05, "CD", - ha="center", va="bottom") - - # no-significance lines - def draw_lines(lines, side=0.05, height=0.1): - start = cline + 0.2 - for l, r in lines: - line([(rankpos(ssums[l]) - side, start), - (rankpos(ssums[r]) + side, start)], - linewidth=2.5) - start += height - - draw_lines(lines) - - elif cd: - begin = rankpos(avranks[cdmethod] - cd) - end = rankpos(avranks[cdmethod] + cd) - line([(begin, cline), (end, cline)], - linewidth=2.5) - line([(begin, cline + bigtick / 2), - (begin, cline - bigtick / 2)], - linewidth=2.5) - line([(end, cline + bigtick / 2), - (end, cline - bigtick / 2)], - linewidth=2.5) - - if filename: - print_figure(fig, filename, **kwargs) diff --git a/Orange/evaluation/testing.py b/Orange/evaluation/testing.py index 400bef53047..8553dc7ae0d 100644 --- a/Orange/evaluation/testing.py +++ b/Orange/evaluation/testing.py @@ -9,7 +9,7 @@ import sklearn.model_selection as skl -from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable +from Orange.data import Domain, ContinuousVariable, DiscreteVariable from Orange.data.util import get_unique_names __all__ = ["Results", "CrossValidation", "LeaveOneOut", "TestOnTrainingData", @@ -25,7 +25,7 @@ def _identity(x): def _mp_worker(fold_i, train_data, test_data, learner_i, learner, - store_models): + store_models, suppresses_exceptions=True): predicted, probs, model, failed = None, None, None, False train_time, test_time = None, None try: @@ -37,13 +37,16 @@ def _mp_worker(fold_i, train_data, test_data, learner_i, learner, train_time = time() - t0 t0 = time() # testing - if train_data.domain.has_discrete_class: + class_var = train_data.domain.class_var + if class_var and class_var.is_discrete: predicted, probs = model(test_data, model.ValueProbs) - elif train_data.domain.has_continuous_class: + else: predicted = model(test_data, model.Value) test_time = time() - t0 # Different models can fail at any time raising any exception except Exception as ex: # pylint: disable=broad-except + if not suppresses_exceptions: + raise ex failed = ex return _MpResults(fold_i, learner_i, store_models and model, failed, len(test_data), predicted, probs, @@ -95,6 +98,7 @@ def __init__(self, data=None, *, row_indices=None, folds=None, score_by_folds=True, learners=None, models=None, failed=None, actual=None, predicted=None, probabilities=None, + # pylint: disable=unused-argument store_data=None, store_models=None, train_time=None, test_time=None): """ @@ -269,7 +273,7 @@ def get_augmented_data(self, model_names, new_meta_vals = np.empty((len(data), 0)) names = [var.name for var in chain(domain.attributes, domain.metas, - [class_var])] + domain.class_vars)] if classification: # predictions @@ -311,7 +315,8 @@ def get_augmented_data(self, model_names, attrs = data.domain.attributes if include_attrs else [] domain = Domain(attrs, data.domain.class_vars, metas=new_meta_attr) predictions = data.transform(domain) - predictions.metas = new_meta_vals + with predictions.unlocked(predictions.metas): + predictions.metas = new_meta_vals predictions.name = data.name return predictions @@ -424,7 +429,8 @@ def fit(self, *args, **kwargs): DeprecationWarning) return self(*args, **kwargs) - def __call__(self, data, learners, preprocessor=None, *, callback=None): + def __call__(self, data, learners, preprocessor=None, *, callback=None, + suppresses_exceptions=True): """ Args: data (Orange.data.Table): data to be used (usually split) into @@ -433,6 +439,7 @@ def __call__(self, data, learners, preprocessor=None, *, callback=None): preprocessor (Orange.preprocess.Preprocess): preprocessor applied on training data callback (Callable): a function called to notify about the progress + suppresses_exceptions (bool): suppress the exceptions if True Returns: results (Result): results of testing @@ -455,7 +462,10 @@ def __call__(self, data, learners, preprocessor=None, *, callback=None): part_results = [] parts = np.linspace(.0, .99, len(learners) * len(indices) + 1)[1:] for progress, part in zip(parts, args_iter): - part_results.append(_mp_worker(*(part + ()))) + part_results.append( + _mp_worker(*(part + ()), + suppresses_exceptions=suppresses_exceptions) + ) callback(progress) callback(1) @@ -500,8 +510,7 @@ def prepare_arrays(cls, data, indices): ptr += len(test) row_indices = np.concatenate(row_indices, axis=0) - actual = data[row_indices].Y.ravel() - return folds, row_indices, actual + return folds, row_indices, data[row_indices].Y @staticmethod def get_indices(data): @@ -540,6 +549,11 @@ def _collect_part_results(self, results, part_results): results.failed[res.learner_i] = res.failed continue + if len(res.values.shape) > 1 and res.values.shape[1] > 1: + msg = "Multiple targets are not supported." + results.failed[res.learner_i] = ValueError(msg) + continue + if self.store_models: results.models[res.fold_i][res.learner_i] = res.model @@ -717,7 +731,7 @@ def __new__(cls, data=None, test_data=None, learners=None, test_data=test_data, **kwargs) def __call__(self, data, test_data, learners, preprocessor=None, - *, callback=None): + *, callback=None, suppresses_exceptions=True): """ Args: data (Orange.data.Table): training data @@ -726,6 +740,7 @@ def __call__(self, data, test_data, learners, preprocessor=None, preprocessor (Orange.preprocess.Preprocess): preprocessor applied on training data callback (Callable): a function called to notify about the progress + suppresses_exceptions (bool): suppress the exceptions if True Returns: results (Result): results of testing @@ -740,7 +755,7 @@ def __call__(self, data, test_data, learners, preprocessor=None, for (learner_i, learner) in enumerate(learners): part_results.append( _mp_worker(0, train_data, test_data, learner_i, learner, - self.store_models)) + self.store_models, suppresses_exceptions)) callback((learner_i + 1) / len(learners)) callback(1) @@ -750,7 +765,7 @@ def __call__(self, data, test_data, learners, preprocessor=None, nrows=len(test_data), learners=learners, row_indices=np.arange(len(test_data)), folds=(Ellipsis, ), - actual=test_data.Y.ravel(), + actual=test_data.Y, score_by_folds=self.score_by_folds, train_time=np.zeros((len(learners),)), test_time=np.zeros((len(learners),))) @@ -772,13 +787,14 @@ def __new__(cls, data=None, learners=None, preprocessor=None, **kwargs): **kwargs) def __call__(self, data, learners, preprocessor=None, *, callback=None, - **kwargs): + suppresses_exceptions=True, **kwargs): kwargs.setdefault("test_data", data) # if kwargs contains anything besides test_data, this will be detected # (and complained about) by super().__call__ return super().__call__( data=data, learners=learners, preprocessor=preprocessor, - callback=callback, **kwargs) + callback=callback, suppresses_exceptions=suppresses_exceptions, + **kwargs) def sample(table, n=0.7, stratified=False, replace=False, diff --git a/Orange/evaluation/tests/test_scoring.py b/Orange/evaluation/tests/test_scoring.py new file mode 100644 index 00000000000..8be6d2796f8 --- /dev/null +++ b/Orange/evaluation/tests/test_scoring.py @@ -0,0 +1,34 @@ +import unittest + +import numpy as np + +from Orange.evaluation import MAPE +from Orange.evaluation.scoring import SMAPE + + +class TestScoring(unittest.TestCase): + def test_mape(self): + f = MAPE.__wraps__ + exp = np.array([100, -200, 300, 60]) + pred = np.array([110, -180, 340, 60]) + self.assertEqual(f(exp, pred), (10 / 100 + 20 / 200 + 40 / 300) / 4 * 100) + + exp = np.array([0, 200, 300]) + self.assertEqual(f(exp, pred), np.inf) + + def test_smape(self): + f = SMAPE.__wraps__ + exp = np.array([100, -200, 300, 60, 80]) + pred = np.array([110, -180, -340, 60, 50]) + self.assertEqual( + f(exp, pred), + 2 * (10 / 210 + 20 / 380 + 640 / 640 + 0 / 120 + 30 / 130) / 5 * 100) + + exp = np.array([0, -200, 300, 60, 80]) + self.assertEqual( + f(exp, pred), + 2 * (110 / 110 + 20 / 380 + 640 / 640 + 0 / 120 + 30 / 130) / 5 * 100) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/misc/_distmatrix_xlsx.py b/Orange/misc/_distmatrix_xlsx.py new file mode 100644 index 00000000000..405a8154f21 --- /dev/null +++ b/Orange/misc/_distmatrix_xlsx.py @@ -0,0 +1,130 @@ +import numpy as np +import openpyxl + + +def read_matrix(filename, sheet_name=None): + sheet = _get_sheet(filename, sheet_name) + cells, empty_cols, empty_rows = _non_empty_cells(sheet) + if cells.dtype in (float, np.float64, np.float32): + return cells, None, None, 1 + + col_labels = _get_labels(cells[0]) + row_labels = _get_labels(cells[:, 0]) + if col_labels and row_labels: + col_labels = col_labels[1:] + row_labels = row_labels[1:] + cells = cells[int(bool(col_labels)):, int(bool(row_labels)):] + matrix = _matrix_from_cells( + cells, empty_cols + bool(row_labels), empty_rows + bool(col_labels)) + return matrix, row_labels, col_labels, 1 + + +def _get_sheet(filename, sheet_name): + workbook = openpyxl.load_workbook(filename, data_only=True) + if sheet_name is None: + return workbook.active + if sheet_name not in workbook.sheetnames: + raise ValueError(f"No such sheet: {sheet_name}") + return workbook.worksheets[workbook.sheetnames.index(sheet_name)] + + +def _non_empty_cells(sheet): + """ + Reported sheet.max_column and sheet.max_row may be too large, so + we must read all cells from (supposedly) used region and trim it. + Since we must remove empty rows and columns at the end anywey, we + are kind to users and also remove leading empty rows and columns. + + Returns: + - np.array with non-empty part of the sheet + - number of empty columns to the left + - number of empty rows above + """ + def raise_empty(): + raise ValueError("empty sheet") + + cells = np.array([[cell.value for cell in row] for row in sheet.rows]) + # Quick route out for any large table of numbers + if not cells.size: + raise_empty() + if np.can_cast(cells.dtype, float): + return cells.astype(float), 0, 0 + + # compares by array cells, pylint: disable=singleton-comparison + nonempty = cells != None + offsets = [] + for _ in range(2): + nonem = np.cumsum(np.any(nonempty, axis=1)) + if nonem[-1] == 0: + raise_empty() + mask = (nonem > 0) & (nonem < nonem[-1]) + # The last element that increased cumsum is also non-empty + mask[1:] |= mask[:-1] + offsets.append(np.sum(nonem == 0)) + cells = cells[mask] + cells = cells.T + nonempty = nonempty.T + return cells, *offsets + + +def _get_labels(labels): + try: + for label in labels[1:]: + # pylint: disable=expression-not-assigned + label is None or float(label) + except ValueError: + return ["?" if label is None else str(label) + for label in labels] + else: + return None + + +def _matrix_from_cells(cells, row_offset, col_offset): + matrix = np.full(cells.shape, np.nan) + for y, row in enumerate(cells): + for x, value in enumerate(row): + if value is None: + continue + if isinstance(value, (int, float)): + matrix[y, x] = value + continue + try: + # Triggers AttributeError, if value is not a string + if not value.strip(): + continue + # Triggers ValueError if not a number + matrix[y, x] = float(value) + except (AttributeError, ValueError): + raise ValueError( + "invalid data in cell " + f"{openpyxl.utils.get_column_letter(x + col_offset + 1)}" + f"{y + row_offset + 1}") from None + return matrix + + +def write_matrix(matrix: "DistMatrix", filename): + wb = openpyxl.Workbook() + sheet = wb.active + row_labels = matrix.get_labels(matrix.row_items) + col_labels = (matrix.col_items is not matrix.row_items) \ + and matrix.get_labels(matrix.col_items) + has_row_labels = row_labels is not None + has_col_labels = col_labels is not None and col_labels is not False + row_off = 1 + int(has_col_labels) + col_off = 1 + int(has_row_labels) + + if has_row_labels: + for row, label in enumerate(row_labels, start=row_off): + sheet.cell(row, 1).value = label + if has_col_labels: + for col, label in enumerate(col_labels, start=col_off): + sheet.cell(1, col).value = label + symmetric = matrix.is_symmetric() + has_diagonal = int(np.any(np.diag(matrix) != 0)) + for y in range(matrix.shape[0]): + for x in range(y + has_diagonal if symmetric else matrix.shape[1]): + value = matrix[y, x] + if not np.isnan(value): + sheet.cell(y + row_off, x + col_off).value = value + + wb.save(filename) diff --git a/Orange/misc/cache.py b/Orange/misc/cache.py index ee165934a3f..d813fba5d59 100644 --- a/Orange/misc/cache.py +++ b/Orange/misc/cache.py @@ -1,5 +1,6 @@ """Common caching methods, using `lru_cache` sometimes has its downsides.""" from functools import wraps, lru_cache +from typing import MutableMapping import weakref @@ -54,3 +55,31 @@ def _wrapped_func(self, *args, **kwargs): return _wrapped_func return _decorator + + +class IDWeakrefCache: + """ + Cache that caches keys according to their id() for speed. It also stores + weak references to the keys to ensure that the same keys are being accessed. + """ + + def __init__(self, cache: MutableMapping): + self._cache = cache + + def __setitem__(self, keys, value): + self._cache[tuple(map(id, keys))] = \ + value, [weakref.ref(k) for k in keys] + + def __getitem__(self, keys): + key = tuple(map(id, keys)) + if key not in self._cache: + raise KeyError() + shared, weakrefs = self._cache[key] + for r in weakrefs: + if r() is None: + del self._cache[key] + raise KeyError() + return shared + + def clear(self): + self._cache.clear() diff --git a/Orange/misc/collections.py b/Orange/misc/collections.py index 94586a5a4f3..1325e40b72c 100644 --- a/Orange/misc/collections.py +++ b/Orange/misc/collections.py @@ -53,3 +53,57 @@ def natural_keys(element): return element return sorted(values, key=natural_keys) + + +class DictMissingConst(dict): + """ + `dict` with a constant for `__missing__()` value. + + This is mostly used for speed optimizations where + `DictMissingConst(default, d).__getitem__(k)` is the least overhead + equivalent to `d.get(k, default)` in the case where misses are not + frequent by avoiding LOAD_* bytecode instructions for `default` at + every call. + + Note + ---- + This differs from `defaultdict(lambda: CONST)` in that misses do not + grow the dict. + + Parameters + ---------- + missing: Any + The missing constant + *args + **kwargs + The `*args`, and `**kwargs` are passed to `dict` constructor. + """ + __slots__ = ("__missing",) + + def __init__(self, missing, *args, **kwargs): + self.__missing = missing + super().__init__(*args, **kwargs) + + @property + def missing(self): + return self.__missing + + def __missing__(self, key): + return self.__missing + + def __eq__(self, other): + return super().__eq__(other) and isinstance(other, DictMissingConst) \ + and self.missing == other.missing + + def __ne__(self, other): + return not self.__eq__(other) + + def __reduce_ex__(self, protocol): + return type(self), (self.missing, list(self.items())), \ + getattr(self, "__dict__", None) + + def copy(self): + return type(self)(self.missing, self) + + def __repr__(self): + return f"{type(self).__qualname__}({self.missing!r}, {dict(self)!r})" diff --git a/Orange/misc/distmatrix.py b/Orange/misc/distmatrix.py index b41f6ba098a..b72ca41e55f 100644 --- a/Orange/misc/distmatrix.py +++ b/Orange/misc/distmatrix.py @@ -1,5 +1,8 @@ +import os.path + import numpy as np +from Orange.misc import _distmatrix_xlsx from Orange.util import deprecated @@ -40,6 +43,7 @@ def __new__(cls, data, row_items=None, col_items=None, axis=1): return obj def __array_finalize__(self, obj): + # defined in __new___, pylint: disable=attribute-defined-outside-init """See http://docs.scipy.org/doc/numpy/user/basics.subclassing.html""" if obj is None: return @@ -47,14 +51,13 @@ def __array_finalize__(self, obj): self.col_items = getattr(obj, 'col_items', None) self.axis = getattr(obj, 'axis', 1) - def __array_wrap__(self, out_arr, context=None): + def __array_wrap__(self, out_arr, context=None, return_scalar=False): if out_arr.ndim == 0: # a single scalar return out_arr[()] + del return_scalar # added support for the argument due to a deprecation warning return np.ndarray.__array_wrap__(self, out_arr, context) - """ - __reduce__() and __setstate__() ensure DistMatrix is picklable. - """ + # __reduce__() and __setstate__() ensure DistMatrix is picklable. def __reduce__(self): state = super().__reduce__() newstate = state[2] + (self.row_items, self.col_items, self.axis) @@ -62,6 +65,7 @@ def __reduce__(self): # noinspection PyMethodOverriding,PyArgumentList def __setstate__(self, state): + # defined in __new___, pylint: disable=attribute-defined-outside-init self.row_items = state[-3] self.col_items = state[-2] self.axis = state[-1] @@ -94,17 +98,21 @@ def submatrix(self, row_items, col_items=None): if not col_items: col_items = row_items obj = self[np.ix_(row_items, col_items)] - if self.row_items is not None: + if isinstance(self.row_items, list): + obj.row_items = list(np.array(self.row_items)[row_items]) + elif self.row_items is not None: obj.row_items = self.row_items[row_items] - if self.col_items is not None: - if self.col_items is self.row_items and row_items is col_items: - obj.col_items = obj.row_items - else: - obj.col_items = self.col_items[col_items] + + if self.col_items is self.row_items and col_items is row_items: + obj.col_items = obj.row_items + elif isinstance(self.col_items, list): + obj.col_items = list(np.array(self.col_items)[col_items]) + elif self.col_items is not None: + obj.col_items = self.col_items[col_items] return obj @classmethod - def from_file(cls, filename): + def from_file(cls, filename, sheet=None): """ Load distance matrix from a file @@ -138,8 +146,32 @@ def from_file(cls, filename): Args: filename: file name """ - # prevent circular imports + _, ext = os.path.splitext(filename) + if ext == ".xlsx": + matrix, row_labels, col_labels, axis \ + = _distmatrix_xlsx.read_matrix(filename, sheet) + else: + assert sheet is None + matrix, row_labels, col_labels, axis = cls._from_dst(filename) + return cls(matrix, + cls._labels_to_tables(row_labels), + cls._labels_to_tables(col_labels), + axis) + + @staticmethod + def _labels_to_tables(labels): + # prevent circular imports, pylint: disable=import-outside-toplevel from Orange.data import Table, StringVariable, Domain + + if labels is None or isinstance(labels, Table): + return labels + return Table.from_numpy( + Domain([], metas=[StringVariable("label")]), + np.empty((len(labels), 0)), None, np.array(labels)[:, None]) + + @classmethod + def _from_dst(cls, filename): + # prevent circular imports, pylint: disable=import-outside-toplevel from Orange.data.io import detect_encoding with open(filename, encoding=detect_encoding(filename)) as fle: @@ -171,56 +203,115 @@ def from_file(cls, filename): if name == "axis" and value.isdigit(): axis = int(value) else: - raise ValueError("invalid flag '{}'".format( - flag, filename)) + raise ValueError(f"invalid flag '{flag}'") if col_labels is not None: col_labels = [x.strip() for x in fle.readline().strip().split("\t")] if len(col_labels) != n: - raise ValueError("mismatching number of column labels") + raise ValueError("mismatching number of column labels, " + f"{len(col_labels)} != {n}") + + def num_or_lab(n, labels): + return f"'{labels[n]}'" if labels else str(n + 1) matrix = np.zeros((n, n)) for i, line in enumerate(fle): if i >= n: - raise ValueError("too many rows".format(filename)) + raise ValueError("too many rows") line = line.strip().split("\t") if row_labels is not None: row_labels.append(line.pop(0).strip()) if len(line) > n: - raise ValueError("too many columns in matrix row {}". - format("'{}'".format(row_labels[i]) - if row_labels else i + 1)) + raise ValueError( + f"too many columns in matrix row " + f"{num_or_lab(i, row_labels)}") for j, e in enumerate(line[:i + 1 if symmetric else n]): try: matrix[i, j] = float(e) except ValueError as exc: raise ValueError( - "invalid element at row {}, column {}".format( - "'{}'".format(row_labels[i]) - if row_labels else i + 1, - "'{}'".format(col_labels[j]) - if col_labels else j + 1)) from exc + "invalid element at " + f"row {num_or_lab(i, row_labels)}, " + f"column {num_or_lab(j, col_labels)}") from exc if symmetric: matrix[j, i] = matrix[i, j] - if col_labels: - col_labels = Table.from_list( - Domain([], metas=[StringVariable("label")]), - [[item] for item in col_labels]) - if row_labels: - row_labels = Table.from_list( - Domain([], metas=[StringVariable("label")]), - [[item] for item in row_labels]) - return cls(matrix, row_labels, col_labels, axis) - - @staticmethod - def _trivial_labels(items): - # prevent circular imports + return matrix, row_labels, col_labels, axis + + def auto_symmetricized(self, copy=False): + def self_or_copy(): + return self.copy() if copy else self + + def get_labels(labels): + return np.array(labels) if isinstance(labels, list) \ + else labels.metas[:, 0] if self._trivial_labels(labels) \ + else object() + + h, w = self.shape + m = max(w, h) + if (abs(h - w) > 1 + or self.row_items and self.col_items + and np.any(get_labels(self.row_items) + != get_labels(self.col_items)) + or self.row_items and len(self.row_items) != m + or self.col_items and len(self.col_items) != m): + return self_or_copy() + + nans = np.isnan(self) + low_indices = np.tril_indices(h, -1) + low_empty = np.all(nans[low_indices]) + high_indices = np.triu_indices(w, 1) + high_empty = np.all(nans[high_indices]) + if low_empty is high_empty: # both non-empty, or both empty (only diagonal) + return self_or_copy() + + indices = low_indices if low_empty else high_indices + if w == h: + matrix = np.array(self) + else: + if low_empty: + row = np.vstack((self[:, -1, None], [[0]])).T + matrix = np.vstack((self, row)) + else: + col = np.hstack((self[-1, None], [[0]])).T + matrix = np.hstack((self, col)) + diag_indices = np.diag_indices(len(matrix)) + matrix[diag_indices] = np.nan_to_num(matrix[diag_indices]) + matrix[indices] = self.T[indices] + return type(self)(matrix, + self.row_items or self.col_items, + self.col_items or self.row_items) + + def _trivial_labels(self, items): + # prevent circular imports, pylint: disable=import-outside-toplevel from Orange.data import Table, StringVariable - return items and \ - isinstance(items, Table) and \ - len(items.domain.metas) == 1 and \ - isinstance(items.domain.metas[0], StringVariable) + return (isinstance(items, (list, tuple)) + and all(isinstance(item, str) for item in items) + or + isinstance(items, Table) + and (self.axis == 0 or + sum(isinstance(meta, StringVariable) + for meta in items.domain.metas) == 1 + ) + ) + + def is_symmetric(self): + # prevent circular imports, pylint: disable=import-outside-toplevel + from Orange.data import Table + + if self.shape[0] != self.shape[1] or not np.allclose(self, self.T): + return False + if self.row_items is None or self.col_items is None: + return True + if isinstance(self.row_items, Table): + return (isinstance(self.col_items, Table) + and self.col_items.domain == self.row_items.domain + and np.array_equal(self.col_items.X, self.row_items.X) + and np.array_equal(self.col_items.Y, self.row_items.Y) + and np.array_equal(self.col_items.metas, self.row_items.metas)) + else: + return (not isinstance(self.col_items, Table) + and np.array_equal(self.row_items, self.col_items)) def has_row_labels(self): """ @@ -243,7 +334,29 @@ def has_col_labels(self): """ return self._trivial_labels(self.col_items) + def get_labels(self, items): + # prevent circular imports, pylint: disable=import-outside-toplevel + from Orange.data import StringVariable + + if not self._trivial_labels(items): + return None + if isinstance(items, (list, tuple)) \ + and all(isinstance(x, str) for x in items): + return items + if self.axis == 0: + return [attr.name for attr in items.domain.attributes] + else: + string_var = next(var for var in items.domain.metas + if isinstance(var, StringVariable)) + return items.get_column(string_var) + def save(self, filename): + if os.path.splitext(filename)[1] == ".xlsx": + _distmatrix_xlsx.write_matrix(self, filename) + else: + self._save_dst(filename) + + def _save_dst(self, filename): """ Save the distance matrix to a file in the file format described at :obj:`~Orange.misc.distmatrix.DistMatrix.from_file`. @@ -252,7 +365,7 @@ def save(self, filename): filename: file name """ n = len(self) - data = "{}\taxis={}".format(n, self.axis) + data = f"{n}\taxis={self.axis}" row_labels = col_labels = None if self.has_col_labels(): data += "\tcol_labels" @@ -260,10 +373,10 @@ def save(self, filename): if self.has_row_labels(): data += "\trow_labels" row_labels = self.row_items - symmetric = np.allclose(self, self.T) + symmetric = self.is_symmetric() if not symmetric: data += "\tasymmetric" - with open(filename, "wt") as fle: + with open(filename, "wt", encoding="utf-8") as fle: fle.write(data + "\n") if col_labels is not None: fle.write("\t".join(str(e.metas[0]) for e in col_labels) + "\n") diff --git a/Orange/misc/server_embedder.py b/Orange/misc/server_embedder.py index bdcf945952d..4141c925f80 100644 --- a/Orange/misc/server_embedder.py +++ b/Orange/misc/server_embedder.py @@ -3,29 +3,37 @@ import logging import random import uuid +from collections import namedtuple from json import JSONDecodeError from os import getenv -from typing import Any, Callable, List, Optional +from typing import Any, Callable, List, Optional, Dict, Union from AnyQt.QtCore import QSettings -from httpx import AsyncClient, NetworkError, ReadTimeout, Response +from httpx import AsyncClient, NetworkError, ReadTimeout, Response, AsyncHTTPTransport +from numpy import linspace -from Orange.misc.utils.embedder_utils import (EmbedderCache, - EmbeddingCancelledException, - EmbeddingConnectionError, - get_proxies) +from Orange.misc.utils.embedder_utils import ( + EmbedderCache, + EmbeddingConnectionError, + get_proxies, +) +from Orange.util import dummy_callback log = logging.getLogger(__name__) +TaskItem = namedtuple("TaskItem", ("id", "item", "no_repeats")) + + +def _rewrite_proxies_to_mounts(proxies): + if proxies is None: + return None + return {c: AsyncHTTPTransport(proxy=url) for c, url in get_proxies().items()} class ServerEmbedderCommunicator: """ This class needs to be inherited by the class which re-implements - _encode_data_instance and defines self.content_type. For sending a table - with data items use embedd_table function. This one is called with the - complete Orange data Table. Then _encode_data_instance needs to extract - data to be embedded from the RowInstance. For images, it takes the image - path from the table, load image, and transform it into bytes. + _encode_data_instance and defines self.content_type. For sending a list + with data items use embedd_table function. Attributes ---------- @@ -58,10 +66,6 @@ def __init__( self._model = model_name self.embedder_type = embedder_type - # attribute that offers support for cancelling the embedding - # if ran in another thread - self._cancelled = False - self.machine_id = None try: self.machine_id = QSettings().value( @@ -69,20 +73,21 @@ def __init__( ) or str(uuid.getnode()) except TypeError: self.machine_id = str(uuid.getnode()) - self.session_id = str(random.randint(1, 1e10)) + self.session_id = str(random.randint(1, int(1e10))) self._cache = EmbedderCache(model_name) # default embedding timeouts are too small we need to increase them self.timeout = 180 - self.num_parallel_requests = 0 - self.max_parallel = max_parallel_requests + self.max_parallel_requests = max_parallel_requests + self.content_type = None # need to be set in a class inheriting def embedd_data( - self, - data: List[Any], - processed_callback: Callable[[bool], None] = None, + self, + data: List[Any], + *, + callback: Callable = dummy_callback, ) -> List[Optional[List[float]]]: """ This function repeats calling embedding function until all items @@ -93,10 +98,8 @@ def embedd_data( ---------- data List with data that needs to be embedded. - processed_callback - A function that is called after each item is embedded - by either getting a successful response from the server, - getting the result from cache or skipping the item. + callback + Callback for reporting the progress in share of embedded items Returns ------- @@ -111,25 +114,18 @@ def embedd_data( EmbeddingCancelledException: If cancelled attribute is set to True (default=False). """ - # if there is less items than 10 connection error should be raised - # earlier + # if there is less items than 10 connection error should be raised earlier self.max_errors = min(len(data) * self.MAX_REPEATS, 10) - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - embeddings = asyncio.get_event_loop().run_until_complete( - self.embedd_batch(data, processed_callback) - ) - except Exception: - loop.close() - raise - - loop.close() - return embeddings + return asyncio.run( + self.embedd_batch(data, callback=callback) + ) async def embedd_batch( - self, data: List[Any], proc_callback: Callable[[bool], None] = None + self, + data: List[Any], + *, + callback: Callable = dummy_callback, ) -> List[Optional[List[float]]]: """ Function perform embedding of a batch of data items. @@ -138,10 +134,8 @@ async def embedd_batch( ---------- data A list of data that must be embedded. - proc_callback - A function that is called after each item is fully processed - by either getting a successful response from the server, - getting the result from cache or skipping the item. + callback + Callback for reporting the progress in share of embedded items Returns ------- @@ -153,32 +147,59 @@ async def embedd_batch( EmbeddingCancelledException: If cancelled attribute is set to True (default=False). """ - requests = [] - async with AsyncClient( - timeout=self.timeout, base_url=self.server_url, proxies=get_proxies() - ) as client: - for p in data: - if self._cancelled: - raise EmbeddingCancelledException() - requests.append(self._send_to_server(p, client, proc_callback)) + progress_items = iter(linspace(0, 1, len(data))) + + def success_callback(): + """Callback called on every successful embedding""" + callback(next(progress_items)) - embeddings = await asyncio.gather(*requests) - self._cache.persist_cache() - assert self.num_parallel_requests == 0 + results = [None] * len(data) + queue = asyncio.Queue() - return embeddings + # fill the queue with items to embedd + for i, item in enumerate(data): + queue.put_nowait(TaskItem(id=i, item=item, no_repeats=0)) - async def __wait_until_released(self) -> None: - while self.num_parallel_requests >= self.max_parallel: - await asyncio.sleep(0.1) + proxy_mounts = _rewrite_proxies_to_mounts(get_proxies()) - def __check_cancelled(self): - if self._cancelled: - raise EmbeddingCancelledException() + async with AsyncClient( + timeout=self.timeout, base_url=self.server_url, mounts=proxy_mounts + ) as client: + tasks = self._init_workers(client, queue, results, success_callback) - async def _encode_data_instance( - self, data_instance: Any - ) -> Optional[bytes]: + try: + # wait for workers to stop - they stop when queue is empty + # if one worker raises exception wait will raise it further + await asyncio.gather(*tasks) + finally: + await self._cancel_workers(tasks) + self._cache.persist_cache() + + return results + + def _init_workers(self, client, queue, results, callback): + """Init required number of workers""" + t = [ + asyncio.create_task(self._send_to_server(client, queue, results, callback)) + # when number of instances less than max_parallel_requests create + # only required number of workers + for _ in range(min(self.max_parallel_requests, len(results))) + ] + log.debug("Created %d workers", self.max_parallel_requests) + return t + + @staticmethod + async def _cancel_workers(tasks): + """Cancel worker at the end""" + log.debug("Canceling workers") + # cancel all tasks in both cases + for task in tasks: + task.cancel() + # Wait until all worker tasks are cancelled. + await asyncio.gather(*tasks, return_exceptions=True) + log.debug("All workers canceled") + + async def _encode_data_instance(self, data_instance: Any) -> Optional[bytes]: """ The reimplementation of this function must implement the procedure to encode the data item in a string format that will be sent to the @@ -197,66 +218,73 @@ async def _encode_data_instance( raise NotImplementedError async def _send_to_server( - self, - data_instance: Any, - client: AsyncClient, - proc_callback: Callable[[bool], None] = None, - ) -> Optional[List[float]]: + self, + client: AsyncClient, + queue: asyncio.Queue, + results: List, + proc_callback: Callable, + ): """ - Function get an data instance. It extract data from it and send them to - server and retrieve responses. + Worker that embedds data. It is pulling items from the queue until + it is empty. It is runs until anything is in the queue, or it is canceled Parameters ---------- - data_instance - Single row of the input table. client HTTPX client that communicates with the server + queue + The queue with items of type TaskItem to be embedded + results + The list to append results in. The list has length equal to numbers + of all items to embedd. The result need to be inserted at the index + defined in queue items. proc_callback A function that is called after each item is fully processed by either getting a successful response from the server, getting the result from cache or skipping the item. - - Returns - ------- - Embedding. For items that are not successfully embedded returns None. """ - await self.__wait_until_released() - self.__check_cancelled() - - self.num_parallel_requests += 1 - # load bytes - data_bytes = await self._encode_data_instance(data_instance) - if data_bytes is None: - self.num_parallel_requests -= 1 - return None - - # if data in cache return it - cache_key = self._cache.md5_hash(data_bytes) - emb = self._cache.get_cached_result_or_none(cache_key) - - if emb is None: - # in case that embedding not sucessfull resend it to the server - # maximally for MAX_REPEATS time - for i in range(1, self.MAX_REPEATS + 1): - self.__check_cancelled() + while not queue.empty(): + # get item from the queue + i, data_instance, num_repeats = await queue.get() + + # load bytes + data_bytes = await self._encode_data_instance(data_instance) + if data_bytes is None: + continue + + # retrieve embedded item from the local cache + cache_key = self._cache.md5_hash(data_bytes) + log.debug("Embedding %s", cache_key) + emb = self._cache.get_cached_result_or_none(cache_key) + + if emb is None: + # send the item to the server for embedding if not in the local cache + log.debug("Sending to the server: %s", cache_key) url = ( - f"/{self.embedder_type}/{self._model}?" - f"machine={self.machine_id}" - f"&session={self.session_id}&retry={i}" + f"/{self.embedder_type}/{self._model}?machine={self.machine_id}" + f"&session={self.session_id}&retry={num_repeats+1}" ) emb = await self._send_request(client, data_bytes, url) if emb is not None: self._cache.add(cache_key, emb) - break # repeat only when embedding None - if proc_callback: - proc_callback(emb is not None) - self.num_parallel_requests -= 1 - return emb + if emb is not None: + # store result if embedding is successful + log.debug("Successfully embedded: %s", cache_key) + results[i] = emb + proc_callback() + elif num_repeats+1 < self.MAX_REPEATS: + log.debug("Embedding unsuccessful - reading to queue: %s", cache_key) + # if embedding not successful put the item to queue to be handled at + # the end - the item is put to the end since it is possible that server + # still process the request and the result will be in the cache later + # repeating the request immediately may result in another fail when + # processing takes longer + queue.put_nowait(TaskItem(i, data_instance, no_repeats=num_repeats+1)) + queue.task_done() async def _send_request( - self, client: AsyncClient, data: bytes, url: str + self, client: AsyncClient, data: Union[bytes, Dict], url: str ) -> Optional[List[float]]: """ This function sends a single request to the server. @@ -281,30 +309,28 @@ async def _send_request( "Content-Length": str(len(data)), } try: - response = await client.post(url, headers=headers, data=data) + # bytes are sent as content parameter and dictionary as data + kwargs = dict(content=data) if isinstance(data, bytes) else dict(data=data) + response = await client.post(url, headers=headers, **kwargs) except ReadTimeout as ex: log.debug("Read timeout", exc_info=True) - # it happens when server do not respond in 60 seconds, in - # this case we return None and items will be resend later + # it happens when server do not respond in time defined by timeout + # return None and items will be resend later # if it happens more than in ten consecutive cases it means # sth is wrong with embedder we stop embedding self.count_read_errors += 1 - if self.count_read_errors >= self.max_errors: - self.num_parallel_requests = 0 # for safety reasons raise EmbeddingConnectionError from ex return None except (OSError, NetworkError) as ex: log.debug("Network error", exc_info=True) - # it happens when no connection and items cannot be sent to the - # server - # we count number of consecutive errors + # it happens when no connection and items cannot be sent to server + # if more than 10 consecutive errors it means there is no # connection so we stop embedding with EmbeddingConnectionError self.count_connection_errors += 1 if self.count_connection_errors >= self.max_errors: - self.num_parallel_requests = 0 # for safety reasons raise EmbeddingConnectionError from ex return None except Exception: @@ -342,6 +368,3 @@ def _parse_response(response: Response) -> Optional[List[float]]: def clear_cache(self): self._cache.clear_cache() - - def set_cancelled(self): - self._cancelled = True diff --git a/Orange/misc/tests/test_collections.py b/Orange/misc/tests/test_collections.py index 9769cb61424..4185cf38d9a 100644 --- a/Orange/misc/tests/test_collections.py +++ b/Orange/misc/tests/test_collections.py @@ -1,6 +1,7 @@ +import pickle import unittest -from Orange.misc.collections import frozendict, natural_sorted +from Orange.misc.collections import frozendict, natural_sorted, DictMissingConst class TestFrozenDict(unittest.TestCase): @@ -61,5 +62,23 @@ def test_natural_sorted_numbers(self): self.assertListEqual(res, natural_sorted(data)) +class TestDictMissingConst(unittest.TestCase): + def test_dict_missing(self): + d = DictMissingConst("<->", {1: 1, 2: 2}) + self.assertEqual(d[1], 1) + self.assertEqual(d[-1], "<->") + # d[-1] must not grow the dict + self.assertEqual(len(d), 2) + self.assertEqual(d, DictMissingConst("<->", {1: 1, 2: 2})) + self.assertNotEqual( + DictMissingConst("A", {1: 1}), DictMissingConst("B", {1: 1}), + ) + dc = pickle.loads(pickle.dumps(d)) + self.assertEqual(d, dc) + self.assertEqual(dict(d), dict(dc)) + self.assertEqual(d.missing, dc.missing) + self.assertEqual(d[object()], dc[object()]) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/misc/tests/test_distmatrix.py b/Orange/misc/tests/test_distmatrix.py new file mode 100644 index 00000000000..ea7c3b2e467 --- /dev/null +++ b/Orange/misc/tests/test_distmatrix.py @@ -0,0 +1,161 @@ +# pylint: disable=protected-access + +import unittest +from unittest.mock import patch + +import numpy as np +from Orange.data import ContinuousVariable, StringVariable, Table, Domain +from Orange.misc import DistMatrix + + +class DistMatrixTest(unittest.TestCase): + def test_reader_selection(self): + with patch("Orange.misc._distmatrix_xlsx.read_matrix") as read_matrix, \ + patch.object(DistMatrix, "_from_dst") as _from_dst: + read_matrix.return_value = (np.zeros((3, 4)), None, None, 1) + _from_dst.return_value = (np.zeros((2, 2)), None, None, 1) + + matrix = DistMatrix.from_file("test.dst") + self.assertEqual(matrix.shape, (2, 2)) + + matrix = DistMatrix.from_file("test.xlsx") + self.assertEqual(matrix.shape, (3, 4)) + + def test_auto_symmetricized_result(self): + data = np.array([[np.nan, np.nan, np.nan], + [1528.13, np.nan, np.nan], + [1497.61, 999.25, np.nan], + [1062.89, 1372.59, 651.62]]) + + exp_sym = np.array([[1528.13, 1497.61, 1062.89], + [1497.61, 999.25, 1372.59], + [1062.89, 1372.59, 651.62]]) + + labels = list("ABC") + for ri, li in ((labels, labels), + (labels, None), + (None, labels), + (None, None)): + matrix = DistMatrix(data[1:], ri, li) + sym = matrix.auto_symmetricized() + np.testing.assert_almost_equal(sym, exp_sym) + self.assertEqual(sym.row_items, sym.col_items) + self.assertIs((ri or li), sym.row_items) + + matrix = DistMatrix(data[1:].T) + sym = matrix.auto_symmetricized() + np.testing.assert_almost_equal(sym, exp_sym) + + labels = list("ABCD") + data[1, 1] = 2 + exp_sym = np.array( + [[ 0. , 1528.13, 1497.61, 1062.89], + [1528.13, 2. , 999.25, 1372.59], + [1497.61, 999.25, 0. , 651.62], + [1062.89, 1372.59, 651.62, 0. ]]) + matrix = DistMatrix(data, labels) + sym = matrix.auto_symmetricized() + np.testing.assert_almost_equal(sym, exp_sym) + + matrix = DistMatrix(data.T, None, labels) + sym = matrix.auto_symmetricized() + np.testing.assert_almost_equal(sym, exp_sym) + + def test_auto_symmetricized_dont_apply(self): + data = DistMatrix(np.array([[np.nan, np.nan]] * 3 + [[1, np.nan]])) + self.assertIs(data.auto_symmetricized(), data) + + data = np.array([[np.nan, np.nan, 1], + [1528.13, np.nan, np.nan], + [1497.61, 999.25, np.nan], + [1062.89, 1372.59, 651.62]]) + matrix = DistMatrix(data) + self.assertIs(matrix.auto_symmetricized(), matrix) + + data = np.array([[np.nan, np.nan, 1], + [1528.13, np.nan, np.nan], + [1497.61, 999.25, np.nan], + [1062.89, 1372.59, 651.62]]) + matrix = DistMatrix(data) + sym = matrix.auto_symmetricized(copy=True) + np.testing.assert_equal(matrix, sym) + self.assertIsNot(sym, matrix) + + matrix = DistMatrix(data.T) + self.assertIs(matrix.auto_symmetricized(), matrix) + + data = np.array([[np.nan, np.nan, np.nan], + [1528.13, np.nan, np.nan], + [1497.61, 999.25, np.nan], + [1062.89, 1372.59, 651.62]]) + matrix = DistMatrix(data, None, list("abc")) + self.assertIs(matrix.auto_symmetricized(), matrix) + + matrix = DistMatrix(data.T, list("abc")) + self.assertIs(matrix.auto_symmetricized(), matrix) + + data = np.array([[1528.13, np.nan, np.nan], + [1497.61, 999.25, np.nan], + [1062.89, 1372.59, 651.62]]) + matrix = DistMatrix(data, list("def"), list("abc")) + self.assertIs(matrix.auto_symmetricized(), matrix) + + def test_trivial_labels(self): + matrix = DistMatrix(np.array([[1, 2, 3], [4, 5, 6]])) + + self.assertFalse(matrix._trivial_labels(matrix.row_items)) + self.assertIsNone(matrix.get_labels(matrix.row_items)) + + matrix.row_items = list("abc") + self.assertTrue(matrix._trivial_labels(matrix.row_items)) + self.assertEqual(matrix.get_labels(matrix.row_items), list("abc")) + + matrix.row_items = ["a", 1, "c"] + self.assertFalse(matrix._trivial_labels(matrix.row_items)) + self.assertIsNone(matrix.get_labels(matrix.row_items)) + + c1, c2 = (ContinuousVariable(c) for c in "xy") + s1, s2 = (StringVariable(c) for c in "st") + data = Table.from_list(Domain([c1], None, [c2, s1]), + [[1, 0, "a"], [2, 2, "b"], [3, 1, "c"]]) + matrix.row_items = data + + matrix.axis = 1 + self.assertTrue(matrix._trivial_labels(matrix.row_items)) + self.assertEqual(list(matrix.get_labels(matrix.row_items)), list("abc")) + + matrix.axis = 0 + self.assertTrue(matrix._trivial_labels(matrix.row_items)) + self.assertEqual(list(matrix.get_labels(matrix.row_items)), list("x")) + + + data = Table.from_list(Domain([c1], None, [c2, s1, s2]), + [[1, 2, "a", "2"], + [2, 4, "b", "5"], + [3, 0, "c", "g"]]) + matrix.row_items = data + + matrix.axis = 1 + self.assertFalse(matrix._trivial_labels(matrix.row_items)) + self.assertIsNone(matrix.get_labels(matrix.row_items)) + + matrix.axis = 0 + self.assertTrue(matrix._trivial_labels(matrix.row_items)) + self.assertEqual(list(matrix.get_labels(matrix.row_items)), list("x")) + + data = Table.from_list(Domain([c1], None, [c2]), + [[1, 2], + [2, 4], + [3, 0]]) + matrix.row_items = data + matrix.axis = 1 + self.assertFalse(matrix._trivial_labels(matrix.row_items)) + self.assertIsNone(matrix.get_labels(matrix.row_items)) + + matrix.axis = 0 + self.assertTrue(matrix._trivial_labels(matrix.row_items)) + self.assertEqual(matrix.get_labels(matrix.row_items), list("x")) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/misc/tests/test_distmatrix_xlsx.py b/Orange/misc/tests/test_distmatrix_xlsx.py new file mode 100644 index 00000000000..16788b47b5d --- /dev/null +++ b/Orange/misc/tests/test_distmatrix_xlsx.py @@ -0,0 +1,263 @@ +# pylint: disable=protected-access + +import os +import unittest +from unittest.mock import patch, Mock + +import numpy as np +import openpyxl + +from Orange.misc import DistMatrix +from Orange.misc._distmatrix_xlsx import read_matrix, _get_sheet, \ + _non_empty_cells, _get_labels, _matrix_from_cells, write_matrix + +import Orange.tests +from Orange.tests import named_file + +files_dir = os.path.join(os.path.split(Orange.tests.__file__)[0], "xlsx_files") + + +class ReadMatrixTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.file = os.path.join(files_dir, "distances.xlsx") + + def test_layouts(self): + def test(sheet, exp_matrix, exp_row_labels, exp_col_labels): + matrix, row_labels, col_labels, _ = read_matrix(self.file, sheet) + np.testing.assert_almost_equal(matrix, exp_matrix) + self.assertEqual(row_labels, exp_row_labels) + self.assertEqual(col_labels, exp_col_labels) + + labels = "Barcelona Belgrade Berlin Brussels".split() + data = np.array([[np.nan, np.nan, np.nan], + [1528.13, np.nan, np.nan], + [1497.61, 999.25, np.nan], + [1062.89, 1372.59, 651.62]]) + + test("lower_row_labels", data, labels, None) + test("upper_col_labels", data.T, None, labels) + + data = np.array([[1528.13, np.nan, np.nan, np.nan], + [1497.61, 999.25, np.nan, np.nan], + [1062.89, 1372.59, 651.62, np.nan]]) + test("lower_col_labels", data, None, labels) + + data = np.array([[np.nan, 1528.13, 1497.61, 1062.89], + [np.nan, np.nan, 999.25, 1372.59], + [np.nan, np.nan, np.nan, 651.62], + [np.nan, np.nan, np.nan, np.nan]]) + test("upper_row_labels", data, labels, None) + test("upper_both_labels", data, list("AERU"), labels) + test("lower_both_labels", data.T, labels, list("AERU")) + test("upper_no_labels", data[:-1, 1:], None, None) + test("lower_no_labels", data[:-1, 1:].T, None, None) + + data[np.diag_indices(4)] = [1, 2, 3, 4] + test("upper_with_diag", data, labels, None) + test("lower_with_diag", data.T, labels, None) + test("with_nans", + np.array([[1, np.nan, 1, 2], + [np.nan, 2, np.nan, 4], + [2, np.nan, 3, 5], + [np.nan, 4, np.nan, 4]]), labels, None) + + data = np.array([[5, 5, np.nan, 47, 7, 4], + [7, 5, np.nan, 2, np.nan, np.nan], + [2, 7, np.nan, np.nan, 27, 5], + [np.nan, 2, 2, np.nan, 2, np.nan]]) + test("non_square_both", data, labels, list("abcdef")) + test("non_square_row_labels", data, labels, None) + test("non_square_col_labels", data, None, list("abcdef")) + test("non_square_no_labels", data, None, None) + + test("non_square_off", + np.array([[np.nan] * 8, + [np.nan] * 8, + [5, 5, np.nan, 47, 7, 4, np.nan, np.nan], + [7, 5, np.nan, 2, np.nan, np.nan, np.nan, np.nan], + [2, 7, np.nan, np.nan, 27, 5, np.nan, np.nan], + [np.nan, 2, 2, np.nan, 2, np.nan, np.nan, np.nan]]), + list("abcd??"), list("???ABCDE"), ) + + test("just_numbers", [[1, 2, 3], [4, 5, 6]], None, None) + + def test_fast_floats(self): + with patch("numpy.cumsum", Mock(wraps=np.cumsum)) as cumsum: + read_matrix(self.file, "non_square_off") + cumsum.assert_called() # sanity check + cumsum.reset_mock() + + data, row_labels, col_labels, _ = \ + read_matrix(self.file, "numbers_upper_left") + cumsum.assert_not_called() + np.testing.assert_almost_equal(data, [[1.2, 4.6, 1.8], + [2.6, 6.4, 1.7]]) + self.assertIsNone(row_labels) + self.assertIsNone(col_labels) + + def test_errors(self): + self.assertRaisesRegex( + ValueError, "sheet", read_matrix, self.file, "koala") + + self.assertRaisesRegex( + ValueError, "E15", read_matrix, self.file, "non_square_off_err") + + self.assertRaisesRegex( + ValueError, "empty", read_matrix, self.file, "no data") + + def test_active_worksheet(self): + # Either succeed or report error, just load something :) + try: + matrix, *_ = read_matrix(self.file) + self.assertIsNotNone(matrix) + except ValueError as exc: + self.assertTrue({"E15", "sheet", "empty"} & set(str(exc).split())) + + +class FunctionsTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.file = os.path.join(files_dir, "distances.xlsx") + + def test_get_sheet(self): + # Just return something... + self.assertIsInstance(_get_sheet(self.file, None), + openpyxl.worksheet.worksheet.Worksheet) + self.assertIsInstance(_get_sheet(self.file, "lower_row_labels"), + openpyxl.worksheet.worksheet.Worksheet) + + def test_non_empty_cells(self): + sheet = _get_sheet(self.file, "upper_row_labels") + cells, row_off, col_off = _non_empty_cells(sheet) + self.assertEqual(cells.shape, (4, 5)) + self.assertEqual(row_off, 0) + self.assertEqual(col_off, 0) + + sheet = _get_sheet(self.file, "non_square_both") + cells, row_off, col_off = _non_empty_cells(sheet) + self.assertEqual(cells.shape, (5, 7)) + self.assertEqual(row_off, 5) + self.assertEqual(col_off, 2) + + sheet = _get_sheet(self.file, "non_square_off") + cells, row_off, col_off = _non_empty_cells(sheet) + self.assertEqual(cells.shape, (7, 9)) + self.assertEqual(row_off, 10) + self.assertEqual(col_off, 1) + self.assertEqual(cells[1, 0], "a") + self.assertEqual(cells[0, 8], "E") + self.assertIsNone(cells[6, 8]) + + sheet = _get_sheet(self.file, "no data") + self.assertRaisesRegex(ValueError, ".*empty.*", _non_empty_cells, sheet) + + with patch("numpy.cumsum", Mock(wraps=np.cumsum)) as cumsum: + sheet = _get_sheet(self.file, "non_square_off") + _non_empty_cells(sheet) + cumsum.assert_called() # sanity check + cumsum.reset_mock() + + sheet = _get_sheet(self.file, "numbers_upper_left") + _non_empty_cells(sheet) + cumsum.assert_not_called() + + def test_get_labels(self): + self.assertEqual(_get_labels(["a", "b", "c"]), ["a", "b", "c"]) + self.assertEqual(_get_labels(["a", "bb", 1, 2]), ["a", "bb", "1", "2"]) + self.assertEqual(_get_labels([None, "b", None]), ["?", "b", "?"]) + self.assertIsNone(_get_labels([None, "1.5", 2]), None) + self.assertIsNone(_get_labels([]), None) + + def test_matrix_from_cells(self): + np.testing.assert_almost_equal( + _matrix_from_cells( + np.array([[1, 2, None], ["3.15", None, ""]]), + 1, 2), + np.array([[1, 2, np.nan], [3.15, np.nan, np.nan]]) + ) + + self.assertRaisesRegex( + ValueError, ".*D3.*", _matrix_from_cells, + np.array([[1, 2, None], ["3.15", "foo", ""]]), + 1, 2) + self.assertRaisesRegex( + ValueError, ".*D3.*", _matrix_from_cells, + np.array([[1, 2, None], ["3.15", object(), ""]]), + 1, 2) + + def test_write(self): + with named_file("", suffix=".xlsx") as fname: + matrix = DistMatrix([[1, 2, 3], [4, 5, 6]]) + write_matrix(matrix, fname) + matrix2, *_ = read_matrix(fname) + np.testing.assert_equal(matrix, matrix2) + + matrix.row_items = mrow_items = ["aa", "bb"] + matrix.col_items = mcol_items = ["cc", "dd", "ee"] + + matrix.row_items = mrow_items + matrix.col_items = mcol_items + write_matrix(matrix, fname) + matrix2, row_labels, col_labels, _ = read_matrix(fname) + np.testing.assert_equal(matrix, matrix2) + self.assertEqual(row_labels, mrow_items) + self.assertEqual(col_labels, mcol_items) + + matrix.row_items = None + matrix.col_items = mcol_items + write_matrix(matrix, fname) + matrix2, row_labels, col_labels, _ = read_matrix(fname) + np.testing.assert_equal(matrix, matrix2) + self.assertIsNone(row_labels) + self.assertEqual(col_labels, mcol_items) + + matrix.row_items = mrow_items + matrix.col_items = None + write_matrix(matrix, fname) + matrix2, row_labels, col_labels, _ = read_matrix(fname) + np.testing.assert_equal(matrix, matrix2) + self.assertEqual(row_labels, mrow_items) + self.assertEqual(col_labels, None) + + matrix.row_items = matrix._labels_to_tables(mrow_items) + matrix.col_items = matrix._labels_to_tables(mcol_items) + write_matrix(matrix, fname) + matrix2, row_labels, col_labels, _ = read_matrix(fname) + np.testing.assert_equal(matrix, matrix2) + self.assertEqual(row_labels, mrow_items) + self.assertEqual(col_labels, mcol_items) + + matrix = DistMatrix([[1, 2, 3], [2, 0, 4], [3, 4, 0]]) + write_matrix(matrix, fname) + matrix2, *_ = read_matrix(fname) + np.testing.assert_equal(matrix2, [[1, np.nan, np.nan], + [2, 0, np.nan], + [3, 4, 0]]) + + matrix = DistMatrix([[0, 2, 3], [2, 0, 4], [3, 4, 0]]) + matrix.col_items = mcol_items + write_matrix(matrix, fname) + matrix2, row_items, col_items, *_ = read_matrix(fname) + np.testing.assert_equal(matrix2, [[np.nan, np.nan, np.nan], + [2, np.nan, np.nan], + [3, 4, np.nan]]) + self.assertIsNone(row_items) + self.assertEqual(col_items, mcol_items) + + def test_nan_values(self): + with named_file("", suffix=".xlsx") as fname: + matrix = DistMatrix([[np.nan, 2, 3], [4, np.nan, np.nan]]) + write_matrix(matrix, fname) + + def test_read_matrix_with_nan_values(self): + with named_file("", suffix=".xlsx") as fname: + matrix = DistMatrix([[np.nan, 2, 3], [4, np.nan, np.nan]]) + write_matrix(matrix, fname) + matrix, _, _, _ = read_matrix(fname) + self.assertTrue(np.isnan(matrix[0, 0])) + self.assertTrue(np.isnan(matrix[1, 1])) + self.assertTrue(np.isnan(matrix[1, 2])) + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/misc/tests/test_embedder_utils.py b/Orange/misc/tests/test_embedder_utils.py new file mode 100644 index 00000000000..d10aab747e4 --- /dev/null +++ b/Orange/misc/tests/test_embedder_utils.py @@ -0,0 +1,149 @@ +import os +import shutil +import stat +import tempfile +import unittest +from unittest.mock import patch + +from Orange.misc.utils.embedder_utils import get_proxies, EmbedderCache + + +class TestProxies(unittest.TestCase): + def setUp(self) -> None: + self.previous_http = os.environ.get("http_proxy") + self.previous_https = os.environ.get("https_proxy") + os.environ.pop("http_proxy", None) + os.environ.pop("https_proxy", None) + + def tearDown(self) -> None: + os.environ.pop("http_proxy", None) + os.environ.pop("https_proxy", None) + if self.previous_http is not None: + os.environ["http_proxy"] = self.previous_http + if self.previous_https is not None: + os.environ["https_proxy"] = self.previous_https + + def test_add_scheme(self): + os.environ["http_proxy"] = "test1.com" + os.environ["https_proxy"] = "test2.com" + res = get_proxies() + self.assertEqual("http://test1.com", res.get("http://")) + self.assertEqual("http://test2.com", res.get("https://")) + + os.environ["http_proxy"] = "test1.com/path" + os.environ["https_proxy"] = "test2.com/path" + res = get_proxies() + self.assertEqual("http://test1.com/path", res.get("http://")) + self.assertEqual("http://test2.com/path", res.get("https://")) + + os.environ["http_proxy"] = "https://test1.com:123" + os.environ["https_proxy"] = "https://test2.com:124" + res = get_proxies() + self.assertEqual("https://test1.com:123", res.get("http://")) + self.assertEqual("https://test2.com:124", res.get("https://")) + + def test_both_urls(self): + os.environ["http_proxy"] = "http://test1.com:123" + os.environ["https_proxy"] = "https://test2.com:124" + res = get_proxies() + self.assertEqual("http://test1.com:123", res.get("http://")) + self.assertEqual("https://test2.com:124", res.get("https://")) + self.assertNotIn("all://", res) + + def test_http_only(self): + os.environ["http_proxy"] = "http://test1.com:123" + res = get_proxies() + self.assertEqual("http://test1.com:123", res.get("http://")) + self.assertNotIn("https://", res) + + def test_https_only(self): + os.environ["https_proxy"] = "https://test1.com:123" + res = get_proxies() + self.assertEqual("https://test1.com:123", res.get("https://")) + self.assertNotIn("http://", res) + + def test_none(self): + """ When no variable is set return None """ + self.assertIsNone(get_proxies()) + + +class TestEmbedderCache(unittest.TestCase): + # pylint: disable=protected-access + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + patcher = patch( + "Orange.misc.utils.embedder_utils.cache_dir", return_value=self.temp_dir + ) + patcher.start() + self.addCleanup(patch.stopall) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_save_load_cache(self): + # open when cache file doesn't exist yet + cache = EmbedderCache("TestModel") + self.assertDictEqual({}, cache._cache_dict) + + # add values and save to test opening with existing file + cache.add("abc", [1, 2, 3]) + cache.persist_cache() + + cache = EmbedderCache("TestModel") + self.assertDictEqual({"abc": [1, 2, 3]}, cache._cache_dict) + + def test_save_cache_no_permission(self): + # prepare a file + cache = EmbedderCache("TestModel") + self.assertDictEqual({}, cache._cache_dict) + cache.add("abc", [1, 2, 3]) + cache.persist_cache() + + # set file to read-only and try to write + curr_permission = os.stat(cache._cache_file_path).st_mode + os.chmod(cache._cache_file_path, stat.S_IRUSR) + cache.add("abcd", [1, 2, 3]) + # new values should be cached since file is readonly + cache.persist_cache() + cache = EmbedderCache("TestModel") + self.assertDictEqual({"abc": [1, 2, 3]}, cache._cache_dict) + os.chmod(cache._cache_file_path, curr_permission) + + def test_load_cache_no_permission(self): + # prepare a file + cache = EmbedderCache("TestModel") + self.assertDictEqual({}, cache._cache_dict) + cache.add("abc", [1, 2, 3]) + cache.persist_cache() + + # no read permission - load no cache + if os.name == "nt": + with patch( + "Orange.misc.utils.embedder_utils.pickle.load", + side_effect=PermissionError, + ): + # it is difficult to change write permission on Windows using + # patch instead + cache = EmbedderCache("TestModel") + else: + os.chmod(cache._cache_file_path, stat.S_IWUSR) + cache = EmbedderCache("TestModel") + self.assertDictEqual({}, cache._cache_dict) + + def test_load_cache_eof_error(self): + # prepare a file + cache = EmbedderCache("TestModel") + self.assertDictEqual({}, cache._cache_dict) + cache.add("abc", [1, 2, 3]) + cache.persist_cache() + + # eof error + with patch( + "Orange.misc.utils.embedder_utils.pickle.load", side_effect=EOFError, + ): + cache = EmbedderCache("TestModel") + self.assertDictEqual({}, cache._cache_dict) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/misc/tests/test_server_embedder.py b/Orange/misc/tests/test_server_embedder.py index f5f9007c33e..ca30338f8ff 100644 --- a/Orange/misc/tests/test_server_embedder.py +++ b/Orange/misc/tests/test_server_embedder.py @@ -1,5 +1,6 @@ import asyncio import unittest +from random import random from unittest.mock import MagicMock, call, patch import numpy as np @@ -22,11 +23,15 @@ def __init__(self, content): self.content = content -def make_dummy_post(response, sleep=0): +def make_dummy_post(response): @staticmethod # pylint: disable=unused-argument - async def dummy_post(url, headers, data): - await asyncio.sleep(sleep) + async def dummy_post(url, headers, content=None, data=None): + # when sleeping some workers to still compute while other are done + # it causes that not all embeddings are computed if we do not wait all + # workers to finish + assert (content is None) ^ (data is None) + await asyncio.sleep(random() / 10) return DummyResponse(content=response) return dummy_post @@ -124,15 +129,6 @@ def test_too_many_examples_for_one_batch(self): # pylint: disable=protected-access self.assertEqual(200, len(self.embedder._cache._cache_dict)) - @patch(_HTTPX_POST_METHOD, regular_dummy_sr) - def test_embedding_cancelled(self): - # pylint: disable=protected-access - # test for the server embedders - self.assertFalse(self.embedder._cancelled) - self.embedder.set_cancelled() - with self.assertRaises(Exception): - self.embedder.embedd_data(self.test_data) - @patch(_HTTPX_POST_METHOD, side_effect=OSError) def test_connection_error(self, _): for num_rows in range(1, 20): @@ -157,6 +153,11 @@ def test_read_error(self, _): self.embedder.embedd_data(test_data) self.setUp() # to init new embedder + @patch(_HTTPX_POST_METHOD, side_effect=ValueError) + def test_other_errors(self, _): + with self.assertRaises(ValueError): + self.embedder.embedd_data(self.test_data) + @patch(_HTTPX_POST_METHOD, regular_dummy_sr) def test_encode_data_instance(self): mocked_fun = self.embedder._encode_data_instance = AsyncMock( @@ -167,3 +168,20 @@ def test_encode_data_instance(self): mocked_fun.assert_has_calls( [call(item) for item in self.test_data], any_order=True ) + + @patch(_HTTPX_POST_METHOD, return_value=DummyResponse(b''), new_callable=AsyncMock) + def test_retries(self, mock): + self.embedder.embedd_data(self.test_data) + self.assertEqual(len(self.test_data) * 3, mock.call_count) + + @patch(_HTTPX_POST_METHOD, regular_dummy_sr) + def test_callback(self): + mock = MagicMock() + self.embedder.embedd_data(self.test_data, callback=mock) + + process_items = [call(x) for x in np.linspace(0, 1, len(self.test_data))] + mock.assert_has_calls(process_items) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/misc/utils/embedder_utils.py b/Orange/misc/utils/embedder_utils.py index e01c6ab91f8..23594bd3e19 100644 --- a/Orange/misc/utils/embedder_utils.py +++ b/Orange/misc/utils/embedder_utils.py @@ -38,21 +38,37 @@ def __init__(self, model): def _init_cache(self): if isfile(self._cache_file_path): - try: - return self.load_pickle(self._cache_file_path) - except EOFError: - return {} + return self.load_pickle(self._cache_file_path) return {} @staticmethod def save_pickle(obj, file_name): - with open(file_name, 'wb') as f: - pickle.dump(obj, f) + try: + with open(file_name, 'wb') as f: + pickle.dump(obj, f) + except PermissionError as ex: + # skip saving cache if no right permissions + log.warning( + "Can't save embedding to %s due to %s.", + file_name, + type(ex).__name__, + exc_info=True, + ) @staticmethod def load_pickle(file_name): - with open(file_name, 'rb') as f: - return pickle.load(f) + try: + with open(file_name, 'rb') as f: + return pickle.load(f) + except (EOFError, PermissionError) as ex: + # load empty cache if no permission or EOF error + log.warning( + "Can't load embedding from %s due to %s.", + file_name, + type(ex).__name__, + exc_info=True, + ) + return {} @staticmethod def md5_hash(bytes_): @@ -78,7 +94,7 @@ def add(self, cache_key, value): def get_proxies() -> Optional[Dict[str, str]]: """ - Return dict with proxy addresses if they exists. + Return dict with proxy addresses if they exist. Returns ------- @@ -86,14 +102,18 @@ def get_proxies() -> Optional[Dict[str, str]]: Dictionary with format {proxy type: proxy address} or None if they not set. """ - def add_protocol(url: Optional[str], prot: str) -> Optional[str]: - if url and not url.startswith(prot): - return f"{prot}://{url}" - return url - http_proxy = add_protocol(environ.get("http_proxy"), "http") - https_proxy = add_protocol(environ.get("https_proxy"), "https") - if http_proxy and https_proxy: # both proxy addresses defined - return {"http://": https_proxy, "https://": https_proxy} - elif any([https_proxy, http_proxy]): # one of the proxies defined - return {"all://": http_proxy or https_proxy} - return None # proxies not defined + def add_scheme(url: Optional[str]) -> Optional[str]: + if url is not None and "://" not in url: + # if no scheme default to http - as other libraries do (e.g. requests) + return f"http://{url}" + else: + return url + + http_proxy = add_scheme(environ.get("http_proxy")) + https_proxy = add_scheme(environ.get("https_proxy")) + proxy_dict = {} + if http_proxy: + proxy_dict["http://"] = http_proxy + if https_proxy: + proxy_dict["https://"] = https_proxy + return proxy_dict if proxy_dict else None diff --git a/Orange/modelling/__init__.py b/Orange/modelling/__init__.py index 206151fcdf2..22d91d563c0 100644 --- a/Orange/modelling/__init__.py +++ b/Orange/modelling/__init__.py @@ -11,6 +11,7 @@ from .randomforest import * from .svm import * from .tree import * +from .column import * try: from .catgb import * except ImportError: diff --git a/Orange/modelling/base.py b/Orange/modelling/base.py index b7c2a24e10f..77c425366ea 100644 --- a/Orange/modelling/base.py +++ b/Orange/modelling/base.py @@ -83,9 +83,7 @@ def __kwargs(self, problem_type): learner_kwargs = set( self.__fits__[problem_type].__init__.__code__.co_varnames[1:]) changed_kwargs = self._change_kwargs(self.kwargs, problem_type) - # Make sure to remove any params that are set to None and use defaults - filtered_kwargs = {k: v for k, v in changed_kwargs.items() if v is not None} - return {k: v for k, v in filtered_kwargs.items() if k in learner_kwargs} + return {k: v for k, v in changed_kwargs.items() if k in learner_kwargs} def _change_kwargs(self, kwargs, problem_type): """Handle the kwargs to be passed to the learner before they are used. diff --git a/Orange/modelling/column.py b/Orange/modelling/column.py new file mode 100644 index 00000000000..447097461fd --- /dev/null +++ b/Orange/modelling/column.py @@ -0,0 +1,155 @@ +from typing import Optional + +import numpy as np + +from Orange.data import Variable, DiscreteVariable, Domain, Table +from Orange.classification import LogisticRegressionLearner +from Orange.regression import LinearRegressionLearner +from Orange.modelling import Model, Learner + +__all__ = ["ColumnLearner", "ColumnModel"] + + +def _check_column_combinations( + class_var: Variable, + column: Variable, + fit_regression: bool): + if class_var.is_continuous: + if not column.is_continuous: + raise ValueError( + "Regression can only be used with numeric variables") + return + + assert isinstance(class_var, DiscreteVariable) # remove type warnings + if column.is_continuous: + if len(class_var.values) != 2: + raise ValueError( + "Numeric columns can only be used with binary class variables") + else: + assert isinstance(column, DiscreteVariable) + if not valid_value_sets(class_var, column): + raise ValueError( + "Column contains values that are not in class variable") + if fit_regression and not column.is_continuous: + raise ValueError( + "Intercept and coefficient are only allowed for continuous " + "variables") + + +def valid_prob_range(values: np.ndarray): + return np.nanmin(values) >= 0 and np.nanmax(values) <= 1 + + +def valid_value_sets(class_var: DiscreteVariable, + column_var: DiscreteVariable): + return set(column_var.values) <= set(class_var.values) + + +class ColumnLearner(Learner): + def __init__(self, + class_var: Variable, + column: Variable, + fit_regression: bool = False): + super().__init__() + _check_column_combinations(class_var, column, fit_regression) + self.class_var = class_var + self.column = column + self.fit_regression = fit_regression + self.name = f"column '{column.name}'" + + def __fit_coefficients(self, data: Table): + # Use learners from Orange rather than directly calling + # scikit-learn, so that we make sure we use the same parameters + # and get the same result as we would if we used the widgets. + data1 = data.transform(Domain([self.column], self.class_var)) + if self.class_var.is_discrete: + model = LogisticRegressionLearner()(data1) + return model.intercept[0], model.coefficients[0][0] + else: + model = LinearRegressionLearner()(data1) + return model.intercept, model.coefficients[0] + + def fit_storage(self, data: Table): + if data.domain.class_var != self.class_var: + raise ValueError("Class variable does not match the data") + if not self.fit_regression: + return ColumnModel(self.class_var, self.column) + + intercept, coefficient = self.__fit_coefficients(data) + return ColumnModel(self.class_var, self.column, intercept, coefficient) + + +class ColumnModel(Model): + def __init__(self, + class_var: Variable, + column: Variable, + intercept: Optional[float] = None, + coefficient: Optional[float] = None): + super().__init__(Domain([column], class_var)) + + _check_column_combinations(class_var, column, intercept is not None) + if (intercept is not None) is not (coefficient is not None): + raise ValueError( + "Intercept and coefficient must both be provided or absent") + + self.class_var = class_var + self.column = column + self.intercept = intercept + self.coefficient = coefficient + if (column.is_discrete and + class_var.values[:len(column.values)] != column.values): + self.value_mapping = np.array([class_var.to_val(x) + for x in column.values]) + else: + self.value_mapping = None + + pars = f" ({intercept}, {coefficient})" if intercept is not None else "" + self.name = f"column '{column.name}'{pars}" + + def predict_storage(self, data: Table): + vals = data.get_column(self.column) + if self.class_var.is_discrete: + return self._predict_discrete(vals) + else: + return self._predict_continuous(vals) + + def _predict_discrete(self, vals): + assert isinstance(self.class_var, DiscreteVariable) + nclasses = len(self.class_var.values) + proba = np.full((len(vals), nclasses), np.nan) + rows = np.isfinite(vals) + if self.column.is_discrete: + mapped = vals[rows].astype(int) + if self.value_mapping is not None: + mapped = self.value_mapping[mapped] + vals = vals.copy() + vals[rows] = mapped + proba[rows] = 0 + proba[rows, mapped] = 1 + else: + if self.coefficient is None: + if not valid_prob_range(vals): + raise ValueError("Column values must be in [0, 1] range " + "unless logistic function is applied") + proba[rows, 1] = vals[rows] + else: + proba[rows, 1] = ( + 1 / + (1 + np.exp(-self.intercept - self.coefficient * vals[rows]) + )) + + proba[rows, 0] = 1 - proba[rows, 1] + vals = (proba[:, 1] > 0.5).astype(float) + vals[~rows] = np.nan + return vals, proba + + def _predict_continuous(self, vals): + if self.coefficient is None: + return vals + else: + return vals * self.coefficient + self.intercept + + def __str__(self): + pars = f" ({self.intercept}, {self.coefficient})" \ + if self.intercept is not None else "" + return f'ColumnModel {self.column.name}{pars}' diff --git a/Orange/modelling/linear.py b/Orange/modelling/linear.py index 2f42df97ecd..295349e87f6 100644 --- a/Orange/modelling/linear.py +++ b/Orange/modelling/linear.py @@ -26,10 +26,9 @@ class SGDLearner(SklFitter, _FeatureScorerMixin): 'regression': SGDRegressionLearner} def _change_kwargs(self, kwargs, problem_type): - if problem_type is self.CLASSIFICATION: - kwargs['loss'] = kwargs.get('classification_loss') - kwargs['epsilon'] = kwargs.get('classification_epsilon') - elif problem_type is self.REGRESSION: - kwargs['loss'] = kwargs.get('regression_loss') - kwargs['epsilon'] = kwargs.get('regression_epsilon') - return kwargs + pref = "classification" if problem_type is self.CLASSIFICATION else "regression" + return kwargs | { + attr: kwargs[pattr] + for attr, pattr in ((attr, f"{pref}_{attr}") + for attr in ('loss', 'epsilon')) + if pattr in kwargs} diff --git a/Orange/modelling/randomforest.py b/Orange/modelling/randomforest.py index cbffcca1409..ad7f8cb19d7 100644 --- a/Orange/modelling/randomforest.py +++ b/Orange/modelling/randomforest.py @@ -1,4 +1,4 @@ -from Orange.base import RandomForestModel +from Orange.base import RandomForestModel, Learner from Orange.classification import RandomForestLearner as RFClassification from Orange.data import Variable from Orange.modelling import SklFitter @@ -24,3 +24,8 @@ class RandomForestLearner(SklFitter, _FeatureScorerMixin): 'regression': RFRegression} __returns__ = RandomForestModel + + @property + def fitted_parameters(self) -> list[Learner.FittedParameter]: + return [self.FittedParameter("n_estimators", "Number of trees", + int, 1, None)] diff --git a/Orange/modelling/tests/test_catgb.py b/Orange/modelling/tests/test_catgb.py index f9c79863f01..d77cb4f3998 100644 --- a/Orange/modelling/tests/test_catgb.py +++ b/Orange/modelling/tests/test_catgb.py @@ -42,6 +42,9 @@ def test_scorer(self): booster.score(self.iris) booster.score(self.housing) + def test_supports_weights(self): + self.assertTrue(CatGBLearner().supports_weights) + if __name__ == "__main__": unittest.main() diff --git a/Orange/modelling/tests/test_column.py b/Orange/modelling/tests/test_column.py new file mode 100644 index 00000000000..9cc64747d46 --- /dev/null +++ b/Orange/modelling/tests/test_column.py @@ -0,0 +1,306 @@ +import unittest +from unittest.mock import patch + +import numpy as np + +from Orange.data import DiscreteVariable, ContinuousVariable, Table, Domain +from Orange.modelling.column import _check_column_combinations, \ + valid_prob_range, valid_value_sets, ColumnLearner, ColumnModel + + +class TestBase(unittest.TestCase): + def setUp(self): + self.disc_a = DiscreteVariable("a", values=("a", "b", "c")) + self.disc_b = DiscreteVariable("b", values=("c", "a", "b")) + self.disc_c = DiscreteVariable("c", values=("c", "b")) + self.cont_e = ContinuousVariable("e") + self.cont_f = ContinuousVariable("f") + self.cont_g = ContinuousVariable("g") + + +class TestColumnLearner(TestBase): + @patch("Orange.modelling.column._check_column_combinations") + def test_column_regressor(self, check): + data = Table.from_numpy( + Domain([self.disc_a, self.cont_e, self.cont_f], + self.cont_g), + np.array([[0, 1, -6], + [1, 2, -4], + [2, 4, 0], + [np.nan, 6, 4], + [0, 3, -2]]), + np.array([0, 1, 3, 5, 2])) + + model = ColumnLearner(self.cont_g, self.cont_e, True)(data) + check.assert_called() + self.assertIs(model.class_var, self.cont_g) + self.assertIs(model.column, self.cont_e) + self.assertAlmostEqual(model.intercept, -1) + self.assertAlmostEqual(model.coefficient, 1) + + model = ColumnLearner(self.cont_g, self.cont_f, True)(data) + self.assertIs(model.class_var, self.cont_g) + self.assertIs(model.column, self.cont_f) + self.assertAlmostEqual(model.intercept, 3) + self.assertAlmostEqual(model.coefficient, 0.5) + + check.reset_mock() + model = ColumnLearner(self.cont_g, self.cont_f)(data) + check.assert_called() + self.assertIs(model.class_var, self.cont_g) + self.assertIs(model.column, self.cont_f) + self.assertIsNone(model.intercept) + self.assertIsNone(model.coefficient) + + @patch("Orange.modelling.column._check_column_combinations") + def test_column_classifier_from_numeric(self, check): + data = Table.from_numpy( + Domain([self.disc_a, self.disc_b, self.cont_e], + self.disc_c), + np.array([[0, 1, 0], + [1, 0, 1], + [2, 1, 1], + [0, 2, 0], + [0, 0, 1]]), + np.array([0, 1, 0, 1, 0])) + + model = ColumnLearner(self.disc_c, self.cont_e, True)(data) + check.assert_called() + self.assertIs(model.class_var, self.disc_c) + self.assertIs(model.column, self.cont_e) + # These values were not computed manually + self.assertAlmostEqual(model.intercept, -0.3127646959895215) + self.assertAlmostEqual(model.coefficient, -0.15535275317811897) + + @patch("Orange.modelling.column._check_column_combinations") + def test_column_classifier_from_discrete(self, check): + data = Table.from_numpy( + Domain([self.disc_b, self.disc_c, self.cont_e], + self.disc_a), + np.array([[0, 1, 0], + [1, 0, 1], + [2, 1, 1], + [0, 0, 0], + [0, 0, 1]]), + np.array([0, 1, 2, 1, 0])) + model = ColumnLearner(self.disc_a, self.disc_c)(data) + check.assert_called() + self.assertIs(model.class_var, self.disc_a) + self.assertIs(model.column, self.disc_c) + self.assertIsNone(model.intercept) + self.assertIsNone(model.coefficient) + + def test_class_mismatch(self): + data = Table.from_numpy( + Domain([self.disc_b, self.disc_c, self.cont_e], + self.disc_a), + np.array([[0, 1, 0], + [1, 0, 1], + [2, 1, 1], + [0, 0, 0], + [0, 0, 1]]), + np.array([0, 1, 2, 1, 0])) + + self.assertRaises( + ValueError, + ColumnLearner(self.disc_b, self.disc_c), + data) + + +class TestModel(TestBase): + def test_str(self): + model = ColumnModel(self.disc_a, self.disc_c) + self.assertEqual(str(model), "ColumnModel c") + + model = ColumnModel(self.disc_c, self.cont_e, 1, 2) + self.assertEqual(str(model), "ColumnModel e (1, 2)") + + def test_mapping(self): + model = ColumnModel(self.disc_a, + DiscreteVariable("a2", self.disc_a.values)) + self.assertIsNone(model.value_mapping) + model = ColumnModel(self.disc_c, self.cont_e) + self.assertIsNone(model.value_mapping) + model = ColumnModel(self.disc_c, self.cont_e, 1, 2) + self.assertIsNone(model.value_mapping) + model = ColumnModel(self.disc_b, self.disc_c) + np.testing.assert_equal(model.value_mapping, [0, 2]) + model = ColumnModel(self.disc_a, self.disc_c) + np.testing.assert_equal(model.value_mapping, [2, 1]) + model = ColumnModel(self.disc_b, self.disc_a) + np.testing.assert_equal(model.value_mapping, [1, 2, 0]) + + @patch("Orange.modelling.column._check_column_combinations") + def test_check_validity(self, check): + ColumnModel(self.disc_c, self.cont_e) + check.assert_called() + + with self.assertRaises(ValueError): + ColumnModel(self.disc_b, self.cont_e, 0, None) + with self.assertRaises(ValueError): + ColumnModel(self.disc_b, self.cont_e, None, 1) + + def test_name(self): + self.assertEqual( + ColumnModel(self.disc_c, self.cont_e).name, + "column 'e'") + + self.assertEqual( + ColumnModel(self.disc_c, self.cont_e, -1, 2).name, + "column 'e' (-1, 2)") + + @patch("Orange.modelling.column.ColumnModel._predict_discrete") + @patch("Orange.modelling.column.ColumnModel._predict_continuous") + def test_predict_storage(self, predict_cont, predict_disc): + model = ColumnModel(self.cont_e, self.cont_f) + data = Table.from_numpy( + Domain([self.cont_f], self.cont_e), + np.array([[1], [2], [3]]), + np.array([0, 1, 2])) + model.predict_storage(data) + predict_cont.assert_called() + predict_cont.reset_mock() + predict_disc.assert_not_called() + + model = ColumnModel(self.disc_a, self.disc_c) + data = Table.from_numpy( + Domain([self.disc_c], self.disc_a), + np.array([[0], [1], [0]]), + np.array([0, 1, 0])) + model.predict_storage(data) + predict_disc.assert_called() + predict_cont.assert_not_called() + + def test_predict_disc_from_disc_w_mapping(self): + data = Table.from_numpy( + Domain([self.disc_a, self.disc_c, self.cont_e], + self.disc_b), + np.array([[0, 1, 0], # a, b, 0 + [1, 0, 1], # b c 1 + [2, 1, 1], # c b 1 + [0, 0, 0], # a c 0 + [0, 0, 1]]), # a c 1 + np.array([0, 1, 0, 1, 0])) + + model = ColumnModel(self.disc_b, self.disc_c) + vals, probs = model.predict_storage(data) + np.testing.assert_equal(vals, [2, 0, 2, 0, 0]) + np.testing.assert_almost_equal(probs, [[0, 0, 1], + [1, 0, 0], + [0, 0, 1], + [1, 0, 0], + [1, 0, 0]]) + + model = ColumnModel(self.disc_b, self.disc_a) + vals, probs = model.predict_storage(data) + np.testing.assert_equal(vals, [1, 2, 0, 1, 1]) + np.testing.assert_almost_equal(probs, [[0, 1, 0], + [0, 0, 1], + [1, 0, 0], + [0, 1, 0], + [0, 1, 0]]) + + def test_predict_disc_from_disc_wout_mapping(self): + data = Table.from_numpy( + Domain([self.disc_a, self.disc_c, self.cont_e], + DiscreteVariable("a1", values=self.disc_a.values)), + np.array([[0, 1, 0], + [1, 0, 1], + [2, 1, 1], + [0, 0, 0], + [0, 0, 1]]), + np.array([0, 1, 0, 1, 0])) + + model = ColumnModel(data.domain.class_var, self.disc_a) + vals, probs = model.predict_storage(data) + np.testing.assert_equal(vals, [0, 1, 2, 0, 0]) + np.testing.assert_almost_equal(probs, [[1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [1, 0, 0], + [1, 0, 0]]) + + def test_predict_disc_from_cont(self): + data = Table.from_numpy( + Domain([self.cont_e, self.cont_f], + self.disc_c), + np.array([[0, -1], + [1, 0], + [0.45, 1], + [np.nan, 2], + [0.52, np.nan]]), + np.array([0, 1, 0, 1, 0])) + + model = ColumnModel(data.domain.class_var, self.cont_e) + vals, probs = model.predict_storage(data) + p1 = np.array([0, 1, 0.45, np.nan, 0.52]) + np.testing.assert_equal(vals, [0, 1, 0, np.nan, 1]) + np.testing.assert_almost_equal(probs, np.vstack((1 - p1, p1)).T) + + model = ColumnModel(self.disc_c, self.cont_e, -0.1, 5) + vals, probs = model.predict_storage(data) + p1e = 1 / (1 + np.exp(+0.1 - 5 * p1)) + np.testing.assert_equal(vals, [0, 1, 1, np.nan, 1]) + np.testing.assert_almost_equal(probs, np.vstack((1 - p1e, p1e)).T) + + with self.assertRaises(ValueError): + # values outside [0, 1] range + ColumnModel(self.disc_c, self.cont_f)(data) + + model = ColumnModel(self.disc_c, self.cont_f, -1, 5) + vals, probs = model.predict_storage(data) + p1 = 1 / (1 + np.exp(1 - 5 * data.X[:, 1])) + np.testing.assert_equal(vals, [0, 0, 1, 1, np.nan]) + np.testing.assert_almost_equal(probs, np.vstack((1 - p1, p1)).T) + + def test_predict_cont(self): + data = Table.from_numpy( + Domain([self.cont_e, self.cont_f], + self.cont_g), + np.array([[0, -1], + [1, 0], + [0.45, 1], + [np.nan, 2], + [0.52, np.nan]]), + np.array([0, 1, 0.45, 2, 0.52])) + + model = ColumnModel(self.cont_g, self.cont_e) + np.testing.assert_equal(model.predict_storage(data), data.X[:, 0]) + + model = ColumnModel(self.cont_g, self.cont_f, -0.1, 5) + np.testing.assert_almost_equal( + model.predict_storage(data), + -0.1 + 5 * data.X[:, 1]) + + +class Test(TestBase): + def test_checks(self): + def check(class_var, column, fit=False): + _check_column_combinations(class_var, column, fit) + + def value_error(*args): + self.assertRaises(ValueError, check, *args) + + value_error(self.cont_e, self.disc_a) # regression from discrete column + value_error(self.disc_a, self.cont_e) # non-binary class from numeric + value_error(self.disc_c, self.disc_a) # column has vales not in class + value_error(self.disc_a, self.disc_b, True) # fitting from discrete column + + check(self.cont_e, self.cont_f) + check(self.cont_e, self.cont_f, True) + check(self.disc_a, self.disc_b) + check(self.disc_a, self.disc_c) + + def test_valid_prob_range(self): + self.assertTrue(valid_prob_range(np.array([1, 0, 0.5]))) + self.assertFalse(valid_prob_range(np.array([-0.1, 0.5, 1]))) + self.assertFalse(valid_prob_range(np.array([0, 1.1, 0.5]))) + + def test_valid_value_sets(self): + self.assertTrue(valid_value_sets(self.disc_a, self.disc_b)) + self.assertTrue(valid_value_sets(self.disc_a, self.disc_c)) + self.assertFalse(valid_value_sets(self.disc_c, self.disc_a)) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/modelling/tests/test_gb.py b/Orange/modelling/tests/test_gb.py index b200b199490..141779f8764 100644 --- a/Orange/modelling/tests/test_gb.py +++ b/Orange/modelling/tests/test_gb.py @@ -37,6 +37,9 @@ def test_scorer(self): booster.score(self.iris) booster.score(self.housing) + def test_supports_weights(self): + self.assertTrue(GBLearner().supports_weights) + if __name__ == "__main__": unittest.main() diff --git a/Orange/modelling/tests/test_knn.py b/Orange/modelling/tests/test_knn.py new file mode 100644 index 00000000000..2fa6a2d5416 --- /dev/null +++ b/Orange/modelling/tests/test_knn.py @@ -0,0 +1,61 @@ +import unittest +from unittest.mock import patch + +import numpy as np +from scipy import sparse +import sklearn + +from Orange.data import Table +from Orange.modelling import KNNLearner + +knn_init = sklearn.neighbors.KNeighborsClassifier.__init__ + + +class Test(unittest.TestCase): + def setUp(self): + x = np.array([[3, 0, 4], + [12, 5, 0], + [1, 2, 2]]) + y = np.array([1, 0, 1]) + self.data = Table.from_numpy(None, x, y) + + @patch("Orange.base.SklLearner.fit") + def test_cosine_normalizes(self, fit): + learner = KNNLearner(metric="cosine") + learner(self.data) + X = fit.call_args[0][0] + np.testing.assert_allclose(np.sum(X ** 2, axis=1), 1) + np.testing.assert_allclose(X, [ + [3 / 5, 0, 4 / 5], + [12 / 13, 5 / 13, 0], + [1 / 3, 2 / 3, 2 / 3]]) + fit.reset_mock() + + with self.data.unlocked(): + self.data.X = sparse.csr_matrix(self.data.X) + learner(self.data) + X = fit.call_args[0][0] + np.testing.assert_allclose(np.sum(X ** 2, axis=1), 1) + row0 = X[0] + np.testing.assert_allclose(row0, row0 / np.linalg.norm(row0)) + + def test_cosine_does_not_modify_input_data(self): + original = self.data.X.copy() + learner = KNNLearner(metric="cosine") + learner(self.data) + np.testing.assert_array_equal(self.data.X, original) + + @patch("sklearn.neighbors.KNeighborsClassifier.__init__", + side_effect=knn_init, autospec=True) + def test_cosine_computes_euclidean(self, mock_init): + learner = KNNLearner(metric="cosine") + learner(self.data) + self.assertEqual(mock_init.call_args[1]["metric"], "euclidean") + + learner = KNNLearner(metric="manhattan") + learner(self.data) + self.assertEqual(mock_init.call_args[1]["metric"], "manhattan") + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/modelling/tests/test_xgb.py b/Orange/modelling/tests/test_xgb.py index 761dc961775..c70cc463898 100644 --- a/Orange/modelling/tests/test_xgb.py +++ b/Orange/modelling/tests/test_xgb.py @@ -55,6 +55,10 @@ def test_scorer(self, learner_class: Union[XGBLearner, XGBRFLearner]): booster.score(self.iris) booster.score(self.housing) + @test_learners + def test_supports_weights(self, learner_class: Union[XGBLearner, XGBRFLearner]): + self.assertTrue(learner_class().supports_weights) + if __name__ == "__main__": unittest.main() diff --git a/Orange/preprocess/_relieff.pyx b/Orange/preprocess/_relieff.pyx index 67b2c7a7712..db493954f6a 100644 --- a/Orange/preprocess/_relieff.pyx +++ b/Orange/preprocess/_relieff.pyx @@ -26,7 +26,7 @@ from libcpp.algorithm cimport make_heap, pop_heap # Import C99 features from numpy's npy_math (MSVC 2010) # Note we cannot import isnan due to mixing C++ and C # (at least on OSX the undefines the isnan macro) -from numpy.math cimport INFINITY, NAN +from libc.math cimport INFINITY, NAN ctypedef np.float64_t double ctypedef np.int8_t[:] arr_i1_t @@ -354,7 +354,7 @@ cdef void contingency_tables(np.ndarray X, cdef tuple prepare(X, y, is_discrete, contingencies): X = np.array(X, dtype=np.float64, order='C') - is_discrete = np.asarray(is_discrete, dtype=np.bool8) + is_discrete = np.asarray(is_discrete, dtype=np.bool_) is_continuous = ~is_discrete if is_continuous.any(): row_min = np.nanmin(X, 0) @@ -362,11 +362,16 @@ cdef tuple prepare(X, y, is_discrete, contingencies): row_ptp[row_ptp == 0] = np.inf # Avoid zero-division X[:, is_continuous] -= row_min[is_continuous] X[:, is_continuous] /= row_ptp[is_continuous] - y = np.array(y, dtype=np.float64) + if y.ndim > 1: + if y.shape[1] > 1: + raise ValueError("ReliefF expects a single class") + y = np.array(y[:, 0], dtype=np.float64) + else: + y = np.array(y, dtype=np.float64) is_defined = np.logical_not(np.isnan(y)) X = X[is_defined] y = y[is_defined] - attr_stats = np.row_stack((np.nanmean(X, 0), np.nanstd(X, 0))) + attr_stats = np.vstack((np.nanmean(X, 0), np.nanstd(X, 0))) is_discrete = np.asarray(is_discrete, dtype=np.int8) contingency_tables(X, y, is_discrete, contingencies) return X, y, attr_stats, is_discrete diff --git a/Orange/preprocess/discretize.py b/Orange/preprocess/discretize.py index 1502b97f0f4..fe06ba72bfb 100644 --- a/Orange/preprocess/discretize.py +++ b/Orange/preprocess/discretize.py @@ -1,14 +1,15 @@ import calendar import re import time -from typing import NamedTuple, List, Union, Callable +from numbers import Number +from typing import NamedTuple, List, Union, Callable, Optional import datetime -from itertools import count +from itertools import count, chain import numpy as np import scipy.sparse as sp -from Orange.data import DiscreteVariable, Domain +from Orange.data import DiscreteVariable, Domain, TimeVariable, Table from Orange.data.sql.table import SqlTable from Orange.statistics import distribution, contingency, util as ut from Orange.statistics.basic_stats import BasicStats @@ -43,39 +44,94 @@ def transform(self, c): if sp.issparse(c): return self.digitize(c, self.points) elif c.size: - return np.where(np.isnan(c), np.NaN, self.digitize(c, self.points)) + return np.where(np.isnan(c), np.nan, self.digitize(c, self.points)) else: return np.array([], dtype=int) @staticmethod - def _fmt_interval(low, high, formatter): - assert low is not None or high is not None + def _fmt_interval(low, high, formatter, strip_zeros=True): assert low is None or high is None or low < high - if low is None or np.isinf(low): - return f"< {formatter(high)}" - if high is None or np.isinf(high): - return f"≥ {formatter(low)}" - return f"{formatter(low)} - {formatter(high)}" + + def strip0(s): + if strip_zeros and re.match(r"^\d+\.\d+", s): + return s.rstrip("0").rstrip(".") + return s + + lows = (low is not None and not np.isinf(low) + and strip0(formatter(low))) + highs = (high is not None and not np.isinf(high) + and strip0(formatter(high))) + assert lows or highs + if lows == highs: + raise ValueError(f"Formatter returned identical thresholds: {lows}") + + if not lows: + return f"< {highs}" + if not highs: + return f"≥ {lows}" + return f"{lows} - {highs}" @classmethod - def create_discretized_var(cls, var, points): - def fmt(val): - sval = var.str_val(val) - # For decimal numbers, remove trailing 0's and . if no decimals left - if re.match(r"^\d+\.\d+", sval): - return sval.rstrip("0").rstrip(".") - return sval - - lpoints = list(points) - if lpoints: - values = [ - cls._fmt_interval(low, high, fmt) - for low, high in zip([-np.inf] + lpoints, lpoints + [np.inf])] - to_sql = BinSql(var, lpoints) - else: + def _get_labels(cls, fmt, points, strip_zeros=True): + return [ + cls._fmt_interval(low, high, fmt, strip_zeros=strip_zeros) + for low, high in zip( + chain([-np.inf], points), + chain(points, [np.inf]))] + + @classmethod + def _get_discretized_values(cls, var, points, ndigits=None): + if len(points) == 0: values = ["single_value"] to_sql = SingleValueSql(values[0]) + return points, values, to_sql + + npoints = np.array(points, dtype=np.float64) + if len(points) > 1: + mindiff = np.min(npoints[1:] - npoints[:-1]) + if mindiff == 0: + raise ValueError("Some interval thresholds are identical") + else: + mindiff = 1 # prevent warnings + + if ndigits is None or len(points) == 1: + try: + values = cls._get_labels(var.str_val, points) + except ValueError: # points would create identical formatted thresholds + pass + else: + if len(values) == len(set(values)): + to_sql = BinSql(var, points) + return points, values, to_sql + + mindigits = max(ndigits or 0, + int(-np.log10(mindiff))) + maxdigits = np.finfo(npoints.dtype).precision + 2 + for digits in range(mindigits, maxdigits + 1): + # ensure that builtin round is used for compatibility with float formatting + # de-numpyize points p (otherwise np.floats use numpy's round) + npoints = [round(float(p), digits) for p in points] + if len(npoints) == len(set(npoints)): + def fmt_fixed(val): + # We break the loop, pylint: disable=cell-var-from-loop + return f"{val:.{digits}f}" + + points = list(npoints) + break + else: + # pragma: no cover + assert False + + values = cls._get_labels( + fmt_fixed, points, + strip_zeros=digits != ndigits) + assert len(values) == len(set(values)) + to_sql = BinSql(var, points) + return points, values, to_sql + @classmethod + def create_discretized_var(cls, var, points, ndigits=None): + points, values, to_sql = cls._get_discretized_values(var, points, ndigits) dvar = DiscreteVariable(name=var.name, values=values, compute_value=cls(var, points), sparse=var.sparse) @@ -96,8 +152,8 @@ def __init__(self, var, points): self.points = points def __call__(self): - return 'width_bucket(%s, ARRAY%s::double precision[])' % ( - self.var.to_sql(), str(self.points)) + return f'width_bucket({self.var.to_sql()}, ' \ + f'ARRAY{str(self.points)}::double precision[])' class SingleValueSql: @@ -163,30 +219,174 @@ def __init__(self, n=4): self.n = n # noinspection PyProtectedMember - def __call__(self, data, attribute, fixed=None): + def __call__(self, data: Table, attribute, fixed=None): if fixed: - min, max = fixed[attribute.name] - points = self._split_eq_width(min, max) + mn, mx = fixed[attribute.name] + points = self._split_eq_width(mn, mx) else: if type(data) == SqlTable: stats = BasicStats(data, attribute) points = self._split_eq_width(stats.min, stats.max) else: - values = data[:, attribute] - values = values.X if values.X.size else values.Y + values = data.get_column(attribute) if values.size: - min, max = ut.nanmin(values), ut.nanmax(values) - points = self._split_eq_width(min, max) + mn, mx = ut.nanmin(values), ut.nanmax(values) + points = self._split_eq_width(mn, mx) else: points = [] return Discretizer.create_discretized_var( data.domain[attribute], points) - def _split_eq_width(self, min, max): - if np.isnan(min) or np.isnan(max) or min == max: + def _split_eq_width(self, mn, mx): + if np.isnan(mn) or np.isnan(mx) or mn == mx: return [] - dif = (max - min) / self.n - return [min + (i + 1) * dif for i in range(self.n - 1)] + dif = (mx - mn) / self.n + return [mn + i * dif for i in range(1, self.n)] + + +class TooManyIntervals(ValueError): + pass + + +class FixedWidth(Discretization): + def __init__(self, width, digits=None): + super().__init__() + self.width = width + self.digits = digits + + def __call__(self, data: Table, attribute): + values = data.get_column(attribute) + points = [] + if values.size: + mn, mx = ut.nanmin(values), ut.nanmax(values) + if not np.isnan(mn): + minf = int(1 + np.floor(mn / self.width)) + maxf = int(1 + np.floor(mx / self.width)) + if maxf - minf - 1 >= 100: + raise TooManyIntervals + points = [i * self.width for i in range(minf, maxf)] + return Discretizer.create_discretized_var( + data.domain[attribute], points, ndigits=self.digits) + + +class FixedTimeWidth(Discretization): + def __init__(self, width, unit): + # unit: 0=year, 1=month, 2=day, 3=hour, 4=minute, 5=second + # for week, use day with a width of 7 + super().__init__() + self.width = width + self.unit = unit + + def __call__(self, data: Table, attribute): + fmt = ["%Y", "%y %b", "%y %b %d", "%y %b %d %H:%M", "%y %b %d %H:%M", + "%H:%M:%S"][self.unit] + values = data.get_column(attribute) + times = [] + if values.size: + mn, mx = ut.nanmin(values), ut.nanmax(values) + if not np.isnan(mn): + mn = utc_from_timestamp(mn).timetuple() + mx = utc_from_timestamp(mx).timetuple() + times = _time_range(mn, mx, self.unit, self.width, 0, 100) + if times is None: + raise TooManyIntervals + times = [time.struct_time(t + (0, 0, 0)) for t in times][1:-1] + points = np.array([calendar.timegm(t) for t in times]) + values = [time.strftime(fmt, t) for t in times] + values = _simplified_time_intervals(values) + var = data.domain[attribute] + return DiscreteVariable(name=var.name, values=values, + compute_value=Discretizer(var, points), + sparse=var.sparse) + + +def _simplified_time_intervals(labels): + def no_common(a, b): + for i, pa, pb in zip(count(), a, b): + if pa != pb: + if common + i == 2: + i -= 1 + return b[i:] + # can't come here (unless a == b?!) + return b # pragma: no cover + + + if not labels: + return [] + common = 100 + labels = [label.split() for label in labels] + for common, parts in enumerate(map(set, zip(*labels))): + if len(parts) > 1: + break + if common == 2: # If we keep days, we must also keep months + common = 1 + labels = [label[common:] for label in labels] + join = " ".join + return [f"< {join(labels[0])}"] + [ + f"{join(low)} - {join(no_common(low, high))}" + for low, high in zip(labels, labels[1:]) + ] + [f"≥ {join(labels[-1])}"] + + + +class Binning(Discretization): + """Discretization with nice thresholds + + This class creates different decimal or time binnings and picks the one + in which the number of interval is closest to the desired number. + The difference is measured as proportion; e.g. having 30 % less intervals + is the same difference as having 30 % too many. + + .. attribute:: n + + Desired number of bins (default: 4). + """ + def __init__(self, n=4): + self.n = n + + def __call__(self, data: Table, attribute): + attribute = data.domain[attribute] + values = data.get_column(attribute) + values = values.astype(float) + if not values.size: + return self._create_binned_var(None, attribute) + + var = data.domain[attribute] + if isinstance(var, TimeVariable): + binnings = time_binnings(values) + else: + binnings = decimal_binnings(values) + return self._create_binned_var(binnings, attribute) + + def _create_binned_var(self, binnings, variable): + if not binnings: + return Discretizer.create_discretized_var(variable, []) + + # If self.n is 2, require two intervals (one threshold, excluding top + # and bottom), else require at least three intervals + # ... unless this is the only option, in which case we use it + # Break ties in favour of more bins + binning = min( + (binning for binning in binnings + if len(binning.thresholds) - 2 >= 1 + (self.n != 2)), + key=lambda binning: (abs(self.n - (len(binning.short_labels) - 1)), + -len(binning.short_labels)), + default=binnings[-1]) + + if len(binning.thresholds) == 2: + return Discretizer.create_discretized_var(variable, []) + + blabels = binning.labels[1:-1] + labels = [f"< {blabels[0]}"] + [ + f"{lab1} - {lab2}" for lab1, lab2 in zip(blabels, blabels[1:]) + ] + [f"≥ {blabels[-1]}"] + + discretizer = Discretizer(variable, list(binning.thresholds[1:-1])) + dvar = DiscreteVariable(name=variable.name, values=labels, + compute_value=discretizer, + sparse=variable.sparse) + dvar.source_variable = variable + return dvar class BinDefinition(NamedTuple): @@ -234,7 +434,7 @@ def decimal_binnings( data, *, min_width=0, min_bins=2, max_bins=50, min_unique=5, add_unique=0, factors=(0.01, 0.02, 0.025, 0.05, 0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 20), - label_fmt="%g"): + label_fmt="%g") -> List[BinDefinition]: """ Find a set of nice splits of data into bins @@ -283,13 +483,6 @@ def decimal_binnings( or a function for formatting thresholds (e.g. var.str_val) Returns: - bin_boundaries (list of np.ndarray): a list of bin boundaries, - including the top boundary of the last interval, hence the list - size equals the number bins + 1. These array match the `bin` - argument of `numpy.histogram`. - - This is returned if `return_defs` is left `True`. - bin_definition (list of BinDefinition): `BinDefinition` is a named tuple containing the beginning of the first bin (`start`), number of bins (`nbins`) and their widths @@ -297,8 +490,6 @@ def decimal_binnings( elements, which describes bins of unequal width and is used for binnings that match the unique values in the data (see `min_unique` and `add_unique`). - - This is returned if `return_defs` is `False`. """ bins = [] @@ -329,7 +520,8 @@ def decimal_binnings( return bins -def time_binnings(data, *, min_bins=2, max_bins=50, min_unique=5, add_unique=0): +def time_binnings(data, *, min_bins=2, max_bins=50, min_unique=5, add_unique=0 + ) -> List[BinDefinition]: """ Find a set of nice splits of time variable data into bins @@ -355,7 +547,7 @@ def time_binnings(data, *, min_bins=2, max_bins=50, min_unique=5, add_unique=0): number of unique values Returns: - bin_boundaries (list): a list of possible binning. + bin_boundaries (list of BinDefinition): a list of possible binning. Each element of `bin_boundaries` is a tuple consisting of a label describing the bin size (e.g. `2 weeks`) and a list of thresholds. Thresholds are given as pairs @@ -386,7 +578,7 @@ def _time_binnings(mn, mx, min_pts, max_pts): if not times: continue times = [time.struct_time(t + (0, 0, 0)) for t in times] - thresholds = [calendar.timegm(t) for t in times] + thresholds = np.array([calendar.timegm(t) for t in times]) labels = [time.strftime(fmt, t) for t in times] short_labels = _simplified_labels(labels) if place == 2 and step >= 7: @@ -448,7 +640,7 @@ def _simplified_labels(labels): to_remove = "42" while True: firsts = {f for f, *_ in (lab.split() for lab in labels)} - if len(firsts) > 1: + if len(firsts) != 1: # can be 0 if there are no labels break to_remove = firsts.pop() flen = len(to_remove) @@ -532,7 +724,10 @@ def __call__(self, data, attribute): data.domain[attribute], points) @classmethod - def _normalize(cls, X, axis=None, out=None): + def _normalize(cls, + X: Union[List[List[Number]], np.ndarray], + axis: Optional[int] = None, + out: Optional[np.ndarray] = None) -> np.ndarray: """ Normalize `X` array so it sums to 1.0 over the `axis`. @@ -547,6 +742,7 @@ def _normalize(cls, X, axis=None, out=None): """ X = np.asarray(X, dtype=float) scale = np.sum(X, axis=axis, keepdims=True) + scale[scale == 0] = 1 if out is None: return X / scale else: @@ -576,6 +772,8 @@ def _entropy_normalized(cls, D, axis=None): # req: np.all(np.abs(np.sum(D, axis=axis) - 1) < 1e-9) D = np.asarray(D) + if np.sum(D) == 0: + return 0 Dc = np.clip(D, np.finfo(D.dtype).eps, 1.0) return - np.sum(D * np.log2(Dc), axis=axis) diff --git a/Orange/preprocess/fss.py b/Orange/preprocess/fss.py index b892508bde1..fc68eab990e 100644 --- a/Orange/preprocess/fss.py +++ b/Orange/preprocess/fss.py @@ -93,7 +93,7 @@ def __call__(self, data): def score_only_nice_features(self, data, method): # dtype must be defined because array can be empty mask = np.array([isinstance(a, method.feature_type) - for a in data.domain.attributes], dtype=np.bool) + for a in data.domain.attributes], dtype=bool) features = [f for f in data.domain.attributes if isinstance(f, method.feature_type)] scores = [method(data, f) for f in features] diff --git a/Orange/preprocess/impute.py b/Orange/preprocess/impute.py index f11674cba4f..5f3bb972e1b 100644 --- a/Orange/preprocess/impute.py +++ b/Orange/preprocess/impute.py @@ -2,6 +2,7 @@ import scipy.sparse as sp import Orange.data +from Orange.data.table import DomainTransformationError from Orange.statistics import distribution, basic_stats from Orange.util import Reprable from .transformation import Transformation, Lookup @@ -88,7 +89,7 @@ class DropInstances(BaseImputeMethod): description = "" def __call__(self, data, variable): - col, _ = data.get_column_view(variable) + col = data.get_column(variable) return np.isnan(col) @@ -172,7 +173,7 @@ def copy(self): return FixedValueByType(*self.defaults.values()) -class ReplaceUnknownsModel(Reprable): +class ReplaceUnknownsModel(Transformation): """ Replace unknown values with predicted values using a `Orange.base.Model` @@ -185,15 +186,14 @@ class ReplaceUnknownsModel(Reprable): """ def __init__(self, variable, model): assert model.domain.class_var == variable - self.variable = variable + super().__init__(variable) self.model = model def __call__(self, data): if isinstance(data, Orange.data.Instance): data = Orange.data.Table.from_list(data.domain, [data]) domain = data.domain - column = np.array(data.get_column_view(self.variable)[0], copy=True) - + column = data.transform(self._target_domain).get_column(self.variable, copy=True) mask = np.isnan(column) if not np.any(mask): return column @@ -203,10 +203,25 @@ def __call__(self, data): data = data.transform( Orange.data.Domain(domain.attributes, None, domain.metas) ) - predicted = self.model(data[mask]) - column[mask] = predicted + try: + column[mask] = self.model(data[mask]) + except DomainTransformationError: + # owpredictions showed error when imputing target using a Model + # based imputer (owpredictions removes the target before predicing) + pass return column + def transform(self, c): + assert False, "abstract in Transformation, never used here" + + def __eq__(self, other): + return type(self) is type(other) \ + and self.variable == other.variable \ + and self.model == other.model + + def __hash__(self): + return hash((type(self), hash(self.variable), hash(self.model))) + class Model(BaseImputeMethod): _name = "Model-based imputer" @@ -224,7 +239,8 @@ def __call__(self, data, variable): variable = data.domain[variable] domain = domain_with_class_var(data.domain, variable) - if self.learner.check_learner_adequacy(domain): + incompatibility_reason = self.learner.incompatibility_reason(domain) + if incompatibility_reason is None: data = data.transform(domain) model = self.learner(data) assert model.domain.class_var == variable @@ -239,7 +255,7 @@ def copy(self): def supports_variable(self, variable): domain = Orange.data.Domain([], class_vars=variable) - return self.learner.check_learner_adequacy(domain) + return self.learner.incompatibility_reason(domain) is None def domain_with_class_var(domain, class_var): diff --git a/Orange/preprocess/normalize.py b/Orange/preprocess/normalize.py index 83868ee5d11..8e9206f697f 100644 --- a/Orange/preprocess/normalize.py +++ b/Orange/preprocess/normalize.py @@ -1,7 +1,7 @@ import numpy as np from Orange.data import Domain, ContinuousVariable -from Orange.statistics import distribution +from Orange.statistics import basic_stats from Orange.util import Reprable from .preprocess import Normalize from .transformation import Normalizer as Norm @@ -22,30 +22,34 @@ def __init__(self, self.normalize_datetime = normalize_datetime def __call__(self, data): - dists = distribution.get_distributions(data) - new_attrs = [self.normalize(dists[i], var) for + stats = basic_stats.DomainBasicStats(data, compute_variance=True) + new_attrs = [self.normalize(stats[i], var) for (i, var) in enumerate(data.domain.attributes)] new_class_vars = data.domain.class_vars if self.transform_class: attr_len = len(data.domain.attributes) - new_class_vars = [self.normalize(dists[i + attr_len], var) for + new_class_vars = [self.normalize(stats[i + attr_len], var) for (i, var) in enumerate(data.domain.class_vars)] domain = Domain(new_attrs, new_class_vars, data.domain.metas) return data.transform(domain) - def normalize(self, dist, var): + def normalize(self, stats, var): if not var.is_continuous or (var.is_time and not self.normalize_datetime): return var elif self.norm_type == Normalize.NormalizeBySD: - var = self.normalize_by_sd(dist, var) + var = self.normalize_by_sd(stats, var) elif self.norm_type == Normalize.NormalizeBySpan: - var = self.normalize_by_span(dist, var) + var = self.normalize_by_span(stats, var) return var - def normalize_by_sd(self, dist, var: ContinuousVariable) -> ContinuousVariable: - avg, sd = (dist.mean(), dist.standard_deviation()) if dist.size else (0, 1) + def normalize_by_sd(self, stats, var: ContinuousVariable) -> ContinuousVariable: + avg, sd = (stats.mean, stats.var**0.5) + if np.isnan(avg): + avg = 0 + if np.isnan(sd): + sd = 1 if sd == 0: sd = 1 if self.center: @@ -62,8 +66,8 @@ def normalize_by_sd(self, dist, var: ContinuousVariable) -> ContinuousVariable: return var.copy(compute_value=compute_val, number_of_decimals=num_decimals) - def normalize_by_span(self, dist, var: ContinuousVariable) -> ContinuousVariable: - dma, dmi = (dist.max(), dist.min()) if dist.shape[1] else (np.nan, np.nan) + def normalize_by_span(self, stats, var: ContinuousVariable) -> ContinuousVariable: + dma, dmi = (stats.max, stats.min) diff = dma - dmi if diff < 1e-15: diff = 1 diff --git a/Orange/preprocess/preprocess.py b/Orange/preprocess/preprocess.py index 1334fbc8c0e..743a2d14283 100644 --- a/Orange/preprocess/preprocess.py +++ b/Orange/preprocess/preprocess.py @@ -158,20 +158,18 @@ def __call__(self, data): if isinstance(data, SqlTable): return Impute()(data) imputer = SimpleImputer(strategy=self.strategy) - X = imputer.fit_transform(data.X) + imputer.fit(data.X) # Create new variables with appropriate `compute_value`, but # drop the ones which do not have valid `imputer.statistics_` # (i.e. all NaN columns). `sklearn.preprocessing.Imputer` already # drops them from the transformed X. - features = [impute.Average()(data, var, value) + features = [var.copy(compute_value=impute.ReplaceUnknowns(var, value)) for var, value in zip(data.domain.attributes, imputer.statistics_) if not np.isnan(value)] - assert X.shape[1] == len(features) domain = Orange.data.Domain(features, data.domain.class_vars, data.domain.metas) new_data = data.transform(domain) - new_data.X = X return new_data @@ -414,12 +412,13 @@ def __call__(self, data): rstate = np.random.RandomState(self.rand_seed) # ensure the same seed is not used to shuffle X and Y at the same time r1, r2, r3 = rstate.randint(0, 2 ** 32 - 1, size=3, dtype=np.int64) - if self.rand_type & Randomize.RandomizeClasses: - new_data.Y = self.randomize(new_data.Y, r1) - if self.rand_type & Randomize.RandomizeAttributes: - new_data.X = self.randomize(new_data.X, r2) - if self.rand_type & Randomize.RandomizeMetas: - new_data.metas = self.randomize(new_data.metas, r3) + with new_data.unlocked(): + if self.rand_type & Randomize.RandomizeClasses: + new_data.Y = self.randomize(new_data.Y, r1) + if self.rand_type & Randomize.RandomizeAttributes: + new_data.X = self.randomize(new_data.X, r2) + if self.rand_type & Randomize.RandomizeMetas: + new_data.metas = self.randomize(new_data.metas, r3) return new_data @staticmethod diff --git a/Orange/preprocess/remove.py b/Orange/preprocess/remove.py index 21008823189..74b00f2d231 100644 --- a/Orange/preprocess/remove.py +++ b/Orange/preprocess/remove.py @@ -208,13 +208,9 @@ def purge_var_M(var, data, flags): def has_at_least_two_values(data, var): ((dist, unknowns),) = data._compute_distributions([var]) - # TODO this check is suboptimal for sparse since get_column_view - # densifies the data. Should be irrelevant after Pandas. - _, sparse = data.get_column_view(var) if var.is_continuous: dist = dist[1, :] - min_size = 0 if sparse and unknowns else 1 - return np.sum(dist > 0.0) > min_size + return np.sum(dist > 0.0) > 1 def remove_constant(var, data): @@ -233,11 +229,11 @@ def remove_constant(var, data): def remove_unused_values(var, data): - unique = nanunique(data.get_column_view(var)[0].astype(float)).astype(int) + unique = nanunique(data.get_column(var)).astype(int) if len(unique) == len(var.values): return var used_values = [var.values[i] for i in unique] - translation_table = np.array([np.NaN] * len(var.values)) + translation_table = np.array([np.nan] * len(var.values)) translation_table[unique] = range(len(used_values)) return DiscreteVariable(var.name, values=used_values, sparse=var.sparse, compute_value=Lookup(var, translation_table)) diff --git a/Orange/preprocess/score.py b/Orange/preprocess/score.py index 298dc146bcb..f8986f93cbc 100644 --- a/Orange/preprocess/score.py +++ b/Orange/preprocess/score.py @@ -172,8 +172,13 @@ def join_derived_features(scores): for attr, score in zip(model_attributes, scores): # Go up the chain of preprocessors to obtain the original variable, but no further # than the data.domain, because the data is perhaphs already preprocessed. - while not (attr in data.domain) and getattr(attr, 'compute_value', False): - attr = getattr(attr.compute_value, 'variable', attr) + while not (attr in data.domain) and attr.compute_value is not None: + if hasattr(attr.compute_value, 'variable'): + attr = getattr(attr.compute_value, 'variable') + else: + # The attributes's parent can not be identified. Thus, the attributes + # score will be ignored. + break scores_grouped[attr].append(score) return [max(scores_grouped[attr]) if attr in scores_grouped else 0 @@ -346,7 +351,7 @@ class ReliefF(Scorer): friendly_name = "ReliefF" preprocessors = Scorer.preprocessors + [RemoveNaNColumns()] - def __init__(self, n_iterations=50, k_nearest=10, random_state=None): + def __init__(self, n_iterations=50, k_nearest=10, random_state=0): self.n_iterations = n_iterations self.k_nearest = k_nearest self.random_state = random_state @@ -367,7 +372,7 @@ def score_data(self, data, feature): from Orange.preprocess._relieff import relieff weights = np.asarray(relieff(data.X, data.Y, self.n_iterations, self.k_nearest, - np.array([a.is_discrete for a in data.domain.attributes]), + np.array([a.is_discrete for a in data.domain.attributes], dtype=bool), rstate)) if feature: return weights[0] @@ -381,7 +386,7 @@ class RReliefF(Scorer): friendly_name = "RReliefF" preprocessors = Scorer.preprocessors + [RemoveNaNColumns()] - def __init__(self, n_iterations=50, k_nearest=50, random_state=None): + def __init__(self, n_iterations=50, k_nearest=50, random_state=0): self.n_iterations = n_iterations self.k_nearest = k_nearest self.random_state = random_state diff --git a/Orange/preprocess/tests/test_discretize.py b/Orange/preprocess/tests/test_discretize.py index 2ad7ad1cbb6..5f109252632 100644 --- a/Orange/preprocess/tests/test_discretize.py +++ b/Orange/preprocess/tests/test_discretize.py @@ -1,14 +1,343 @@ # File contains some long lines; breaking them would decrease readability -# pylint: disable=line-too-long +# pylint: disable=line-too-long,too-many-lines,protected-access import calendar import unittest +from unittest.mock import patch from time import struct_time, mktime import numpy as np -from Orange.data import ContinuousVariable +from Orange.data import ContinuousVariable, TimeVariable, Table, Domain from Orange.preprocess.discretize import \ - _time_binnings, time_binnings, BinDefinition, Discretizer + _time_binnings, time_binnings, BinDefinition, Discretizer, FixedWidth, \ + FixedTimeWidth, Binning, \ + TooManyIntervals, SingleValueSql, BinSql + + +class TestFixedWidth(unittest.TestCase): + def test_discretization(self): + x = np.array([[0.21, 0.335, 0, 0.26, np.nan], + [0] * 5, + [np.nan] * 5]).T + domain = Domain([ContinuousVariable(f"c{i}") for i in range(x.shape[1])]) + data = Table.from_numpy(domain, x, None) + + dvar = FixedWidth(0.1, 2)(data, 0) + np.testing.assert_almost_equal(dvar.compute_value.points, + (0.1, 0.2, 0.3)) + self.assertEqual(dvar.values, + ('< 0.10', '0.10 - 0.20', '0.20 - 0.30', '≥ 0.30')) + + dvar = FixedWidth(0.2, 1)(data, 0) + np.testing.assert_almost_equal(dvar.compute_value.points, (0.2, )) + self.assertEqual(dvar.values, ('< 0.2', '≥ 0.2')) + + dvar = FixedWidth(1, 2)(data, 0) + np.testing.assert_almost_equal(dvar.compute_value.points, []) + + dvar = FixedWidth(0.11, 2)(data, 1) + np.testing.assert_almost_equal(dvar.compute_value.points, []) + + dvar = FixedWidth(0.11, 2)(data, 2) + np.testing.assert_almost_equal(dvar.compute_value.points, []) + + self.assertRaises(TooManyIntervals, FixedWidth(0.0001, 1), data, 0) + + +class TestFixedTimeWidth(unittest.TestCase): + def test_discretization(self): + t = TimeVariable("t") + x = np.array([[t.to_val("1914"), t.to_val("1945"), np.nan], + [t.to_val("1914"), t.to_val("1914"), np.nan], + [np.nan, np.nan, np.nan], + ]).T + domain = Domain([t, TimeVariable("t2"), TimeVariable("t3")]) + data = Table.from_numpy(domain, x, None) + + dvar = FixedTimeWidth(10, 1)(data, 1) + np.testing.assert_almost_equal(dvar.compute_value.points, []) + + dvar = FixedTimeWidth(10, 2)(data, 2) + np.testing.assert_almost_equal(dvar.compute_value.points, []) + + self.assertRaises(TooManyIntervals, FixedWidth(0.0001, 1), data, 0) + + dvar = FixedTimeWidth(10, 0)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(str(y))) for y in (1920, 1930, 1940)]) + self.assertEqual(dvar.values, + ('< 1920', '1920 - 1930', '1930 - 1940', '≥ 1940')) + + dvar = FixedTimeWidth(5, 0)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(str(y))) for y in (1915, 1920, 1925, 1930, 1935, + 1940, 1945)]) + self.assertEqual(dvar.values, + ('< 1915', '1915 - 1920', '1920 - 1925', '1925 - 1930', + '1930 - 1935', '1935 - 1940', '1940 - 1945', '≥ 1945') + ) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-07-28"), t.to_val("1918-11-11")]]).T) + dvar = FixedTimeWidth(6, 1)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1915-01-01", "1915-07-01", + "1916-01-01", "1916-07-01", + "1917-01-01", "1917-07-01", + "1918-01-01", "1918-07-01")]) + + def tuple_lower(t): + return tuple(a.lower() for a in t) + + self.assertEqual(tuple_lower(dvar.values), + ('< 15 jan', '15 jan - jul', '15 jul - 16 jan', + '16 jan - jul', '16 jul - 17 jan', '17 jan - jul', + '17 jul - 18 jan', '18 jan - jul', '≥ 18 jul')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-07-28"), t.to_val("1914-11-11")]]).T) + dvar = FixedTimeWidth(6, 1)(data, 0) + np.testing.assert_almost_equal(dvar.compute_value.points, []) + + dvar = FixedTimeWidth(2, 1)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-09-01", "1914-11-01")]) + self.assertEqual(tuple_lower(dvar.values), ('< sep', 'sep - nov', '≥ nov')) + + dvar = FixedTimeWidth(1, 1)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-08-01", "1914-09-01", + "1914-10-01", "1914-11-01")]) + self.assertEqual(tuple_lower(dvar.values), + ('< aug', 'aug - sep', 'sep - oct', + 'oct - nov', '≥ nov')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-28 10:45"), + t.to_val("1914-07-04 15:25")]]).T) + dvar = FixedTimeWidth(2, 2)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-29", "1914-07-01", + "1914-07-03")]) + self.assertEqual(tuple_lower(dvar.values), + ('< jun 29', 'jun 29 - jul 01', + 'jul 01 - jul 03', '≥ jul 03')) + + dvar = FixedTimeWidth(1, 2)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-29", "1914-06-30", + "1914-07-01", "1914-07-02", + "1914-07-03", "1914-07-04")]) + self.assertEqual(tuple_lower(dvar.values), + ('< jun 29', 'jun 29 - jun 30', + 'jun 30 - jul 01', 'jul 01 - jul 02', + 'jul 02 - jul 03', 'jul 03 - jul 04', + '≥ jul 04')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-12-30 22:45"), + t.to_val("1915-01-02 15:25")]]).T) + dvar = FixedTimeWidth(1, 2)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-12-31", "1915-01-01", + "1915-01-02")]) + self.assertEqual(tuple_lower(dvar.values), + ('< 14 dec 31', + '14 dec 31 - 15 jan 01', + '15 jan 01 - jan 02', '≥ 15 jan 02')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-28 10:45"), + t.to_val("1914-06-28 15:25")]]).T) + dvar = FixedTimeWidth(2, 3)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-28 12:00", "1914-06-28 14:00")]) + self.assertEqual(dvar.values, ('< 12:00', '12:00 - 14:00', '≥ 14:00')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-28 10:45"), + t.to_val("1914-06-28 15:25")]]).T) + dvar = FixedTimeWidth(1, 3)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-28 11:00", "1914-06-28 12:00", + "1914-06-28 13:00", "1914-06-28 14:00", + "1914-06-28 15:00")]) + self.assertEqual(dvar.values, ('< 11:00', '11:00 - 12:00', + '12:00 - 13:00', '13:00 - 14:00', + '14:00 - 15:00', '≥ 15:00')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-28 22:45"), + t.to_val("1914-06-29 03:25")]]).T) + dvar = FixedTimeWidth(1, 3)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-28 23:00", "1914-06-29 00:00", + "1914-06-29 01:00", "1914-06-29 02:00", + "1914-06-29 03:00")]) + self.assertEqual(tuple_lower(dvar.values), + ('< jun 28 23:00', + 'jun 28 23:00 - jun 29 00:00', + 'jun 29 00:00 - 01:00', + 'jun 29 01:00 - 02:00', + 'jun 29 02:00 - 03:00', + '≥ jun 29 03:00')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-28 22:43"), + t.to_val("1914-06-28 23:01")]]).T) + dvar = FixedTimeWidth(5, 4)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-28 22:45", "1914-06-28 22:50", + "1914-06-28 22:55", "1914-06-28 23:00")]) + self.assertEqual(dvar.values, ('< 22:45', "22:45 - 22:50", + "22:50 - 22:55", "22:55 - 23:00", + '≥ 23:00')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-30 23:48"), + t.to_val("1914-07-01 00:06")]]).T) + dvar = FixedTimeWidth(5, 4)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-30 23:50", "1914-06-30 23:55", + "1914-07-01 00:00", "1914-07-01 00:05")]) + self.assertEqual(tuple_lower(dvar.values), + ('< jun 30 23:50', "jun 30 23:50 - 23:55", + "jun 30 23:55 - jul 01 00:00", + "jul 01 00:00 - 00:05", '≥ jul 01 00:05')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-29 23:48"), + t.to_val("1914-06-30 00:06")]]).T) + dvar = FixedTimeWidth(5, 4)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-29 23:50", "1914-06-29 23:55", + "1914-06-30 00:00", "1914-06-30 00:05")]) + self.assertEqual(tuple_lower(dvar.values), + ('< jun 29 23:50', "jun 29 23:50 - 23:55", + "jun 29 23:55 - jun 30 00:00", + "jun 30 00:00 - 00:05", '≥ jun 30 00:05')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-29 23:48:05"), + t.to_val("1914-06-29 23:51:59")]]).T) + dvar = FixedTimeWidth(1, 4)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-29 23:49", "1914-06-29 23:50", + "1914-06-29 23:51")]) + self.assertEqual(dvar.values, ('< 23:49', "23:49 - 23:50", + "23:50 - 23:51", '≥ 23:51')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-06-29 23:48:05.123"), + t.to_val("1914-06-29 23:48:33.684")]]).T) + dvar = FixedTimeWidth(10, 5)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-06-29 23:48:10", + "1914-06-29 23:48:20", + "1914-06-29 23:48:30")]) + self.assertEqual(dvar.values, ('< 23:48:10', "23:48:10 - 23:48:20", + "23:48:20 - 23:48:30", '≥ 23:48:30')) + + data = Table.from_numpy( + Domain([t]), + np.array([[t.to_val("1914-12-31 23:59:58.1"), + t.to_val("1915-01-01 00:00:01.8")]]).T) + dvar = FixedTimeWidth(1, 5)(data, 0) + np.testing.assert_almost_equal( + dvar.compute_value.points, + [int(t.to_val(y)) for y in ("1914-12-31 23:59:59", + "1915-01-01 00:00:00", + "1915-01-01 00:00:01")]) + self.assertEqual(dvar.values, ('< 23:59:59', "23:59:59 - 00:00:00", + "00:00:00 - 00:00:01", '≥ 00:00:01')) + + self.assertRaises(TooManyIntervals, FixedTimeWidth(0.0001, 5), data, 0) + + +class TestBinningDiscretizer(unittest.TestCase): + def test_no_data(self): + no_data = Table(Domain([ContinuousVariable("y")]), np.zeros((0, 1))) + dvar = Binning()(no_data, 0) + self.assertEqual(dvar.compute_value.points, []) + + @patch("Orange.preprocess.discretize.time_binnings") + @patch("Orange.preprocess.discretize.decimal_binnings") + @patch("Orange.preprocess.discretize.Binning._create_binned_var") + def test_call(self, _, decbin, timebin): + data = Table(Domain([ContinuousVariable("y"), TimeVariable("t")]), + np.array([[1, 2], [3, 4]])) + + Binning(5)(data, 0) + timebin.assert_not_called() + self.assertEqual(list(decbin.call_args[0][0]), [1, 3]) + decbin.reset_mock() + + Binning(5)(data, 1) + decbin.assert_not_called() + self.assertEqual(list(timebin.call_args[0][0]), [2, 4]) + + def test_binning_selection(self): + var = ContinuousVariable("y") + discretize = Binning(2) + # pylint: disable=redefined-outer-name + create = discretize._create_binned_var + + binnings = [] + self.assertEqual(create(binnings, var).compute_value.points, []) + + binnings = None + self.assertEqual(create(binnings, var).compute_value.points, []) + + binnings = [ + BinDefinition(np.arange(i + 1), + [f"t{x}" for x in range(i + 1)], + [f"t{x}" for x in range(i + 1)], + 1 / i, str(i) + ) + for i in (3, 5, 10, 20) + ] + + for discretize.n in (2, 3): + self.assertEqual(create(binnings, var).values, + ('< t1', "t1 - t2", "≥ t2")) + + for discretize.n in (4, 5, 6, 7): + self.assertEqual(create(binnings, var).values, + ('< t1', "t1 - t2", "t2 - t3", "t3 - t4", "≥ t4")) + + for discretize.n in range(8, 15): + self.assertEqual(len(create(binnings, var).values), 10) + + for discretize.n in range(16, 25): + self.assertEqual(len(create(binnings, var).values), 20) # pylint: disable=redefined-builtin @@ -34,12 +363,13 @@ def tr1(s): s = s.replace(localname, engname) return s - def tr(ss): + def tr2(ss): return list(map(tr1, ss)) def testbin(start, end): bins = _time_binnings(create(*start), create(*end), 3, 51) - return [(bin.width_label, tr(bin.short_labels), bin.thresholds) + return [(bin.width_label, tr2(bin.short_labels), + list(bin.thresholds)) for bin in reversed(bins)] self.assertEqual( @@ -782,6 +1112,184 @@ def test_equality(self): self.assertNotEqual(t1, t1a) self.assertNotEqual(hash(t1), hash(t1a)) + def test_fmt_interval(self): + def fmt(x): + return f"{x:.2f}" + + f = Discretizer._fmt_interval + self.assertEqual(f(1, 2, str), "1 - 2") + self.assertEqual(f(1, 2, fmt), "1 - 2") + self.assertEqual(f(1, 2, fmt, strip_zeros=False), "1.00 - 2.00") + + self.assertEqual(f(-np.inf, 2, fmt), "< 2") + self.assertEqual(f(-np.inf, 2, fmt, strip_zeros=False), "< 2.00") + self.assertEqual(f(None, 2, fmt), "< 2") + self.assertEqual(f(None, 2, fmt, strip_zeros=False), "< 2.00") + + self.assertEqual(f(2, np.inf, fmt), "≥ 2") + self.assertEqual(f(2, np.inf, fmt, strip_zeros=False), "≥ 2.00") + self.assertEqual(f(2, None, fmt), "≥ 2") + self.assertEqual(f(2, None, fmt, strip_zeros=False), "≥ 2.00") + + with self.assertRaises(ValueError): + f(1.122, 1.123, fmt) + + + def test_get_labels(self): + points = [2.46, 2.68, 2.794] + self.assertEqual( + Discretizer._get_labels(lambda x: f"{x:.1f}", points), + ['< 2.5', '2.5 - 2.7', '2.7 - 2.8', '≥ 2.8'] + ) + self.assertEqual( + Discretizer._get_labels(lambda x: f"{x:.2f}", points), + ['< 2.46', '2.46 - 2.68', '2.68 - 2.79', '≥ 2.79'] + ) + self.assertEqual( + Discretizer._get_labels(lambda x: f"{x:.4f}", points), + ['< 2.46', '2.46 - 2.68', '2.68 - 2.794', '≥ 2.794'] + ) + + self.assertEqual( + Discretizer._get_labels(lambda x: f"{x:.4f}", points, + strip_zeros=False), + ['< 2.4600', '2.4600 - 2.6800', '2.6800 - 2.7940', '≥ 2.7940'] + ) + + self.assertEqual( + Discretizer._get_labels(lambda x: f"{x:.4f}", [100, 200]), + ['< 100', '100 - 200', '≥ 200'] + ) + + self.assertEqual( + Discretizer._get_labels(lambda x: f"{x:.4f}", [0]), + ['< 0', '≥ 0'] + ) + + def test_get_discretized_values_empty(self): + x = ContinuousVariable("x") + d = Discretizer(x, []) + points, values, to_sql = d._get_discretized_values(None, np.array([])) + self.assertEqual(len(points), 0) + self.assertEqual(values, ["single_value"]) + self.assertIsInstance(to_sql, SingleValueSql) + + def test_get_discretized_values_identical_points(self): + x = ContinuousVariable("x") + with self.assertRaises(ValueError): + Discretizer._get_discretized_values(x, np.array([0, 1, 1, 2])) + + def test_get_discretized_values_no_ndigits(self): + x = ContinuousVariable("x", number_of_decimals=2) + points, values, to_sql \ + = Discretizer._get_discretized_values(x, np.array([1, 2, 3, 4])) + np.testing.assert_equal(points, [1, 2, 3, 4]) + self.assertEqual(values, ['< 1', '1 - 2', '2 - 3', '3 - 4', '≥ 4']) + self.assertIsInstance(to_sql, BinSql) + + points, values, to_sql \ + = Discretizer._get_discretized_values(x, np.array([1, 2.1, 3, 4])) + np.testing.assert_equal(points, [1, 2.1, 3, 4]) + self.assertEqual(values, ['< 1', '1 - 2.1', '2.1 - 3', '3 - 4', '≥ 4']) + self.assertIsInstance(to_sql, BinSql) + + points, values, to_sql \ + = Discretizer._get_discretized_values(x, + np.array([1, 2.1, 3.1234, 4])) + np.testing.assert_equal(points, [1, 2.1, 3.1234, 4]) + self.assertEqual(values, ['< 1', '1 - 2.1', '2.1 - 3.1234', '3.1234 - 4', '≥ 4']) + self.assertIsInstance(to_sql, BinSql) + + points, values, to_sql \ + = Discretizer._get_discretized_values(x, + np.array([1, + 2.1000000001, + 2.1000000002, + 4])) + np.testing.assert_equal(points, [1, 2.1000000001, 2.1000000002, 4]) + self.assertEqual(values, ['< 1', + '1 - 2.1000000001', + '2.1000000001 - 2.1000000002', + '2.1000000002 - 4', + '≥ 4']) + self.assertIsInstance(to_sql, BinSql) + + points, values, to_sql \ + = Discretizer._get_discretized_values(x, + np.array([1, + 2.1000000001, + 2.1000000002, + 2.1000000003])) + np.testing.assert_equal(points, + [1, 2.1000000001, 2.1000000002, 2.1000000003]) + self.assertEqual(values, ['< 1', + '1 - 2.1000000001', + '2.1000000001 - 2.1000000002', + '2.1000000002 - 2.1000000003', + '≥ 2.1000000003']) + self.assertIsInstance(to_sql, BinSql) + + points, values, to_sql \ + = Discretizer._get_discretized_values(x, + np.array([1, + 2.1000000001, + 2.100000000211, + 2.1000000003])) + np.testing.assert_equal(points, + [1, 2.1000000001, 2.1000000002, 2.1000000003]) + self.assertEqual(values, ['< 1', + '1 - 2.1000000001', + '2.1000000001 - 2.1000000002', + '2.1000000002 - 2.1000000003', + '≥ 2.1000000003']) + self.assertIsInstance(to_sql, BinSql) + + def test_get_discretized_values_round_builtin_vs_numpy(self): + x = ContinuousVariable("x", number_of_decimals=0) + points, values, _ \ + = Discretizer._get_discretized_values(x, + np.array([2.3455, + 2.346])) + np.testing.assert_equal(points, + [2.345, 2.346]) + self.assertEqual(values, ['< 2.345', + '2.345 - 2.346', + '≥ 2.346']) + + points, values, _ \ + = Discretizer._get_discretized_values(x, + np.array([2.1345, + 2.135])) + np.testing.assert_equal(points, + [2.1345, 2.135]) + self.assertEqual(values, ['< 2.1345', + '2.1345 - 2.135', + '≥ 2.135']) + + def test_get_discretized_values_with_ndigits(self): + x = ContinuousVariable("x") + apoints = [1, 2, 3, 4] + points, values, to_sql \ + = Discretizer._get_discretized_values(x, apoints, ndigits=0) + np.testing.assert_array_equal(points, np.array([1, 2, 3, 4])) + self.assertEqual(values, ['< 1', '1 - 2', '2 - 3', '3 - 4', '≥ 4']) + self.assertIsInstance(to_sql, BinSql) + + points, values, to_sql \ + = Discretizer._get_discretized_values(x, apoints, ndigits=2) + np.testing.assert_array_equal(points, np.array([1, 2, 3, 4])) + self.assertEqual(values, ['< 1.00', '1.00 - 2.00', '2.00 - 3.00', + '3.00 - 4.00', '≥ 4.00']) + self.assertIsInstance(to_sql, BinSql) + + apoints = [1, 2.1234, 2.1345, 4] + points, values, to_sql \ + = Discretizer._get_discretized_values(x, apoints, ndigits=1) + np.testing.assert_array_equal(points, np.array([1, 2.12, 2.13, 4])) + self.assertEqual(values, + ['< 1', '1 - 2.12', '2.12 - 2.13', '2.13 - 4', '≥ 4']) + self.assertIsInstance(to_sql, BinSql) + if __name__ == '__main__': unittest.main() diff --git a/Orange/preprocess/tests/test_impute.py b/Orange/preprocess/tests/test_impute.py index 2eeaf0e9cdc..b2320cc42d4 100644 --- a/Orange/preprocess/tests/test_impute.py +++ b/Orange/preprocess/tests/test_impute.py @@ -6,7 +6,8 @@ Domain, Table, \ DiscreteVariable, ContinuousVariable, TimeVariable, StringVariable from Orange.preprocess.impute import ReplaceUnknownsRandom, ReplaceUnknowns, \ - FixedValueByType + FixedValueByType, ReplaceUnknownsModel +from Orange.regression import LinearRegressionLearner from Orange.statistics.distribution import Discrete @@ -111,5 +112,36 @@ def test_with_default(self): "bar") +class TestReplaceUnknownsModel(unittest.TestCase): + def test_eq(self): + iris = Table("iris") + + v1 = iris.domain[0] + v2 = iris.domain[0] + v3 = iris.domain[1] + + l = LinearRegressionLearner() + def new_target(t): + dom = Domain(iris.domain[2:], class_vars=[t]) + return iris.transform(dom) + + mod1 = l(new_target(v1)) + t1 = ReplaceUnknownsModel(v1, mod1) + t1a = ReplaceUnknownsModel(v2, mod1) + t2 = ReplaceUnknownsModel(v3, l(new_target(v3))) + + self.assertEqual(t1, t1) + self.assertEqual(t1, t1a) + self.assertNotEqual(t1, t2) + + # the following should be equal, but will not be unless __eq__ for that + # particular model is defined + t1b = ReplaceUnknownsModel(v1, l(new_target(v1))) + self.assertNotEqual(t1, t1b) # this is WRONG + + self.assertEqual(hash(t1), hash(t1a)) + self.assertNotEqual(hash(t1), hash(t2)) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/preprocess/tests/test_transformation.py b/Orange/preprocess/tests/test_transformation.py index d6dd07b882b..fe55f4c88e7 100644 --- a/Orange/preprocess/tests/test_transformation.py +++ b/Orange/preprocess/tests/test_transformation.py @@ -1,10 +1,12 @@ import unittest import numpy as np +import scipy.sparse as sp from Orange.data import DiscreteVariable from Orange.preprocess.transformation import \ - Transformation, _Indicator, Normalizer, Lookup + Transformation, _Indicator, Normalizer, Lookup, Indicator, Indicator1, \ + MappingTransform class TestTransformEquality(unittest.TestCase): @@ -83,6 +85,115 @@ def test_lookup(self): self.assertNotEqual(t1, t1a) self.assertNotEqual(hash(t1), hash(t1a)) + def test_safe_lookup_table_equal(self): + sl = Lookup._safe_lookup_table_equal + self.assertTrue(sl([1, 2, 3], [1, 2, 3])) + self.assertTrue(sl(np.array([1, 2, 3]), np.array([1, 2, 3]))) + self.assertTrue(sl(np.array("foo bar baz".split()), np.array("foo bar baz".split()))) + + self.assertFalse(sl([1, 2, 3], [1, 2, 4])) + self.assertFalse(sl(np.array([1, 2, 3]), np.array([1, 2, 4]))) + self.assertFalse(sl(np.array("foo bar baz".split()), np.array("foo bar qux".split()))) + self.assertFalse(sl([1, 2, 3], [1, 2, 3, 4])) + self.assertFalse(sl(np.array([1, 2, 3]), np.array([1, 2, 3, 4]))) + self.assertFalse(sl(np.array("foo bar baz".split()), np.array("foo bar baz qux".split()))) + + self.assertFalse(sl(np.array([1, 2, 3]), np.array("foo bar baz".split()))) + + def test_eq(self): + self.assertEqual( + Lookup(self.disc1, np.array([0, 2, 1]), 1), + Lookup(self.disc1a, np.array([0, 2, 1]), 1)) + self.assertEqual( + Lookup(self.disc1, np.array(["foo", "bar", "baz"]), ""), + Lookup(self.disc1a, np.array(["foo", "bar", "baz"]), "")) + + self.assertNotEqual( + Lookup(self.disc1, np.array([0, 2, 1]), 1), + Lookup(self.disc1a, np.array([0, 1, 2]), 1)) + self.assertNotEqual( + Lookup(self.disc1, np.array([0, 2, 1]), 1), + Lookup(self.disc1a, np.array([0, 2, 1, 5]), 1)) + self.assertNotEqual( + Lookup(self.disc1, np.array([0, 2, 1]), 1), + Lookup(self.disc1a, np.array([0, 2, 1]), 2)) + self.assertNotEqual( + Lookup(self.disc1, np.array(["foo", "bar", "baz"]), ""), + Lookup(self.disc1a, np.array(["foo", "baz", "bar"]), "")) + self.assertNotEqual( + Lookup(self.disc1, np.array(["foo", "bar", "baz"]), ""), + Lookup(self.disc1a, np.array(["foo", "bar", "baz"]), "qux")) + self.assertNotEqual( + Lookup(self.disc1, np.array([0, 1, 2]), 1), + Lookup(self.disc1a, np.array(["foo", "bar", "baz"]), "qux")) + + def test_mapping(self): + def test_equal(a, b): + self.assertEqual(a, b) + self.assertEqual(hash(a), hash(b)) + + t1 = MappingTransform(self.disc1, {"a": "1", "b": "2", "c":"3"}) + t1a = MappingTransform(self.disc1a, {"a": "1", "b": "2", "c":"3"}) + t2 = MappingTransform(self.disc2, {"a": "1", "b": "2", "c":"3"}, + unknown="") + test_equal(t1, t1a) + self.assertNotEqual(t1, t2) + + t1 = MappingTransform(self.disc1, {"a": 1, "b": 2, "c": float("nan")}, + unknown=float("nan")) + t1_ = MappingTransform(self.disc1, {"a": 1, "b": 2, "c": float("nan")}, + unknown=float("nan")) + test_equal(t1, t1_) + t1_ = MappingTransform(self.disc1, {"a": 1, "b": float("nan"), "c": 2}, + unknown=float("nan")) + self.assertNotEqual(t1, t1_) + + t1_ = MappingTransform(self.disc1, {}, unknown=float("nan")) + self.assertNotEqual(t1, t1_) + t1_ = MappingTransform(self.disc1, {"f": 4, "k": 2, "j": 10}, + unknown=float("nan")) + self.assertNotEqual(t1, t1_) + + with self.assertRaises(ValueError): + MappingTransform(self.disc1, {float("nan"): 1}) + + +class TestIndicator(unittest.TestCase): + def test_nan(self): + var = DiscreteVariable("d", tuple("abcde")) + + col = np.array([1.0, 4, 2, np.nan, 2, 0]) + + transform = Indicator(var, 2).transform + expected = [0, 0, 1, np.nan, 1, 0] + np.testing.assert_equal(transform(col), expected) + sparse = transform(sp.csr_matrix(col)) + self.assertTrue(sp.issparse(sparse)) + np.testing.assert_equal(sparse.toarray().ravel(), expected) + self.assertEqual(transform(1), 0) + self.assertEqual(transform(2), 1) + self.assertTrue(np.isnan(transform(np.nan))) + + transform = Indicator(var, 0).transform + expected = [0, 0, 0, np.nan, 0, 1] + np.testing.assert_equal(transform(col), expected) + sparse = transform(sp.csr_matrix(col)) + # Currently, this always returns dense array + assert not sp.issparse(sparse) + np.testing.assert_equal(sparse, expected) + self.assertEqual(transform(1), 0) + self.assertEqual(transform(0), 1) + self.assertTrue(np.isnan(transform(np.nan))) + + transform = Indicator1(var, 2).transform + expected = [-1, -1, 1, np.nan, 1, -1] + np.testing.assert_equal(transform(col), expected) + np.testing.assert_equal(transform(sp.csr_matrix(col).toarray().ravel()), + expected) + self.assertEqual(transform(1), -1) + self.assertEqual(transform(2), 1) + self.assertTrue(np.isnan(transform(np.nan))) + if __name__ == '__main__': unittest.main() diff --git a/Orange/preprocess/transformation.py b/Orange/preprocess/transformation.py index 01ea719b5ce..249d637d493 100644 --- a/Orange/preprocess/transformation.py +++ b/Orange/preprocess/transformation.py @@ -1,8 +1,15 @@ +from typing import TYPE_CHECKING, Mapping, Optional + import numpy as np import scipy.sparse as sp +from pandas import isna + +from Orange.data import Instance, Table, Domain, Variable +from Orange.misc.collections import DictMissingConst +from Orange.util import Reprable, nan_eq, nan_hash_stand, frompyfunc -from Orange.data import Instance, Table, Domain -from Orange.util import Reprable +if TYPE_CHECKING: + from numpy.typing import DTypeLike class Transformation(Reprable): @@ -16,12 +23,28 @@ def __init__(self, variable): :type variable: int or str or :obj:`~Orange.data.Variable` """ self.variable = variable + self._create_cached_target_domain() + def _create_cached_target_domain(self): + """ If the same domain is used everytime this allows better caching of + domain transformations in from_table""" if self.variable is not None: if self.variable.is_primitive(): - self.need_domain = Domain([self.variable]) + self._target_domain = Domain([self.variable]) else: - self.need_domain = Domain([], metas=[self.variable]) + self._target_domain = Domain([], metas=[self.variable]) + + def __getstate__(self): + # Do not pickle the cached domain; rather recreate it after unpickling + state = self.__dict__.copy() + state.pop("_target_domain") + return state + + def __setstate__(self, state): + # Ensure that cached target domain is created after unpickling. + # This solves the problem of unpickling old pickled models. + self.__dict__.update(state) + self._create_cached_target_domain() def __call__(self, data): """ @@ -31,12 +54,12 @@ def __call__(self, data): inst = isinstance(data, Instance) if inst: data = Table.from_list(data.domain, [data]) - data = data.transform(self.need_domain) + data = data.transform(self._target_domain) if self.variable.is_primitive(): col = data.X else: col = data.metas - if not sp.issparse(col): + if not sp.issparse(col) and col.ndim > 1: col = col.squeeze(axis=1) transformed = self.transform(col) if inst: @@ -61,10 +84,19 @@ def __hash__(self): class Identity(Transformation): """Return an untransformed value of `c`. """ + InheritEq = True + def transform(self, c): return c + def __eq__(self, other): # pylint: disable=useless-parent-delegation + return super().__eq__(other) + + def __hash__(self): + return super().__hash__() + +# pylint: disable=abstract-method class _Indicator(Transformation): def __init__(self, variable, value): """ @@ -83,14 +115,47 @@ def __eq__(self, other): def __hash__(self): return hash((type(self), self.variable, self.value)) + @staticmethod + def _nan_fixed(c, transformed): + if np.isscalar(c): + if c != c: # pylint: disable=comparison-with-itself + transformed = np.nan + else: + transformed = float(transformed) + else: + transformed = transformed.astype(float) + transformed[np.isnan(c)] = np.nan + return transformed + class Indicator(_Indicator): """ Return an indicator value that equals 1 if the variable has the specified value and 0 otherwise. """ + + InheritEq = True + def transform(self, c): - return c == self.value + if sp.issparse(c): + if self.value != 0: + # If value is nonzero, the matrix will become sparser: + # we transform the data and remove zeros + transformed = c.copy() + transformed.data = self.transform(c.data) + transformed.eliminate_zeros() + return transformed + else: + # Otherwise, it becomes dense anyway (or it wasn't really sparse + # before), so we just convert it to sparse before transforming + c = c.toarray().ravel() + return self._nan_fixed(c, c == self.value) + + def __eq__(self, other): # pylint: disable=useless-parent-delegation + return super().__eq__(other) + + def __hash__(self): + return super().__hash__() class Indicator1(_Indicator): @@ -98,8 +163,14 @@ class Indicator1(_Indicator): Return an indicator value that equals 1 if the variable has the specified value and -1 otherwise. """ - def transform(self, c): - return (c == self.value) * 2 - 1 + + InheritEq = True + + def transform(self, column): + # The result of this is always dense + if sp.issparse(column): + column = column.toarray().ravel() + return self._nan_fixed(column, (column == self.value) * 2 - 1) class Normalizer(Transformation): @@ -147,13 +218,28 @@ def __init__(self, variable, lookup_table, unknown=np.nan): :type variable: int or str or :obj:`~Orange.data.DiscreteVariable` :param lookup_table: transformations for each value of `self.variable` :type lookup_table: np.array - :param unknown: The value to be used as unknown value. - :type unknown: float or int + :param unknown : The value to be used as unknown value. + :type unknown: float or int or str """ super().__init__(variable) self.lookup_table = lookup_table self.unknown = unknown + @staticmethod + def _safe_lookup_table_equal(a, b): + a = np.asarray(a) + b = np.asarray(b) + + if a.shape != b.shape: + return False + + a_is_num = np.issubdtype(a.dtype, np.number) + b_is_num = np.issubdtype(b.dtype, np.number) + if a_is_num and b_is_num: + return np.allclose(a, b, equal_nan=True) + + return np.array_equal(a, b) + def transform(self, column): # Densify DiscreteVariable values coming from sparse datasets. if sp.issparse(column): @@ -164,12 +250,90 @@ def transform(self, column): values = self.lookup_table[column] return np.where(mask, self.unknown, values) + def __eq__(self, other): + return ( + super().__eq__(other) + and self._safe_lookup_table_equal(self.lookup_table, other.lookup_table) + and self._safe_lookup_table_equal(self.unknown, other.unknown) + ) + def __hash__(self): + return hash( + ( + type(self), + self.variable, + # nan value does not have constant hash in Python3.10 + # to avoid different hashes for the same array change to None + # issue: https://bugs.python.org/issue43475#msg388508 + tuple(None if isna(x) else x for x in self.lookup_table), + nan_hash_stand(self.unknown), + ) + ) + + +class MappingTransform(Transformation): + """ + Map values via a dictionary lookup. + + Parameters + ---------- + variable: Variable + mapping: Mapping + The mapping (for the non NA values). + dtype: Optional[DTypeLike] + The optional target dtype. + unknown: Any + The constant with whitch to replace unknown values in input. + """ + def __init__( + self, + variable: Variable, + mapping: Mapping, + dtype: Optional['DTypeLike'] = None, + unknown=np.nan, + ) -> None: + super().__init__(variable) + if any(nan_eq(k, np.nan) for k in mapping.keys()): # ill-defined mapping + raise ValueError("'nan' value in mapping.keys()") + self.mapping = mapping + self.dtype = dtype + self.unknown = unknown + self._mapper = self._make_dict_mapper( + DictMissingConst(unknown, mapping), dtype + ) + + @staticmethod + def _make_dict_mapper(mapping, dtype): + return frompyfunc(mapping.__getitem__, 1, 1, dtype) + + def transform(self, c): + return self._mapper(c) + + def __reduce_ex__(self, protocol): + return type(self), (self.variable, self.mapping, self.dtype, self.unknown) + def __eq__(self, other): return super().__eq__(other) \ - and np.allclose(self.lookup_table, other.lookup_table, - equal_nan=True) \ - and np.allclose(self.unknown, other.unknown, equal_nan=True) + and nan_mapping_eq(self.mapping, other.mapping) \ + and self.dtype == other.dtype \ + and nan_eq(self.unknown, other.unknown) def __hash__(self): - return hash((type(self), self.variable, - tuple(self.lookup_table), self.unknown)) + return hash((type(self), self.variable, nan_mapping_hash(self.mapping), + self.dtype, nan_hash_stand(self.unknown))) + + +def nan_mapping_hash(a: Mapping) -> int: + return hash(tuple((k, nan_hash_stand(v)) for k, v in a.items())) + + +def nan_mapping_eq(a: Mapping, b: Mapping) -> bool: + if len(a) != len(b): + return False + try: + for k, va in a.items(): + vb = b[k] + if not nan_eq(va, vb): + return False + except LookupError: + return False + return True diff --git a/Orange/projection/_som.pyx b/Orange/projection/_som.pyx index 84f6dc313aa..6410e20467f 100644 --- a/Orange/projection/_som.pyx +++ b/Orange/projection/_som.pyx @@ -22,6 +22,8 @@ def get_winners(np.float64_t[:, :, :] weights, np.float64_t[:, :] X, int hex): np.float64_t[:] row np.ndarray[np.int16_t, ndim=2] winners = \ np.empty((X.shape[0], 2), dtype=np.int16) + np.ndarray[np.float64_t, ndim=1] distances = \ + np.empty((X.shape[0]), dtype=np.float64) int nrows = X.shape[0] with nogil: @@ -40,8 +42,9 @@ def get_winners(np.float64_t[:, :, :] weights, np.float64_t[:, :] X, int hex): min_diff = diff winners[rowi, 0] = win_x winners[rowi, 1] = win_y + distances[rowi] = min_diff - return winners + return winners, distances def update(np.float64_t[:, :, :] weights, @@ -127,6 +130,8 @@ def get_winners_sparse(np.float64_t[:, :, :] weights, np.float64_t[:] row, np.ndarray[np.int16_t, ndim=2] winners = \ np.empty((X.shape[0], 2), dtype=np.int16) + np.ndarray[np.float64_t, ndim=1] distances = \ + np.empty((X.shape[0]), dtype=np.float64) int nrows = X.shape[0] with nogil: @@ -149,7 +154,8 @@ def get_winners_sparse(np.float64_t[:, :, :] weights, winners[rowi, 0] = win_x winners[rowi, 1] = win_y - return winners + distances[rowi] = min_diff + return winners, distances def update_sparse(np.ndarray[np.float64_t, ndim=3] weights, diff --git a/Orange/projection/base.py b/Orange/projection/base.py index b07f0cb39e1..f57bb8e7f13 100644 --- a/Orange/projection/base.py +++ b/Orange/projection/base.py @@ -1,3 +1,5 @@ +import warnings + import copy import inspect import threading @@ -9,6 +11,7 @@ from Orange.data.util import SharedComputeValue, get_unique_names from Orange.misc.wrapper_meta import WrapperMeta from Orange.preprocess import RemoveNaNRows +from Orange.util import dummy_callback, wrap_callback, OrangeDeprecationWarning import Orange.preprocess __all__ = ["LinearCombinationSql", "Projector", "Projection", "SklProjector", @@ -44,17 +47,36 @@ def fit(self, X, Y=None): raise NotImplementedError( "Classes derived from Projector must overload method fit") - def __call__(self, data): - data = self.preprocess(data) + def __call__(self, data, progress_callback=None): + if progress_callback is None: + progress_callback = dummy_callback + progress_callback(0, "Preprocessing...") + try: + cb = wrap_callback(progress_callback, end=0.1) + data = self.preprocess(data, progress_callback=cb) + except TypeError: + data = self.preprocess(data) + warnings.warn("A keyword argument 'progress_callback' has been " + "added to the preprocess() signature. Implementing " + "the method without the argument is deprecated and " + "will result in an error in the future.", + OrangeDeprecationWarning, stacklevel=2) self.domain = data.domain + progress_callback(0.1, "Fitting...") clf = self.fit(data.X, data.Y) clf.pre_domain = data.domain clf.name = self.name + progress_callback(1) return clf - def preprocess(self, data): - for pp in self.preprocessors: + def preprocess(self, data, progress_callback=None): + if progress_callback is None: + progress_callback = dummy_callback + n_pps = len(self.preprocessors) + for i, pp in enumerate(self.preprocessors): + progress_callback(i / n_pps) data = pp(data) + progress_callback(1) return data # Projectors implemented using `fit` access the `domain` through the @@ -85,7 +107,8 @@ def __setstate__(self, state): class Projection: def __init__(self, proj): - self.__dict__.update(proj.__dict__) + if proj is not None: + self.__dict__.update(proj.__dict__) self.proj = proj def transform(self, X): @@ -97,20 +120,53 @@ def __call__(self, data): def __repr__(self): return self.name + def __eq__(self, other): + if self is other: + return True + return type(self) is type(other) \ + and self.proj == other.proj + + def __hash__(self): + return hash(self.proj) + class TransformDomain: def __init__(self, projection): self.projection = projection + self._hash = None def __call__(self, data): if data.domain != self.projection.pre_domain: data = data.transform(self.projection.pre_domain) return self.projection.transform(data.X) + def __eq__(self, other): + if self is other: + return True + return type(self) is type(other) \ + and self.projection == other.projection + + def __setstate__(self, state): + self.__dict__.update(state) + self._hash = None + + def __getstate__(self): + state = self.__dict__.copy() + del state["_hash"] + return state + + def __hash__(self): + if self._hash is None: + self._hash = hash(self.projection) + return self._hash + class ComputeValueProjector(SharedComputeValue): - def __init__(self, projection, feature, transform): + def __init__(self, projection=None, feature=None, transform=None): super().__init__(transform) + if projection is not None: + warnings.warn("Argument projection is unused and will be removed.", + OrangeDeprecationWarning, stacklevel=2) self.projection = projection self.feature = feature self.transformed = None @@ -118,6 +174,17 @@ def __init__(self, projection, feature, transform): def compute(self, data, space): return space[:, self.feature] + def __eq__(self, other): + if self is other: + return True + return super().__eq__(other) \ + and self.projection == other.projection \ + and self.feature == other.feature \ + and self.transformed == other.transformed + + def __hash__(self): + return hash((super().__hash__(), self.projection, self.feature, self.transformed)) + class DomainProjection(Projection): var_prefix = "C" @@ -127,7 +194,7 @@ def __init__(self, proj, domain, n_components): def proj_variable(i, name): v = Orange.data.ContinuousVariable( - name, compute_value=ComputeValueProjector(self, i, transformer) + name, compute_value=ComputeValueProjector(feature=i, transform=transformer) ) v.to_sql = LinearCombinationSql( domain.attributes, self.components_[i, :], @@ -154,6 +221,21 @@ def copy(self): model.name = self.name return model + def __eq__(self, other): + # see comment in __hash__() about .domain + if self is other: + return True + return super().__eq__(other) \ + and self.n_components == other.n_components \ + and self.orig_domain == other.orig_domain \ + and self.var_prefix == other.var_prefix + + def __hash__(self): + # hashing self.domain would cause infinite recursion; + # because it is only constructed from .orig_domain, .n_components + # and .proj (dealt with in the superclass), we do not need it + return hash((super().__hash__(), self.n_components, self.orig_domain, self.var_prefix)) + class LinearProjector(Projector): name = "Linear Projection" @@ -208,8 +290,8 @@ def _get_sklparams(self, values): raise TypeError("Wrapper does not define '__wraps__'") return params - def preprocess(self, data): - data = super().preprocess(data) + def preprocess(self, data, progress_callback=None): + data = super().preprocess(data, progress_callback) if any(v.is_discrete and len(v.values) > 2 for v in data.domain.attributes): raise ValueError("Wrapped scikit-learn methods do not support " diff --git a/Orange/projection/cur.py b/Orange/projection/cur.py index 047dce3c4b1..c46b59d799a 100644 --- a/Orange/projection/cur.py +++ b/Orange/projection/cur.py @@ -93,7 +93,7 @@ def fit(self, X, Y=None): if self.compute_U: pinvC = np.linalg.pinv(self.C_) pinvR = np.linalg.pinv(self.R_) - self.U_ = np.dot(np.dot(pinvC, X), pinvR) + self.U_ = np.linalg.multi_dot([pinvC, X, pinvR]) else: self.U_ = None diff --git a/Orange/projection/freeviz.py b/Orange/projection/freeviz.py index 14301dab8c3..83437f97485 100644 --- a/Orange/projection/freeviz.py +++ b/Orange/projection/freeviz.py @@ -21,7 +21,7 @@ class FreeViz(LinearProjector): projection = FreeVizModel def __init__(self, weights=None, center=True, scale=True, dim=2, p=1, - initial=None, maxiter=500, alpha=0.1, + initial=None, maxiter=500, alpha=0.1, gravity=None, atol=1e-5, preprocessors=None): super().__init__(preprocessors=preprocessors) self.weights = weights @@ -33,6 +33,7 @@ def __init__(self, weights=None, center=True, scale=True, dim=2, p=1, self.maxiter = maxiter self.alpha = alpha self.atol = atol + self.gravity = gravity self.is_class_discrete = False self.components_ = None @@ -50,6 +51,7 @@ def get_components(self, X, Y): X, Y, weights=self.weights, center=self.center, scale=self.scale, dim=self.dim, p=self.p, initial=self.initial, maxiter=self.maxiter, alpha=self.alpha, atol=self.atol, + gravity=self.gravity, is_class_discrete=self.is_class_discrete)[1].T @classmethod @@ -104,7 +106,7 @@ def forces_regression(cls, distances, y, p=1): return F @classmethod - def forces_classification(cls, distances, y, p=1): + def forces_classification(cls, distances, y, p=1, gravity=None): diffclass = scipy.spatial.distance.pdist(y.reshape(-1, 1), "hamming") != 0 # handle attractive force if p == 1: @@ -115,11 +117,13 @@ def forces_classification(cls, distances, y, p=1): # handle repulsive force mask = (diffclass & (distances > np.finfo(distances.dtype).eps * 100)) - assert mask.shape == F.shape and mask.dtype == np.bool + assert mask.shape == F.shape and mask.dtype == bool if p == 1: F[mask] = 1 / distances[mask] else: F[mask] = 1 / (distances[mask] ** p) + if gravity is not None: + F[mask] *= -np.sum(F[~mask]) / np.sum(F[mask]) / gravity return F @classmethod @@ -180,7 +184,8 @@ def gradient(cls, X, embeddings, forces, embedding_dist=None, weights=None): return G @classmethod - def freeviz_gradient(cls, X, y, embedding, p=1, weights=None, is_class_discrete=False): + def freeviz_gradient(cls, X, y, embedding, p=1, weights=None, + gravity=None, is_class_discrete=False): """ Return the gradient for the FreeViz [1]_ projection. @@ -214,7 +219,7 @@ def freeviz_gradient(cls, X, y, embedding, p=1, weights=None, is_class_discrete= assert X.ndim == 2 and X.shape[0] == y.shape[0] == embedding.shape[0] D = scipy.spatial.distance.pdist(embedding) if is_class_discrete: - forces = cls.forces_classification(D, y, p=p) + forces = cls.forces_classification(D, y, p=p, gravity=gravity) else: forces = cls.forces_regression(D, y, p=p) G = cls.gradient(X, embedding, forces, embedding_dist=D, weights=weights) @@ -234,7 +239,8 @@ def _rotate(cls, A): @classmethod def freeviz(cls, X, y, weights=None, center=True, scale=True, dim=2, p=1, - initial=None, maxiter=500, alpha=0.1, atol=1e-5, is_class_discrete=False): + initial=None, maxiter=500, alpha=0.1, atol=1e-5, gravity=None, + is_class_discrete=False): """ FreeViz @@ -341,6 +347,7 @@ def freeviz(cls, X, y, weights=None, center=True, scale=True, dim=2, p=1, step_i = 0 while step_i < maxiter: G = cls.freeviz_gradient(X, y, embeddings, p=p, weights=weights, + gravity=gravity, is_class_discrete=is_class_discrete) # Scale the changes (the largest anchor move is alpha * radius) diff --git a/Orange/projection/manifold.py b/Orange/projection/manifold.py index d8d64490a57..8ed8839dc88 100644 --- a/Orange/projection/manifold.py +++ b/Orange/projection/manifold.py @@ -1,3 +1,5 @@ +from typing import Union + import logging import warnings from collections.abc import Iterable @@ -13,6 +15,7 @@ from Orange.data import Table, Domain, ContinuousVariable from Orange.data.util import get_unique_names from Orange.distance import Distance, DistanceModel, Euclidean +from Orange.misc import DistMatrix from Orange.projection import SklProjector, Projector, Projection from Orange.projection.base import TransformDomain, ComputeValueProjector @@ -28,7 +31,7 @@ def __getattr__(self, attr): # Disable t-SNE user warnings openTSNE.tsne.log.setLevel(logging.ERROR) openTSNE.affinity.log.setLevel(logging.ERROR) - return openTSNE.__dict__[attr] + return getattr(openTSNE, attr) openTSNE = _LazyTSNE() @@ -85,7 +88,7 @@ def torgerson(distances, n_components=2, eigen_solver="auto"): U, L = v[:, ::-1], w[::-1] elif eigen_solver == "lapack": # lapack (d|s)syevr w, v = lapack_eigh(B, overwrite_a=True, - eigvals=(max(N - n_components, 0), N - 1)) + subset_by_index=(max(N - n_components, 0), N - 1)) assert np.all(np.diff(w) >= 0), "w was not in ascending order" U, L = v[:, ::-1], w[::-1] else: @@ -218,9 +221,11 @@ def __init__(self, embedding: openTSNE.TSNEEmbedding, table: Table, def proj_variable(i): return self.embedding.domain[i].copy( - compute_value=ComputeValueProjector(self, i, transformer)) + compute_value=ComputeValueProjector(feature=i, transform=transformer)) + + super().__init__(None) + self.name = "TSNE" - super().__init__(self) self.embedding_ = embedding self.embedding = table self.pre_domain = pre_domain @@ -382,12 +387,12 @@ class TSNE(Projector): Orange.preprocess.SklImpute(), ] - def __init__(self, n_components=2, perplexity=30, learning_rate=200, + def __init__(self, n_components=2, perplexity=30, learning_rate="auto", early_exaggeration_iter=250, early_exaggeration=12, - n_iter=750, exaggeration=None, theta=0.5, + n_iter=500, exaggeration=None, theta=0.5, min_num_intervals=10, ints_in_interval=1, initialization="pca", metric="euclidean", n_jobs=1, - neighbors="exact", negative_gradient_method="bh", + neighbors="auto", negative_gradient_method="auto", multiscale=False, callbacks=None, callbacks_every_iters=50, random_state=None, preprocessors=None): super().__init__(preprocessors=preprocessors) @@ -461,6 +466,10 @@ def compute_initialization(self, X): initialization = openTSNE.initialization.pca( X, self.n_components, random_state=self.random_state ) + elif self.initialization == "spectral": + initialization = openTSNE.initialization.spectral( + X, self.n_components, random_state=self.random_state, + ) elif self.initialization == "random": initialization = openTSNE.initialization.random( X, self.n_components, random_state=self.random_state @@ -498,7 +507,7 @@ def fit(self, X: np.ndarray, Y: np.ndarray = None) -> openTSNE.TSNEEmbedding: # Run standard t-SNE optimization embedding.optimize( n_iter=self.early_exaggeration_iter, exaggeration=self.early_exaggeration, - inplace=True, momentum=0.5, propagate_exception=True, + inplace=True, momentum=0.8, propagate_exception=True, ) embedding.optimize( n_iter=self.n_iter, exaggeration=self.exaggeration, @@ -507,17 +516,40 @@ def fit(self, X: np.ndarray, Y: np.ndarray = None) -> openTSNE.TSNEEmbedding: return embedding - def convert_embedding_to_model(self, data, embedding): + def convert_embedding_to_model(self, data: Union[Table, DistMatrix], embedding: np.ndarray): # The results should be accessible in an Orange table, which doesn't # need the full embedding attributes and is cast into a regular array n = self.n_components + + if self.metric == "precomputed": + if not isinstance(data, DistMatrix): + raise ValueError( + f"Expected `data` to be instance of " + f"{DistMatrix.__class__.__name__} when using " + f"`metric='precomputed'. Got {data.__class__.__name__} " + f"instead!" + ) + # The distance matrix need not come attached with the original data + if data.row_items is not None: + data = data.row_items + else: + data = Table.from_domain(Domain([])) + + # Determine variable names postfixes = ["x", "y"] if n == 2 else list(range(1, n + 1)) + tsne_colnames = [f"t-SNE-{p}" for p in postfixes] names = [var.name for var in chain(data.domain.class_vars, data.domain.metas) if var] - proposed = [(f"t-SNE-{p}") for p in postfixes] - uniq_names = get_unique_names(names, proposed) - tsne_cols = [ContinuousVariable(name) for name in uniq_names] - embedding_domain = Domain(tsne_cols, data.domain.class_vars, data.domain.metas) - embedding_table = Table(embedding_domain, embedding.view(np.ndarray), data.Y, data.metas) + tsne_colnames = get_unique_names(names, tsne_colnames) + tsne_cols = [ContinuousVariable(name) for name in tsne_colnames] + + # Distance matrices need not come attached with the original data + if len(data.domain) == 0: + embedding_domain = Domain(tsne_cols) + embedding_table = Table(embedding_domain, embedding.view(np.ndarray)) + + else: # data table was available + embedding_domain = Domain(tsne_cols, data.domain.class_vars, data.domain.metas) + embedding_table = Table(embedding_domain, embedding.view(np.ndarray), data.Y, data.metas) # Create a model object which will be capable of transforming new data # into the existing embedding diff --git a/Orange/projection/pca.py b/Orange/projection/pca.py index 5ba55f49fac..d2b13d7e38a 100644 --- a/Orange/projection/pca.py +++ b/Orange/projection/pca.py @@ -1,16 +1,8 @@ -import numbers -import six import numpy as np import scipy.sparse as sp -from scipy.linalg import lu, qr, svd - from sklearn import decomposition as skl_decomposition -from sklearn.utils import check_array, check_random_state -from sklearn.utils.extmath import svd_flip, safe_sparse_dot -from sklearn.utils.validation import check_is_fitted import Orange.data -from Orange.statistics import util as ut from Orange.data import Variable from Orange.data.util import get_unique_names from Orange.misc.wrapper_meta import WrapperMeta @@ -20,223 +12,6 @@ __all__ = ["PCA", "SparsePCA", "IncrementalPCA", "TruncatedSVD"] -def randomized_pca(A, n_components, n_oversamples=10, n_iter="auto", - flip_sign=True, random_state=0): - """Compute the randomized PCA decomposition of a given matrix. - - This method differs from the scikit-learn implementation in that it supports - and handles sparse matrices well. - - """ - if n_iter == "auto": - # Checks if the number of iterations is explicitly specified - # Adjust n_iter. 7 was found a good compromise for PCA. See sklearn #5299 - n_iter = 7 if n_components < .1 * min(A.shape) else 4 - - n_samples, n_features = A.shape - - c = np.atleast_2d(ut.nanmean(A, axis=0)) - - if n_samples >= n_features: - Q = random_state.normal(size=(n_features, n_components + n_oversamples)) - if A.dtype.kind == "f": - Q = Q.astype(A.dtype, copy=False) - - Q = safe_sparse_dot(A, Q) - safe_sparse_dot(c, Q) - - # Normalized power iterations - for _ in range(n_iter): - Q = safe_sparse_dot(A.T, Q) - safe_sparse_dot(c.T, Q.sum(axis=0)[None, :]) - Q, _ = lu(Q, permute_l=True) - Q = safe_sparse_dot(A, Q) - safe_sparse_dot(c, Q) - Q, _ = lu(Q, permute_l=True) - - Q, _ = qr(Q, mode="economic") - - QA = safe_sparse_dot(A.T, Q) - safe_sparse_dot(c.T, Q.sum(axis=0)[None, :]) - R, s, V = svd(QA.T, full_matrices=False) - U = Q.dot(R) - - else: # n_features > n_samples - Q = random_state.normal(size=(n_samples, n_components + n_oversamples)) - if A.dtype.kind == "f": - Q = Q.astype(A.dtype, copy=False) - - Q = safe_sparse_dot(A.T, Q) - safe_sparse_dot(c.T, Q.sum(axis=0)[None, :]) - - # Normalized power iterations - for _ in range(n_iter): - Q = safe_sparse_dot(A, Q) - safe_sparse_dot(c, Q) - Q, _ = lu(Q, permute_l=True) - Q = safe_sparse_dot(A.T, Q) - safe_sparse_dot(c.T, Q.sum(axis=0)[None, :]) - Q, _ = lu(Q, permute_l=True) - - Q, _ = qr(Q, mode="economic") - - QA = safe_sparse_dot(A, Q) - safe_sparse_dot(c, Q) - U, s, R = svd(QA, full_matrices=False) - V = R.dot(Q.T) - - if flip_sign: - U, V = svd_flip(U, V) - - return U[:, :n_components], s[:n_components], V[:n_components, :] - - -class ImprovedPCA(skl_decomposition.PCA): - """Patch sklearn PCA learner to include randomized PCA for sparse matrices. - - Scikit-learn does not currently support sparse matrices at all, even though - efficient methods exist for PCA. This class patches the default scikit-learn - implementation to properly handle sparse matrices. - - Notes - ----- - - This should be removed once scikit-learn releases a version which - implements this functionality. - - """ - # pylint: disable=too-many-branches - def _fit(self, X): - """Dispatch to the right submethod depending on the chosen solver.""" - X = check_array( - X, - accept_sparse=["csr", "csc"], - dtype=[np.float64, np.float32], - ensure_2d=True, - copy=self.copy, - ) - - # Handle n_components==None - if self.n_components is None: - if self.svd_solver != "arpack": - n_components = min(X.shape) - else: - n_components = min(X.shape) - 1 - else: - n_components = self.n_components - - # Handle svd_solver - self._fit_svd_solver = self.svd_solver - if self._fit_svd_solver == "auto": - # Sparse data can only be handled with the randomized solver - if sp.issparse(X): - self._fit_svd_solver = "randomized" - # Small problem or n_components == 'mle', just call full PCA - elif max(X.shape) <= 500 or n_components == "mle": - self._fit_svd_solver = "full" - elif 1 <= n_components < .8 * min(X.shape): - self._fit_svd_solver = "randomized" - # This is also the case of n_components in (0,1) - else: - self._fit_svd_solver = "full" - - # Ensure we don't try call arpack or full on a sparse matrix - if sp.issparse(X) and self._fit_svd_solver != "randomized": - raise ValueError("only the randomized solver supports sparse matrices") - - # Call different fits for either full or truncated SVD - if self._fit_svd_solver == "full": - return self._fit_full(X, n_components) - elif self._fit_svd_solver in ["arpack", "randomized"]: - return self._fit_truncated(X, n_components, self._fit_svd_solver) - else: - raise ValueError( - "Unrecognized svd_solver='{0}'".format(self._fit_svd_solver) - ) - - def _fit_truncated(self, X, n_components, svd_solver): - """Fit the model by computing truncated SVD (by ARPACK or randomized) on X""" - n_samples, n_features = X.shape - - if isinstance(n_components, six.string_types): - raise ValueError( - "n_components=%r cannot be a string with svd_solver='%s'" % - (n_components, svd_solver) - ) - if not 1 <= n_components <= min(n_samples, n_features): - raise ValueError( - "n_components=%r must be between 1 and min(n_samples, " - "n_features)=%r with svd_solver='%s'" % ( - n_components, min(n_samples, n_features), svd_solver - ) - ) - if not isinstance(n_components, (numbers.Integral, np.integer)): - raise ValueError( - "n_components=%r must be of type int when greater than or " - "equal to 1, was of type=%r" % (n_components, type(n_components)) - ) - if svd_solver == "arpack" and n_components == min(n_samples, n_features): - raise ValueError( - "n_components=%r must be strictly less than min(n_samples, " - "n_features)=%r with svd_solver='%s'" % ( - n_components, min(n_samples, n_features), svd_solver - ) - ) - - random_state = check_random_state(self.random_state) - - self.mean_ = X.mean(axis=0) - total_var = ut.var(X, axis=0, ddof=1) - - if svd_solver == "arpack": - # Center data - X -= self.mean_ - # random init solution, as ARPACK does it internally - v0 = random_state.uniform(-1, 1, size=min(X.shape)) - U, S, V = sp.linalg.svds(X, k=n_components, tol=self.tol, v0=v0) - # svds doesn't abide by scipy.linalg.svd/randomized_svd - # conventions, so reverse its outputs. - S = S[::-1] - # flip eigenvectors' sign to enforce deterministic output - U, V = svd_flip(U[:, ::-1], V[::-1]) - - elif svd_solver == "randomized": - # sign flipping is done inside - U, S, V = randomized_pca( - X, - n_components=n_components, - n_iter=self.iterated_power, - flip_sign=True, - random_state=random_state, - ) - - self.n_samples_, self.n_features_ = n_samples, n_features - self.components_ = V - self.n_components_ = n_components - - # Get variance explained by singular values - self.explained_variance_ = (S ** 2) / (n_samples - 1) - self.explained_variance_ratio_ = self.explained_variance_ / total_var.sum() - self.singular_values_ = S.copy() # Store the singular values. - - if self.n_components_ < min(n_features, n_samples): - self.noise_variance_ = (total_var.sum() - self.explained_variance_.sum()) - self.noise_variance_ /= min(n_features, n_samples) - n_components - else: - self.noise_variance_ = 0 - - return U, S, V - - def transform(self, X): - check_is_fitted(self, ["mean_", "components_"], all_or_any=all) - - X = check_array( - X, - accept_sparse=["csr", "csc"], - dtype=[np.float64, np.float32], - ensure_2d=True, - copy=self.copy, - ) - - if self.mean_ is not None: - X = X - self.mean_ - X_transformed = np.dot(X, self.components_.T) - if self.whiten: - X_transformed /= np.sqrt(self.explained_variance_) - return X_transformed - - class _FeatureScorerMixin(LearnerScorer): feature_type = Variable component = 0 @@ -250,7 +25,7 @@ def score(self, data): class PCA(SklProjector, _FeatureScorerMixin): - __wraps__ = ImprovedPCA + __wraps__ = skl_decomposition.PCA name = 'PCA' supports_sparse = True @@ -264,6 +39,15 @@ def fit(self, X, Y=None): params = self.params.copy() if params["n_components"] is not None: params["n_components"] = min(min(X.shape), params["n_components"]) + + # scikit-learn doesn't support requesting the same number of PCs as + # there are columns when the data is sparse. In this case, densify the + # data. Since we're essentially requesting back a PC matrix of the same + # size as the original data, we will assume the matrix is small enough + # to densify as well + if sp.issparse(X) and params["n_components"] == min(X.shape): + X = X.toarray() + proj = self.__wraps__(**params) proj = proj.fit(X, Y) return PCAModel(proj, self.domain, len(proj.components_)) @@ -339,7 +123,7 @@ def fit(self, X, Y=None): params = self.params.copy() # strict requirement in scikit fit_transform: # n_components must be < n_features - params["n_components"] = min(min(X.shape)-1, params["n_components"]) + params["n_components"] = min(min(X.shape) - 1, params["n_components"]) proj = self.__wraps__(**params) proj = proj.fit(X, Y) diff --git a/Orange/projection/som.py b/Orange/projection/som.py index c77865be689..a49113c6c91 100644 --- a/Orange/projection/som.py +++ b/Orange/projection/som.py @@ -1,3 +1,5 @@ +from typing import Union, Optional + import numpy as np import scipy.sparse as sp @@ -14,6 +16,39 @@ def __init__(self, dim_x, dim_y, self.pca_init = pca_init self.random_seed = random_seed + @staticmethod + def prepare_data(x: Union[np.ndarray, sp.spmatrix], + offsets: Optional[np.ndarray] = None, + scales: Optional[np.ndarray] = None) \ + -> (Union[np.ndarray, sp.spmatrix], + np.ndarray, + Union[np.ndarray, None], + Union[np.ndarray, None]): + if sp.issparse(x) and offsets is not None: + # This is used in compute_value, by any widget, hence there is no + # way to prevent it or report an error. We go dense... + x = x.todense() + if sp.issparse(x): + cont_x = x.tocsr() + mask = np.ones(cont_x.shape[0], bool) + else: + mask = np.all(np.isfinite(x), axis=1) + useful = np.sum(mask) + if useful == 0: + return x, mask, offsets, scales + if useful == len(mask): + cont_x = x.copy() + else: + cont_x = x[mask] + if offsets is None: + offsets = np.min(cont_x, axis=0) + cont_x -= offsets[None, :] + if scales is None: + scales = np.max(cont_x, axis=0) + scales[scales == 0] = 1 + cont_x /= scales[None, :] + return cont_x, mask, offsets, scales + def init_weights_random(self, x): random = (np.random if self.random_seed is None else np.random.RandomState(self.random_seed)) diff --git a/Orange/regression/__init__.py b/Orange/regression/__init__.py index 4c86d70d498..a0315ef65cf 100644 --- a/Orange/regression/__init__.py +++ b/Orange/regression/__init__.py @@ -1,5 +1,5 @@ # Pull members from modules to Orange.regression namespace -# pylint: disable=wildcard-import +# pylint: disable=wildcard-import,broad-except from .base_regression import (ModelRegression as Model, LearnerRegression as Learner, @@ -13,13 +13,15 @@ from .random_forest import * from .tree import * from .neural_network import * +from .pls import * from ..classification.simple_tree import * try: from .catgb import * -except ModuleNotFoundError: +except Exception: pass from .gb import * try: from .xgb import * except Exception: pass +from .curvefit import * diff --git a/Orange/regression/base_regression.py b/Orange/regression/base_regression.py index 3f0f7620f4e..cb0937057eb 100644 --- a/Orange/regression/base_regression.py +++ b/Orange/regression/base_regression.py @@ -5,10 +5,14 @@ class LearnerRegression(Learner): - learner_adequacy_err_msg = "Numeric class variable expected." - def check_learner_adequacy(self, domain): - return domain.has_continuous_class + def incompatibility_reason(self, domain): + reason = None + if len(domain.class_vars) > 1 and not self.supports_multiclass: + reason = "Too many target variables." + elif not domain.has_continuous_class: + reason = "Numeric target variable expected." + return reason class ModelRegression(Model): diff --git a/Orange/regression/curvefit.py b/Orange/regression/curvefit.py new file mode 100644 index 00000000000..db64a273220 --- /dev/null +++ b/Orange/regression/curvefit.py @@ -0,0 +1,416 @@ +import ast +from typing import Callable, List, Optional, Union, Dict, Tuple, Any + +import numpy as np +from scipy.optimize import curve_fit + +from Orange.data import Table, Domain, ContinuousVariable, StringVariable +from Orange.data.filter import HasClass +from Orange.data.util import get_unique_names +from Orange.preprocess import RemoveNaNColumns, Impute +from Orange.regression import Learner, Model + +__all__ = ["CurveFitLearner"] + + +class CurveFitModel(Model): + def __init__( + self, + domain: Domain, + original_domain: Domain, + parameters_names: List[str], + parameters: np.ndarray, + function: Optional[Callable], + create_lambda_args: Optional[Tuple] + ): + super().__init__(domain, original_domain) + self.__parameters_names = parameters_names + self.__parameters = parameters + + if function is None and create_lambda_args is not None: + function, names, _ = _create_lambda(**create_lambda_args) + assert parameters_names == names + + assert function + + self.__function = function + self.__create_lambda_args = create_lambda_args + + @property + def coefficients(self) -> Table: + return Table(Domain([ContinuousVariable("coef")], + metas=[StringVariable("name")]), + self.__parameters[:, None], + metas=np.array(self.__parameters_names)[:, None]) + + def predict(self, X: np.ndarray) -> np.ndarray: + predicted = self.__function(X, *self.__parameters) + if not isinstance(predicted, np.ndarray): + # handle constant function; i.e. len(self.domain.attributes) == 0 + return np.full(len(X), predicted, dtype=float) + return predicted.flatten() + + def __getstate__(self) -> Dict: + if not self.__create_lambda_args: + raise AttributeError( + "Can't pickle/copy callable. Use str expression instead." + ) + return { + "domain": self.domain, + "original_domain": self.original_domain, + "parameters_names": self.__parameters_names, + "parameters": self.__parameters, + "function": None, + "args": self.__create_lambda_args, + } + + def __setstate__(self, state: Dict): + self.__init__(*state.values()) + + +class CurveFitLearner(Learner): + """ + Fit a function to data. + It uses the scipy.curve_fit to find the optimal values of parameters. + + Parameters + ---------- + expression : callable or str + A modeling function. + If callable, it must take the independent variable as the first + argument and the parameters to fit as separate remaining arguments. + If string, a lambda function is created, + using `expression`, `available_feature_names`, `function` and `env` + attributes. + Should be string for pickling the model. + parameters_names : list of str + List of parameters names. Only needed when the expression + is callable. + features_names : list of str + List of features names. Only needed when the expression + is callable. + available_feature_names : list of str + List of all available features names. Only needed when the expression + is string. Needed to distinguish between parameters and features when + translating the expression into the lambda. + functions : list of str + List of all available functions. Only needed when the expression + is string. Needed to distinguish between parameters and functions when + translating the expression into the lambda. + sanitizer : callable + Function for sanitizing names. + env : dict + An environment to capture in the lambda's closure. + p0 : list of floats, optional + Initial guess for the parameters. + bounds : 2-tuple of array_like, optional + Lower and upper bounds on parameters. + preprocessors : tuple of Orange preprocessors, optional + The processors that will be used when data is passed to the learner. + + Examples + -------- + >>> import numpy as np + >>> from Orange.data import Table + >>> from Orange.regression import CurveFitLearner + >>> data = Table("housing") + >>> # example with callable expression + >>> cfun = lambda x, a, b, c: a * np.exp(-b * x[:, 0] * x[:, 1]) + c + >>> learner = CurveFitLearner(cfun, ["a", "b", "c"], ["CRIM", "LSTAT"]) + >>> model = learner(data) + >>> pred = model(data) + >>> coef = model.coefficients + >>> # example with str expression + >>> sfun = "a * exp(-b * CRIM * LSTAT) + c" + >>> names = [a.name for a in data.domain.attributes] + >>> learner = CurveFitLearner(sfun, available_feature_names=names, + ... functions=["exp"]) + >>> model = learner(data) + >>> pred = model(data) + >>> coef = model.coefficients + + """ + preprocessors = [HasClass(), RemoveNaNColumns(), Impute()] + __returns__ = CurveFitModel + name = "Curve Fit" + + def __init__( + self, + expression: Union[Callable, ast.Expression, str], + parameters_names: Optional[List[str]] = None, + features_names: Optional[List[str]] = None, + available_feature_names: Optional[List[str]] = None, + functions: Optional[List[str]] = None, + sanitizer: Optional[Callable] = None, + env: Optional[Dict[str, Any]] = None, + p0: Union[List, Dict, None] = None, + bounds: Union[Tuple, Dict] = (-np.inf, np.inf), + preprocessors=None + ): + super().__init__(preprocessors) + + if callable(expression): + if parameters_names is None: + raise TypeError("Provide 'parameters_names' parameter.") + if features_names is None: + raise TypeError("Provide 'features_names' parameter.") + + args = None + function = expression + else: + if available_feature_names is None: + raise TypeError("Provide 'available_feature_names' parameter.") + if functions is None: + raise TypeError("Provide 'functions' parameter.") + + args = dict(expression=expression, + available_feature_names=available_feature_names, + functions=functions, sanitizer=sanitizer, env=env) + function, parameters_names, features_names = _create_lambda(**args) + + if isinstance(p0, dict): + p0 = [p0.get(p, 1) for p in parameters_names] + if isinstance(bounds, dict): + d = [-np.inf, np.inf] + lower_bounds = [bounds.get(p, d)[0] for p in parameters_names] + upper_bounds = [bounds.get(p, d)[1] for p in parameters_names] + bounds = lower_bounds, upper_bounds + + self.__function = function + self.__parameters_names = parameters_names + self.__features_names = features_names + self.__p0 = p0 + self.__bounds = bounds + + # needed for pickling - if the expression is a lambda function, the + # learner is not picklable + self.__create_lambda_args = args + + @property + def parameters_names(self) -> List[str]: + return self.__parameters_names + + def fit_storage(self, data: Table) -> CurveFitModel: + domain: Domain = data.domain + attributes = [] + for attr in domain.attributes: + if attr.name in self.__features_names: + if not attr.is_continuous: + raise ValueError("Numeric feature expected.") + attributes.append(attr) + + new_domain = Domain(attributes, domain.class_vars, domain.metas) + transformed = data.transform(new_domain) + params = curve_fit(self.__function, transformed.X, transformed.Y, + p0=self.__p0, bounds=self.__bounds)[0] + return CurveFitModel(new_domain, domain, + self.__parameters_names, params, self.__function, + self.__create_lambda_args) + + def __getstate__(self) -> Dict: + if not self.__create_lambda_args: + raise AttributeError( + "Can't pickle/copy callable. Use str expression instead." + ) + state = self.__create_lambda_args.copy() + state["parameters_names"] = None + state["features_names"] = None + state["p0"] = self.__p0 + state["bounds"] = self.__bounds + state["preprocessors"] = self.preprocessors + return state + + def __setstate__(self, state: Dict): + expression = state.pop("expression") + self.__init__(expression, **state) + + +def _create_lambda( + expression: Union[str, ast.Expression] = "", + available_feature_names: List[str] = None, + functions: List[str] = None, + sanitizer: Callable = None, + env: Optional[Dict[str, Any]] = None +) -> Tuple[Callable, List[str], List[str]]: + """ + Create a lambda function from a string expression. + + Parameters + ---------- + expression : str or ast.Expression + Right side of a modeling function. + available_feature_names : list of str + List of all available features names. + Needed to distinguish between parameters, features and functions. + functions : list of str + List of all available functions. + Needed to distinguish between parameters, features and functions. + sanitizer : callable, optional + Function for sanitizing variable names. + env : dict, optional + An environment to capture in the lambda's closure. + + Returns + ------- + func : callable + The created lambda function. + params : list of str + The recognied parameters withint the expression. + vars_ : list of str + The recognied variables withint the expression. + + Examples + -------- + >>> from Orange.data import Table + >>> data = Table("housing") + >>> sfun = "a * exp(-b * CRIM * LSTAT) + c" + >>> names = [a.name for a in data.domain.attributes] + >>> func, par, var = _create_lambda(sfun, available_feature_names=names, + ... functions=["exp"], env={"exp": np.exp}) + >>> y = func(data.X, 1, 2, 3) + >>> par + ['a', 'b', 'c'] + >>> var + ['CRIM', 'LSTAT'] + + """ + if sanitizer is None: + sanitizer = lambda n: n + if env is None: + env = {name: getattr(np, name) for name in functions} + + exp = ast.parse(expression, mode="eval") + search = _ParametersSearch( + [sanitizer(name) for name in available_feature_names], + functions + ) + search.visit(exp) + params = search.parameters + used_sanitized_feature_names = search.variables + + name = get_unique_names(params, "x") + feature_mapper = {n: i for i, n in enumerate(used_sanitized_feature_names)} + exp = _ReplaceVars(name, feature_mapper, functions).visit(exp) + + lambda_ = ast.Lambda( + args=ast.arguments( + posonlyargs=[], + args=[ast.arg(arg=arg) for arg in [name] + params], + varargs=None, + kwonlyargs=[], + kw_defaults=[], + defaults=[], + ), + body=exp.body + ) + exp = ast.Expression(body=lambda_) + ast.fix_missing_locations(exp) + vars_ = [name for name in available_feature_names + if sanitizer(name) in used_sanitized_feature_names] + + # pylint: disable=eval-used + return eval(compile(exp, "", mode="eval"), env), params, vars_ + + +class _ParametersSearch(ast.NodeVisitor): + """ + Find features and parameters: + - feature: if node is instance of ast.Name and is included in vars_names + - parameters: if node is instance of ast.Name and is not included + in functions + + Parameters + ---------- + vars_names : list of str + List of all available features names. + Needed to distinguish between parameters, features and functions. + functions : list of str + List of all available functions. + Needed to distinguish between parameters, features and functions. + + Attributes + ---------- + parameters : list of str + List of used parameters. + variables : list of str + List of used features. + + """ + + def __init__(self, vars_names: List[str], functions: List[str]): + super().__init__() + self.__vars_names = vars_names + self.__functions = functions + self.__parameters: List[str] = [] + self.__variables: List[str] = [] + + @property + def parameters(self) -> List[str]: + return self.__parameters + + @property + def variables(self) -> List[str]: + return self.__variables + + def visit_Name(self, node: ast.Name) -> ast.Name: + if node.id in self.__vars_names: + # don't use Set in order to preserve parameters order + if node.id not in self.__variables: + self.__variables.append(node.id) + elif node.id not in self.__functions: + # don't use Set in order to preserve parameters order + if node.id not in self.__parameters: + self.__parameters.append(node.id) + return node + + +class _ReplaceVars(ast.NodeTransformer): + """ + Replace feature names with X[:, i], where i is index of feature. + + Parameters + ---------- + name : str + List of all available features names. + Needed to distinguish between parameters, features and functions. + vars_mapper : dict + Dictionary of used features names and the belonging index from domain. + functions : list of str + List of all available functions. + + """ + + def __init__(self, name: str, vars_mapper: Dict, functions: List): + super().__init__() + self.__name = name + self.__vars_mapper = vars_mapper + self.__functions = functions + + def visit_Name(self, node: ast.Name) -> Union[ast.Name, ast.Subscript]: + if node.id not in self.__vars_mapper or node.id in self.__functions: + return node + else: + n = self.__vars_mapper[node.id] + return ast.Subscript( + value=ast.Name(id=self.__name, ctx=ast.Load()), + slice=ast.ExtSlice( + dims=[ast.Slice(lower=None, upper=None, step=None), + ast.Index(value=ast.Constant(n))]), + ctx=node.ctx + ) + + +if __name__ == "__main__": + import matplotlib.pyplot as plt + + housing = Table("housing") + xdata = housing.X + ydata = housing.Y + + func = lambda x, a, b, c: a * np.exp(-b * x[:, 0]) + c + pred = CurveFitLearner(func, ["a", "b", "c"], ["LSTAT"])(housing)(housing) + + plt.plot(xdata[:, 12], ydata, "o") + indices = np.argsort(xdata[:, 12]) + plt.plot(xdata[indices, 12], pred[indices]) + plt.show() diff --git a/Orange/regression/gb.py b/Orange/regression/gb.py index d2c0acb2445..95c8e7eeb2e 100644 --- a/Orange/regression/gb.py +++ b/Orange/regression/gb.py @@ -23,9 +23,10 @@ def score(self, data: Table) -> Tuple[np.ndarray, Tuple[Variable]]: class GBRegressor(SklLearner, _FeatureScorerMixin): __wraps__ = skl_ensemble.GradientBoostingRegressor __returns__ = SklModel + supports_weights = True def __init__(self, - loss="ls", + loss="squared_error", learning_rate=0.1, n_estimators=100, subsample=1.0, diff --git a/Orange/regression/knn.py b/Orange/regression/knn.py index 2b80f1697d8..03991d09d91 100644 --- a/Orange/regression/knn.py +++ b/Orange/regression/knn.py @@ -7,3 +7,4 @@ class KNNRegressionLearner(KNNBase, SklLearner): __wraps__ = skl_neighbors.KNeighborsRegressor + supports_weights = False diff --git a/Orange/regression/linear.py b/Orange/regression/linear.py index 5f9825a0cc9..f18b19fd4ba 100644 --- a/Orange/regression/linear.py +++ b/Orange/regression/linear.py @@ -27,6 +27,7 @@ def score(self, data): class LinearRegressionLearner(SklLearner, _FeatureScorerMixin): __wraps__ = skl_linear_model.LinearRegression + supports_weights = True # Arguments are needed for signatures, pylint: disable=unused-argument def __init__(self, preprocessors=None, fit_intercept=True): @@ -40,47 +41,48 @@ def fit(self, X, Y, W=None): class RidgeRegressionLearner(LinearRegressionLearner): __wraps__ = skl_linear_model.Ridge + supports_weights = True # Arguments are needed for signatures, pylint: disable=unused-argument - def __init__(self, alpha=1.0, fit_intercept=True, - normalize=False, copy_X=True, max_iter=None, - tol=0.001, solver='auto', preprocessors=None): + def __init__(self, alpha=1.0, fit_intercept=True, copy_X=True, + max_iter=None, tol=0.001, solver='auto', preprocessors=None): super().__init__(preprocessors=preprocessors) self.params = vars() class LassoRegressionLearner(LinearRegressionLearner): __wraps__ = skl_linear_model.Lasso + supports_weights = True # Arguments are needed for signatures, pylint: disable=unused-argument - def __init__(self, alpha=1.0, fit_intercept=True, normalize=False, - precompute=False, copy_X=True, max_iter=1000, - tol=0.0001, warm_start=False, positive=False, - preprocessors=None): + def __init__(self, alpha=1.0, fit_intercept=True, precompute=False, + copy_X=True, max_iter=1000, tol=0.0001, warm_start=False, + positive=False, preprocessors=None): super().__init__(preprocessors=preprocessors) self.params = vars() class ElasticNetLearner(LinearRegressionLearner): __wraps__ = skl_linear_model.ElasticNet + supports_weights = True # Arguments are needed for signatures, pylint: disable=unused-argument def __init__(self, alpha=1.0, l1_ratio=0.5, fit_intercept=True, - normalize=False, precompute=False, max_iter=1000, - copy_X=True, tol=0.0001, warm_start=False, positive=False, - preprocessors=None): + precompute=False, max_iter=1000, copy_X=True, tol=0.0001, + warm_start=False, positive=False, preprocessors=None): super().__init__(preprocessors=preprocessors) self.params = vars() class ElasticNetCVLearner(LinearRegressionLearner): __wraps__ = skl_linear_model.ElasticNetCV + supports_weights = True # Arguments are needed for signatures, pylint: disable=unused-argument - def __init__(self, l1_ratio=0.5, eps=0.001, n_alphas=100, alphas=None, - fit_intercept=True, normalize=False, precompute='auto', - max_iter=1000, tol=0.0001, cv=5, copy_X=True, - verbose=0, n_jobs=1, positive=False, preprocessors=None): + def __init__(self, l1_ratio=0.5, eps=0.001, n_alphas=100, alphas=100, + fit_intercept=True, precompute='auto', max_iter=1000, + tol=0.0001, cv=5, copy_X=True, verbose=0, n_jobs=1, + positive=False, preprocessors=None): super().__init__(preprocessors=preprocessors) self.params = vars() @@ -88,9 +90,10 @@ def __init__(self, l1_ratio=0.5, eps=0.001, n_alphas=100, alphas=None, class SGDRegressionLearner(LinearRegressionLearner): __wraps__ = skl_linear_model.SGDRegressor preprocessors = SklLearner.preprocessors + [Normalize()] + supports_weights = True # Arguments are needed for signatures, pylint: disable=unused-argument - def __init__(self, loss='squared_loss', penalty='l2', alpha=0.0001, + def __init__(self, loss='squared_error', penalty='l2', alpha=0.0001, l1_ratio=0.15, fit_intercept=True, max_iter=5, tol=1e-3, shuffle=True, epsilon=0.1, n_jobs=1, random_state=None, learning_rate='invscaling', eta0=0.01, power_t=0.25, diff --git a/Orange/regression/neural_network.py b/Orange/regression/neural_network.py index 7a8b553756d..85339ebb660 100644 --- a/Orange/regression/neural_network.py +++ b/Orange/regression/neural_network.py @@ -12,6 +12,7 @@ class MLPRegressorWCallback(skl_nn.MLPRegressor, NIterCallbackMixin): class NNRegressionLearner(NNBase, SklLearner): __wraps__ = MLPRegressorWCallback + supports_weights = False def _initialize_wrapped(self): clf = SklLearner._initialize_wrapped(self) diff --git a/Orange/regression/pls.py b/Orange/regression/pls.py new file mode 100644 index 00000000000..2830c0b05ae --- /dev/null +++ b/Orange/regression/pls.py @@ -0,0 +1,271 @@ +import numpy as np +import scipy.stats as ss +import sklearn.cross_decomposition as skl_pls +from sklearn.preprocessing import StandardScaler + +from Orange.base import Learner +from Orange.data import Table, Domain, Variable, \ + ContinuousVariable, StringVariable +from Orange.data.util import get_unique_names, SharedComputeValue +from Orange.preprocess.score import LearnerScorer +from Orange.regression.base_regression import SklLearnerRegression +from Orange.regression.linear import LinearModel + +__all__ = ["PLSRegressionLearner"] + + +class _FeatureScorerMixin(LearnerScorer): + feature_type = Variable + class_type = ContinuousVariable + + def score(self, data): + model = self(data) + return np.abs(model.coefficients), model.domain.attributes + + +class _PLSCommonTransform: + + def __init__(self, pls_model): + self.pls_model = pls_model + + def _transform_with_numpy_output(self, X, Y): + """ + # the next command does the following + x_center = X - pls._x_mean + y_center = Y - pls._y_mean + t = x_center @ pls.x_rotations_ + u = y_center @ pls.y_rotations_ + """ + pls = self.pls_model.skl_model + mask = np.isnan(Y).any(axis=1) + n_comp = pls.n_components + t = np.full((len(X), n_comp), np.nan, dtype=float) + u = np.full((len(X), n_comp), np.nan, dtype=float) + if (~mask).sum() > 0: + t_, u_ = pls.transform(X[~mask], Y[~mask]) + t[~mask] = t_ + u[~mask] = u_ + if mask.sum() > 0: + t[mask] = pls.transform(X[mask]) + return np.hstack((t, u)) + + def __call__(self, data): + if data.domain != self.pls_model.domain: + data = data.transform(self.pls_model.domain) + if len(data.Y.shape) == 1: + Y = data.Y.reshape(-1, 1) + else: + Y = data.Y + return self._transform_with_numpy_output(data.X, Y) + + def __eq__(self, other): + if self is other: + return True + return type(self) is type(other) \ + and self.pls_model == other.pls_model + + def __hash__(self): + return hash(self.pls_model) + + +class PLSProjector(SharedComputeValue): + def __init__(self, transform, feature): + super().__init__(transform) + self.feature = feature + + def compute(self, _, shared_data): + return shared_data[:, self.feature] + + def __eq__(self, other): + if self is other: + return True + return super().__eq__(other) and self.feature == other.feature + + def __hash__(self): + return hash((super().__hash__(), self.feature)) + + +class PLSModel(LinearModel): + var_prefix_X = "PLS T" + var_prefix_Y = "PLS U" + + def predict(self, X): + vals = self.skl_model.predict(X) + if len(self.domain.class_vars) == 1: + vals = vals.ravel() + return vals + + def __str__(self): + return f"PLSModel {self.skl_model}" + + def _get_var_names(self, n, prefix): + proposed = [f"{prefix}{postfix}" for postfix in range(1, n + 1)] + names = [var.name for var in self.domain.metas + self.domain.variables] + return get_unique_names(names, proposed) + + def project(self, data): + if not isinstance(data, Table): + raise RuntimeError("PLSModel can only project tables") + + transformer = _PLSCommonTransform(self) + + def trvar(i, name): + return ContinuousVariable( + name, compute_value=PLSProjector(transformer, i)) + + n_components = self.skl_model.x_loadings_.shape[1] + + var_names_X = self._get_var_names(n_components, self.var_prefix_X) + var_names_Y = self._get_var_names(n_components, self.var_prefix_Y) + + domain = Domain( + [trvar(i, var_names_X[i]) for i in range(n_components)], + data.domain.class_vars, + [trvar(n_components + i, var_names_Y[i]) for i in + range(n_components)] + ) + + return data.transform(domain) + + def components(self): + orig_domain = self.domain + names = [a.name for a in + orig_domain.attributes + orig_domain.class_vars] + meta_name = get_unique_names(names, 'components') + + n_components = self.skl_model.x_loadings_.shape[1] + + meta_vars = [StringVariable(name=meta_name)] + metas = np.array( + [[f"Component {i + 1}" for i in range(n_components)]], dtype=object + ).T + dom = Domain( + [ContinuousVariable(a.name) for a in orig_domain.attributes], + [ContinuousVariable(a.name) for a in orig_domain.class_vars], + metas=meta_vars) + components = Table(dom, + self.skl_model.x_loadings_.T, + Y=self.skl_model.y_loadings_.T, + metas=metas) + components.name = 'components' + return components + + def coefficients_table(self): + coeffs = self.coefficients.T + domain = Domain( + [ContinuousVariable(f"coef {i}") for i in range(coeffs.shape[1])], + metas=[StringVariable("name")] + ) + waves = [[attr.name] for attr in self.domain.attributes] + coef_table = Table.from_numpy(domain, X=coeffs, metas=waves) + coef_table.name = "coefficients" + return coef_table + + @property + def rotations(self) -> tuple[np.ndarray, np.ndarray]: + return self.skl_model.x_rotations_, self.skl_model.y_rotations_ + + @property + def loadings(self) -> tuple[np.ndarray, np.ndarray]: + return self.skl_model.x_loadings_, self.skl_model.y_loadings_ + + def residuals_normal_probability(self, data: Table) -> Table: + pred = self(data) + n = len(data) + m = len(data.domain.class_vars) + + err = data.Y - pred + if m == 1: + err = err[:, None] + + theoretical_percentiles = (np.arange(1.0, n + 1)) / (n + 1) + quantiles = ss.norm.ppf(theoretical_percentiles) + ind = np.argsort(err, axis=0) + theoretical_quantiles = np.zeros((n, m), dtype=float) + for i in range(m): + theoretical_quantiles[ind[:, i], i] = quantiles + + # check names so that tables could later be merged + proposed = [f"{name} ({var.name})" for var in data.domain.class_vars + for name in ("Sample Quantiles", "Theoretical Quantiles")] + names = get_unique_names(data.domain, proposed) + domain = Domain([ContinuousVariable(name) for name in names]) + X = np.zeros((n, m * 2), dtype=float) + X[:, 0::2] = err + X[:, 1::2] = theoretical_quantiles + res_table = Table.from_numpy(domain, X) + res_table.name = "residuals normal probability" + return res_table + + def dmodx(self, data: Table) -> Table: + data = self.data_to_model_domain(data) + + n_comp = self.skl_model.n_components + resids_ssx = self._residual_ssx(data.X) + s = np.sqrt(resids_ssx / (self.skl_model.x_loadings_.shape[0] - n_comp)) + s0 = np.sqrt(resids_ssx.sum() / ( + (self.skl_model.x_scores_.shape[0] - n_comp - 1) * + (data.X.shape[1] - n_comp))) + dist = np.sqrt((s / s0) ** 2) + + name = get_unique_names(data.domain, ["DModX"])[0] + domain = Domain([ContinuousVariable(name)]) + dist_table = Table.from_numpy(domain, dist[:, None]) + dist_table.name = "DMod" + return dist_table + + def _residual_ssx(self, X: np.ndarray) -> np.ndarray: + pred_scores = self.skl_model.transform(X) + inv_pred_scores = self.skl_model.inverse_transform(pred_scores) + + scaler = StandardScaler() + scaler.fit(X) + x_recons = scaler.transform(inv_pred_scores) + x_scaled = scaler.transform(X) + return np.sum((x_scaled - x_recons) ** 2, axis=1) + + +class PLSRegressionLearner(SklLearnerRegression, _FeatureScorerMixin): + __wraps__ = skl_pls.PLSRegression + __returns__ = PLSModel + supports_multiclass = True + preprocessors = SklLearnerRegression.preprocessors + + def fit(self, X, Y, W=None): + params = self.params.copy() + params["n_components"] = min(X.shape[1] - 1, + X.shape[0] - 1, + params["n_components"]) + clf = self.__wraps__(**params) + return self.__returns__(clf.fit(X, Y)) + + # pylint: disable=unused-argument + def __init__(self, n_components=2, scale=True, + max_iter=500, preprocessors=None): + super().__init__(preprocessors=preprocessors) + self.params = vars() + + def incompatibility_reason(self, domain): + reason = None + if not domain.class_vars: + reason = "Numeric targets expected." + else: + for cv in domain.class_vars: + if not cv.is_continuous: + reason = "Only numeric target variables expected." + return reason + + @property + def fitted_parameters(self) -> list[Learner.FittedParameter]: + return [self.FittedParameter("n_components", "Components", + int, 1, None)] + + +if __name__ == '__main__': + import Orange + + housing = Orange.data.Table('housing') + learners = [PLSRegressionLearner(n_components=2, max_iter=100)] + res = Orange.evaluation.CrossValidation()(housing, learners) + for learner, ca in zip(learners, Orange.evaluation.RMSE(res)): + print(f"learner: {learner}\nRMSE: {ca}\n") diff --git a/Orange/regression/random_forest.py b/Orange/regression/random_forest.py index 6938e91a75c..4b37888ae27 100644 --- a/Orange/regression/random_forest.py +++ b/Orange/regression/random_forest.py @@ -38,15 +38,16 @@ def wrap(tree, i): class RandomForestRegressionLearner(SklLearner, _FeatureScorerMixin): __wraps__ = skl_ensemble.RandomForestRegressor __returns__ = RandomForestRegressor + supports_weights = True def __init__(self, n_estimators=10, - criterion="mse", + criterion="squared_error", max_depth=None, min_samples_split=2, min_samples_leaf=1, min_weight_fraction_leaf=0., - max_features="auto", + max_features=1.0, max_leaf_nodes=None, bootstrap=True, oob_score=False, diff --git a/Orange/regression/simple_random_forest.py b/Orange/regression/simple_random_forest.py index 05b8c64a2f8..88c8edb1842 100644 --- a/Orange/regression/simple_random_forest.py +++ b/Orange/regression/simple_random_forest.py @@ -62,9 +62,14 @@ def __init__(self, learner, data): self.estimators_ = [] self.learn(learner, data) - def predict_storage(self, data): - p = np.zeros(data.X.shape[0]) + def predict(self, X): + p = np.zeros(X.shape[0]) + X = np.ascontiguousarray(X) # so that it is a no-op for individual trees for tree in self.estimators_: - p += tree(data) + # SimpleTrees do not have preprocessors and domain conversion + # was already handled within this class so we can call tree.predict() directly + # instead of going through tree.__call__ + pt = tree.predict(X) + p += pt p /= len(self.estimators_) return p diff --git a/Orange/regression/tests/test_curvefit.py b/Orange/regression/tests/test_curvefit.py new file mode 100644 index 00000000000..10b5420729e --- /dev/null +++ b/Orange/regression/tests/test_curvefit.py @@ -0,0 +1,310 @@ +import pickle +import copy +import ast +import unittest + +import numpy as np + +from Orange.base import Model +from Orange.data import Table, Domain +from Orange.evaluation import CrossValidation, RMSE +from Orange.preprocess import Impute +from Orange.preprocess.impute import Random +from Orange.regression import CurveFitLearner +from Orange.regression.curvefit import _create_lambda +import Orange.tests + +class TestCreateLambda(unittest.TestCase): + def test_create_lambda_simple(self): + func_, params_, vars_ = _create_lambda("a + b", [], []) + self.assertTrue(callable(func_)) + self.assertEqual(params_, ["a", "b"]) + self.assertEqual(vars_, []) + self.assertEqual(func_(np.array([[1, 11], [2, 22]]), 1, 2), 3) + + def test_create_lambda_var(self): + func_, params_, vars_ = _create_lambda("var + a + b", ["var"], []) + self.assertTrue(callable(func_)) + self.assertEqual(params_, ["a", "b"]) + self.assertEqual(vars_, ["var"]) + np.testing.assert_array_equal( + func_(np.array([[1, 11], [2, 22]]), 1, 2), + np.array([4, 5]) + ) + + def test_create_lambda_fun(self): + func_, params_, vars_ = _create_lambda("power(a, 2)", [], ["power"]) + self.assertTrue(callable(func_)) + self.assertEqual(params_, ["a"]) + self.assertEqual(vars_, []) + np.testing.assert_array_equal( + func_(np.array([[1, 11], [2, 22]]), 3), + np.array([9, 9]) + ) + + def test_create_lambda_var_fun(self): + func_, params_, vars_ = _create_lambda( + "var1 + power(a, 2) + power(a, 2)", ["var1", "var2"], ["power"] + ) + self.assertTrue(callable(func_)) + self.assertEqual(params_, ["a"]) + self.assertEqual(vars_, ["var1"]) + np.testing.assert_array_equal( + func_(np.array([[1, 11], [2, 22]]), 3), + np.array([19, 20]) + ) + + def test_create_lambda_x(self): + func_, params_, vars_ = _create_lambda( + "var1 + x", ["var1", "var2"], [] + ) + self.assertTrue(callable(func_)) + self.assertEqual(params_, ["x"]) + self.assertEqual(vars_, ["var1"]) + np.testing.assert_array_equal( + func_(np.array([[1, 11], [2, 22]]), 3), np.array([4, 5]) + ) + + def test_create_lambda_ast(self): + func_, params_, vars_ = _create_lambda( + ast.parse("a + b", mode="eval"), [], [] + ) + self.assertTrue(callable(func_)) + self.assertEqual(params_, ["a", "b"]) + self.assertEqual(vars_, []) + self.assertEqual(func_(np.array([[1, 11], [2, 22]]), 1, 2), 3) + + def test_create_lambda(self): + func_, params_, vars_ = _create_lambda( + "a * var1 + b * exp(var2 * power(pi, 0))", + ["var1", "var2", "var3"], ["exp", "power", "pi"] + ) + self.assertTrue(callable(func_)) + self.assertEqual(params_, ["a", "b"]) + self.assertEqual(vars_, ["var1", "var2"]) + np.testing.assert_allclose( + func_(np.array([[1, 2], [3, 4]]), 3, 2), + np.array([17.778112, 118.1963]) + ) + + +def func(x, a, b, c): + return a * np.exp(-b * x[:, 0]) + c + + +class TestCurveFitLearner(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.data = Table("housing") + + def test_init_str(self): + kw = dict(available_feature_names=[], functions=[]) + learner = CurveFitLearner("a + b", **kw) + self.assertIsInstance(learner, CurveFitLearner) + + self.assertRaises(TypeError, CurveFitLearner, "a + b") + + kw = dict(available_feature_names=[]) + self.assertRaises(TypeError, CurveFitLearner, "a + b", **kw) + + def test_init_ast(self): + kw = dict(available_feature_names=[], functions=[]) + exp = ast.parse("a + b", mode="eval") + learner = CurveFitLearner(exp, **kw) + self.assertIsInstance(learner, CurveFitLearner) + + self.assertRaises(TypeError, CurveFitLearner, exp) + + def test_init_callable(self): + kw = dict(parameters_names=[], features_names=[]) + learner = CurveFitLearner(lambda x, a: a, **kw) + self.assertIsInstance(learner, CurveFitLearner) + + self.assertRaises(TypeError, CurveFitLearner, lambda x, a: a) + + kw = dict(parameters_names=[]) + self.assertRaises(TypeError, CurveFitLearner, lambda x, a: a, **kw) + + def test_fit(self): + learner = CurveFitLearner(func, [], ["CRIM"]) + model = learner(self.data) + self.assertIsInstance(model, Model) + + def test_fit_no_params(self): + learner = CurveFitLearner(lambda x: x[:, 0] + 1, [], ["CRIM"]) + self.assertRaises(ValueError, learner, self.data) + + def test_predict(self): + learner = CurveFitLearner(func, [], ["CRIM"]) + model = learner(self.data) + pred = model(self.data) + self.assertEqual(len(pred), len(self.data)) + + def test_predict_constant(self): + def constant(_, a): + return a + + learner = CurveFitLearner(constant, [], ["CRIM"]) + model = learner(self.data) + pred = model(self.data) + self.assertEqual(pred.shape, (len(self.data),)) + + def test_coefficients(self): + learner = CurveFitLearner(func, ["a", "b", "c"], ["LSTAT"]) + model = learner(self.data) + coef = model.coefficients + self.assertEqual(len(coef), 3) + self.assertEqual(len(coef.domain.variables), 1) + self.assertEqual(len(coef.domain.metas), 1) + + def test_inadequate_data(self): + data = Table("iris") + learner = CurveFitLearner(func, [], ["sepal length"]) + self.assertRaises(ValueError, learner, data) + + learner = CurveFitLearner(func, [], ["iris"]) + attributes = data.domain.attributes[:-1] + class_var = data.domain.attributes[-1] + domain = Domain(attributes + data.domain.class_vars, class_var) + self.assertRaises(ValueError, learner, data.transform(domain)) + + def test_missing_values(self): + data = self.data.copy() + with data.unlocked(): + data.X[0, 12] = np.nan + learner = CurveFitLearner(func, [], ["CRIM"]) + model = learner(data) + pred = model(data) + self.assertEqual(len(pred), len(data)) + + def test_cv(self): + learner = CurveFitLearner(func, [], ["CRIM"]) + cv = CrossValidation(k=10) + results = cv(self.data, [learner]) + RMSE(results) + + # pylint: disable=unsubscriptable-object + def test_cv_preprocess(self): + def fun(x, a): + return x[:, 0] + a + + imputer = Impute() + learner = CurveFitLearner(fun, ["a"], ["CRIM"]) + cv = CrossValidation(k=2) + results = cv(self.data, [learner]) + rmse1 = RMSE(results)[0] + + learner = CurveFitLearner(fun, ["a"], ["CRIM"]) + cv = CrossValidation(k=2) + results = cv(self.data, [learner], preprocessor=imputer) + rmse2 = RMSE(results)[0] + + learner = CurveFitLearner(fun, ["a"], ["CRIM"], preprocessors=imputer) + cv = CrossValidation(k=2) + results = cv(self.data, [learner]) + rmse3 = RMSE(results)[0] + + self.assertEqual(rmse1, rmse2) + self.assertEqual(rmse2, rmse3) + + def test_predict_single_instance(self): + learner = CurveFitLearner(func, [], ["CRIM"]) + model = learner(self.data) + for ins in self.data: + pred = model(ins) + self.assertGreater(pred, 0) + + def test_predict_table(self): + learner = CurveFitLearner(func, [], ["CRIM"]) + model = learner(self.data) + pred = model(self.data) + self.assertEqual(pred.shape, (len(self.data),)) + self.assertGreater(all(pred), 0) + + def test_predict_numpy(self): + learner = CurveFitLearner(func, [], ["CRIM"]) + model = learner(self.data) + pred = model(self.data.X) + self.assertEqual(pred.shape, (len(self.data),)) + self.assertGreater(all(pred), 0) + + def test_predict_sparse(self): + sparse_data = self.data.to_sparse() + learner = CurveFitLearner(func, [], ["CRIM"]) + self.assertRaises(TypeError, learner, sparse_data) + + def test_can_copy_str(self): + available_feature_names = [a.name for a in self.data.domain.attributes] + learner = CurveFitLearner( + "a * exp(-b * CRIM) + c", + available_feature_names=available_feature_names, + functions=["exp"], + ) + + model = learner(self.data) + pred = model(self.data) + + np.testing.assert_array_equal( + pred, copy.deepcopy(model)(self.data) + ) + np.testing.assert_array_equal( + pred, copy.deepcopy(learner)(self.data)(self.data) + ) + + def test_can_copy_callable(self): + learner = CurveFitLearner(func, [], ["CRIM"]) + self.assertRaises(AttributeError, copy.deepcopy, learner) + self.assertRaises(AttributeError, copy.deepcopy, learner(self.data)) + + def test_can_copy_with_imputer(self): + available_feature_names = [a.name for a in self.data.domain.attributes] + learner = CurveFitLearner( + "a * exp(-b * CRIM) + c", + available_feature_names=available_feature_names, + functions=["exp"], + preprocessors=Impute() + ) + copy.deepcopy(learner) + copy.deepcopy(learner(self.data)) + + learner = CurveFitLearner( + "a * exp(-b * CRIM) + c", + available_feature_names=available_feature_names, + functions=["exp"], + preprocessors=Impute(method=Random()) + ) + copy.deepcopy(learner) + # uncomment when issue #5480 is solved + # copy.deepcopy(learner(self.data)) + + def test_can_pickle_str(self): + available_feature_names = [a.name for a in self.data.domain.attributes] + learner = CurveFitLearner( + "a * exp(-b * CRIM) + c", + available_feature_names=available_feature_names, + functions=["exp"], + ) + + model = learner(self.data) + + dumped_learner = pickle.dumps(learner) + loaded_learner = pickle.loads(dumped_learner) + + dumped_model = pickle.dumps(model) + loaded_model = pickle.loads(dumped_model) + + np.testing.assert_array_equal(model(self.data), + loaded_model(self.data)) + np.testing.assert_array_equal(model(self.data), + loaded_learner(self.data)(self.data)) + + def test_can_pickle_callable(self): + learner = CurveFitLearner( + lambda x, a, b, c: a * np.exp(-b * x[:, 0]) + c, [], ["CRIM"] + ) + self.assertRaises(AttributeError, pickle.dumps, learner) + self.assertRaises(AttributeError, pickle.dumps, learner(self.data)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/regression/tests/test_pls.py b/Orange/regression/tests/test_pls.py new file mode 100644 index 00000000000..bc053d9cee7 --- /dev/null +++ b/Orange/regression/tests/test_pls.py @@ -0,0 +1,204 @@ +# pylint: disable=missing-docstring +import unittest + +import numpy as np +from sklearn.cross_decomposition import PLSRegression + +from Orange.data import Table, Domain, ContinuousVariable +from Orange.regression import PLSRegressionLearner +from Orange.regression.pls import _PLSCommonTransform + + +def table(rows, attr, variables): + attr_vars = [ContinuousVariable(name=f"Feature {i}") for i in + range(attr)] + class_vars = [ContinuousVariable(name=f"Class {i}") for i in + range(variables)] + domain = Domain(attr_vars, class_vars, []) + X = np.random.RandomState(0).random((rows, attr)) + Y = np.random.RandomState(1).random((rows, variables)) + return Table.from_numpy(domain, X=X, Y=Y) + + +class TestPLSRegressionLearner(unittest.TestCase): + def test_fitted_parameters(self): + fitted_parameters = PLSRegressionLearner().fitted_parameters + self.assertIsInstance(fitted_parameters, list) + self.assertEqual(len(fitted_parameters), 1) + + def test_allow_y_dim(self): + """ The current PLS version allows only a single Y dimension. """ + learner = PLSRegressionLearner(n_components=2) + d = table(10, 5, 0) + with self.assertRaises(ValueError): + learner(d) + for n_class_vars in [1, 2, 3]: + d = table(10, 5, n_class_vars) + learner(d) # no exception + + def test_compare_to_sklearn(self): + d = table(10, 5, 1) + orange_model = PLSRegressionLearner()(d) + scikit_model = PLSRegression().fit(d.X, d.Y) + np.testing.assert_almost_equal(scikit_model.predict(d.X).ravel(), + orange_model(d)) + np.testing.assert_almost_equal(scikit_model.coef_, + orange_model.coefficients) + + def test_compare_to_sklearn_multid(self): + d = table(10, 5, 3) + orange_model = PLSRegressionLearner()(d) + scikit_model = PLSRegression().fit(d.X, d.Y) + np.testing.assert_almost_equal(scikit_model.predict(d.X), + orange_model(d)) + np.testing.assert_almost_equal(scikit_model.coef_, + orange_model.coefficients) + + def test_too_many_components(self): + # do not change n_components + d = table(5, 5, 1) + model = PLSRegressionLearner(n_components=4)(d) + self.assertEqual(model.skl_model.n_components, 4) + # need to use fewer components; column limited + d = table(6, 5, 1) + model = PLSRegressionLearner(n_components=6)(d) + self.assertEqual(model.skl_model.n_components, 4) + # need to use fewer components; row limited + d = table(5, 6, 1) + model = PLSRegressionLearner(n_components=6)(d) + self.assertEqual(model.skl_model.n_components, 4) + + def test_scores(self): + for d in [table(10, 5, 1), table(10, 5, 3)]: + orange_model = PLSRegressionLearner()(d) + scikit_model = PLSRegression().fit(d.X, d.Y) + scores = orange_model.project(d) + sx, sy = scikit_model.transform(d.X, d.Y) + np.testing.assert_almost_equal(sx, scores.X) + np.testing.assert_almost_equal(sy, scores.metas) + + def test_components(self): + def t2d(m): + return m.reshape(-1, 1) if len(m.shape) == 1 else m + + for d in [table(10, 5, 1), table(10, 5, 3)]: + orange_model = PLSRegressionLearner()(d) + scikit_model = PLSRegression().fit(d.X, d.Y) + components = orange_model.components() + np.testing.assert_almost_equal(scikit_model.x_loadings_, + components.X.T) + np.testing.assert_almost_equal(scikit_model.y_loadings_, + t2d(components.Y).T) + + def test_coefficients(self): + for d in [table(10, 5, 1), table(10, 5, 3)]: + orange_model = PLSRegressionLearner()(d) + scikit_model = PLSRegression().fit(d.X, d.Y) + coef_table = orange_model.coefficients_table() + np.testing.assert_almost_equal(scikit_model.coef_.T, + coef_table.X) + + def test_residuals_normal_probability(self): + for d in [table(10, 5, 1), table(10, 5, 3)]: + orange_model = PLSRegressionLearner()(d) + res_table = orange_model.residuals_normal_probability(d) + n_target = len(d.domain.class_vars) + self.assertEqual(res_table.X.shape, (len(d), 2 * n_target)) + + def test_dmodx(self): + for d in (table(10, 5, 1), table(10, 5, 3)): + orange_model = PLSRegressionLearner()(d) + dist_table = orange_model.dmodx(d) + self.assertEqual(dist_table.X.shape, (len(d), 1)) + + def test_eq_hash(self): + data = Table("housing") + pls1 = PLSRegressionLearner()(data) + pls2 = PLSRegressionLearner()(data) + + proj1 = pls1.project(data) + proj2 = pls2.project(data) + + np.testing.assert_equal(proj1.X, proj2.X) + np.testing.assert_equal(proj1.metas, proj2.metas) + + # even though results are the same, these transformations + # are different because the PLS object is + self.assertNotEqual(proj1, proj2) + self.assertNotEqual(proj1.domain, proj2.domain) + self.assertNotEqual(hash(proj1), hash(proj2)) + self.assertNotEqual(hash(proj1.domain), hash(proj2.domain)) + + def test_eq_hash_fake_same_model(self): + data = Table("housing") + pls1 = PLSRegressionLearner()(data) + pls2 = PLSRegressionLearner()(data) + + proj1 = pls1.project(data) + proj2 = pls2.project(data) + + proj2.domain[0].compute_value.compute_shared.pls_model = \ + proj1.domain[0].compute_value.compute_shared.pls_model + # reset hash caches because object were hacked + # pylint: disable=protected-access + proj1.domain._hash = None + proj2.domain._hash = None + + self.assertEqual(proj1.domain, proj2.domain) + self.assertEqual(hash(proj1.domain), hash(proj2.domain)) + + +class TestPLSCommonTransform(unittest.TestCase): + def test_eq(self): + m = PLSRegressionLearner()(table(10, 5, 1)) + transformer = _PLSCommonTransform(m) + self.assertEqual(transformer, transformer) + self.assertEqual(transformer, _PLSCommonTransform(m)) + + m = PLSRegressionLearner()(table(10, 5, 2)) + self.assertNotEqual(transformer, _PLSCommonTransform(m)) + + def test_hash(self): + m = PLSRegressionLearner()(table(10, 5, 1)) + transformer = _PLSCommonTransform(m) + self.assertEqual(hash(transformer), hash(transformer)) + self.assertEqual(hash(transformer), hash(_PLSCommonTransform(m))) + + m = PLSRegressionLearner()(table(10, 5, 2)) + self.assertNotEqual(hash(transformer), hash(_PLSCommonTransform(m))) + + def test_missing_target(self): + data = table(10, 5, 1) + with data.unlocked(data.Y): + data.Y[::3] = np.nan + pls = PLSRegressionLearner()(data) + proj = pls.project(data) + self.assertFalse(np.isnan(proj.X).any()) + self.assertFalse(np.isnan(proj.metas[1::3]).any()) + self.assertFalse(np.isnan(proj.metas[2::3]).any()) + self.assertTrue(np.isnan(proj.metas[::3]).all()) + + def test_missing_target_multitarget(self): + data = table(10, 5, 3) + with data.unlocked(data.Y): + data.Y[0] = np.nan + data.Y[1, 1] = np.nan + + pls = PLSRegressionLearner()(data) + proj = pls.project(data) + self.assertFalse(np.isnan(proj.X).any()) + self.assertFalse(np.isnan(proj.metas[2:]).any()) + self.assertTrue(np.isnan(proj.metas[:2]).all()) + + def test_apply_domain_classless_data(self): + data = Table("housing") + pls = PLSRegressionLearner()(data) + classless_data = data.transform(Domain(data.domain.attributes))[:5] + + proj = pls.project(classless_data) + self.assertFalse(np.isnan(proj.X).any()) + self.assertTrue(np.isnan(proj.metas).all()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/regression/tree.py b/Orange/regression/tree.py index 802f5a0566b..9e49e930736 100644 --- a/Orange/regression/tree.py +++ b/Orange/regression/tree.py @@ -184,8 +184,9 @@ class SklTreeRegressionLearner(SklLearner): __wraps__ = skl_tree.DecisionTreeRegressor __returns__ = SklTreeRegressor name = 'regression tree' + supports_weights = True - def __init__(self, criterion="mse", splitter="best", max_depth=None, + def __init__(self, criterion="squared_error", splitter="best", max_depth=None, min_samples_split=2, min_samples_leaf=1, max_features=None, random_state=None, max_leaf_nodes=None, diff --git a/Orange/regression/xgb.py b/Orange/regression/xgb.py index 4c8f4b7d362..be66ba8604a 100644 --- a/Orange/regression/xgb.py +++ b/Orange/regression/xgb.py @@ -23,6 +23,7 @@ def score(self, data: Table) -> Tuple[np.ndarray, Tuple[Variable]]: class XGBRegressor(XGBBase, Learner, _FeatureScorerMixin): __wraps__ = xgboost.XGBRegressor + supports_weights = True def __init__(self, max_depth=None, @@ -75,6 +76,7 @@ def __init__(self, class XGBRFRegressor(XGBBase, Learner, _FeatureScorerMixin): __wraps__ = xgboost.XGBRFRegressor + supports_weights = True def __init__(self, max_depth=None, diff --git a/Orange/statistics/basic_stats.py b/Orange/statistics/basic_stats.py index f5b1ffd287d..6ae274257c2 100644 --- a/Orange/statistics/basic_stats.py +++ b/Orange/statistics/basic_stats.py @@ -34,10 +34,11 @@ def from_data(self, data, variable): = stats[0] class DomainBasicStats: - def __init__(self, data, include_metas=False): + def __init__(self, data, include_metas=False, compute_variance=False): self.domain = data.domain self.stats = [BasicStats(s) for s in - data._compute_basic_stats(include_metas=include_metas)] + data._compute_basic_stats(include_metas=include_metas, + compute_variance=compute_variance)] def __getitem__(self, index): """ diff --git a/Orange/statistics/contingency.py b/Orange/statistics/contingency.py index 76135576ac4..da9a1c81983 100644 --- a/Orange/statistics/contingency.py +++ b/Orange/statistics/contingency.py @@ -179,9 +179,20 @@ def __reduce__(self): return ( _create_discrete, (Discrete, np.copy(self), self.col_variable, self.row_variable, - self.col_unknowns, self.row_unknowns) + self.col_unknowns, self.row_unknowns, self.unknowns) ) + def __array_finalize__(self, obj): + # defined in __new__, pylint: disable=attribute-defined-outside-init + """See http://docs.scipy.org/doc/numpy/user/basics.subclassing.html""" + if obj is None: + return + self.col_variable = getattr(obj, 'col_variable', None) + self.row_variable = getattr(obj, 'row_variable', None) + self.col_unknowns = getattr(obj, 'col_unknowns', None) + self.row_unknowns = getattr(obj, 'row_unknowns', None) + self.unknowns = getattr(obj, 'unknowns', None) + class Continuous: def __init__(self, dat, col_variable=None, row_variable=None, diff --git a/Orange/statistics/distribution.py b/Orange/statistics/distribution.py index 747cf5d1bd2..43c1680e6d8 100644 --- a/Orange/statistics/distribution.py +++ b/Orange/statistics/distribution.py @@ -30,6 +30,26 @@ def _get_variable(dat, variable, expected_type=None, expected_name=""): class Distribution(np.ndarray): + def __array_finalize__(self, obj): + # defined in derived classes, + # pylint: disable=attribute-defined-outside-init + """See http://docs.scipy.org/doc/numpy/user/basics.subclassing.html""" + if obj is None: + return + self.variable = getattr(obj, 'variable', None) + self.unknowns = getattr(obj, 'unknowns', 0) + + def __reduce__(self): + state = super().__reduce__() + newstate = state[2] + (self.variable, self.unknowns) + return state[0], state[1], newstate + + def __setstate__(self, state): + # defined in derived classes, + # pylint: disable=attribute-defined-outside-init + super().__setstate__(state[:-2]) + self.variable, self.unknowns = state[-2:] + def __eq__(self, other): return ( np.array_equal(self, other) and @@ -298,9 +318,13 @@ def sample(self, size=None, replace=True): return np.random.choice(self[0, :], size, replace, normalized[1, :]) def mean(self): + if len(self[0]) == 0: + return np.nan return np.average(np.asarray(self[0]), weights=np.asarray(self[1])) def variance(self): + if len(self[0]) == 0: + return np.nan mean = self.mean() return np.dot((self[0] - mean) ** 2, self[1]) / np.sum(self[1]) diff --git a/Orange/statistics/util.py b/Orange/statistics/util.py index a8080b6a3e0..64ce1f757b3 100644 --- a/Orange/statistics/util.py +++ b/Orange/statistics/util.py @@ -9,6 +9,7 @@ import bottleneck as bn import numpy as np +import pandas import scipy.stats.stats from scipy import sparse as sp @@ -115,9 +116,9 @@ def bincount(x, weights=None, max_val=None, minlength=0): values as well, even if they do not appear in the data. However, this will not truncate the bincount if values larger than `max_count` are found. >>> bincount([0, 0, 1, 1, 2], max_val=4) - (array([ 2., 2., 1., 0., 0.]), 0.0) + (array([2., 2., 1., 0., 0.]), 0.0) >>> bincount([0, 1, 2, 3, 4], max_val=2) - (array([ 1., 1., 1., 1., 1.]), 0.0) + (array([1., 1., 1., 1., 1.]), 0.0) """ # Store the original matrix before any manipulation to check for sparse @@ -340,40 +341,34 @@ def stats(X, weights=None, compute_variance=False): """ is_numeric = np.issubdtype(X.dtype, np.number) is_sparse = sp.issparse(X) - weighted = weights is not None and X.dtype != object - def weighted_mean(): + if X.size and is_numeric: if is_sparse: - w_X = X.multiply(sp.csr_matrix(np.c_[weights] / sum(weights))) - return np.asarray(w_X.sum(axis=0)).ravel() + if X.shape[0] == 1: + # Since `countnans` assumes vector shape to be (1, n) and `x` + # shape is (n, 1), we pass the transpose + nans = countnans(X.T, axis=1) + else: + nans = countnans(X, axis=0) + X = X.tocsc() else: - return np.nansum(X * np.c_[weights] / sum(weights), axis=0) - - if X.size and is_numeric and not is_sparse: - nans = np.isnan(X).sum(axis=0) - return np.column_stack(( - np.nanmin(X, axis=0), - np.nanmax(X, axis=0), - np.nanmean(X, axis=0) if not weighted else weighted_mean(), - np.nanvar(X, axis=0) if compute_variance else np.zeros(X.shape[1]), - nans, - X.shape[0] - nans)) - elif is_sparse and X.size: + nans = np.isnan(X).sum(axis=0) if compute_variance: - raise NotImplementedError - - non_zero = np.bincount(X.nonzero()[1], minlength=X.shape[1]) - X = X.tocsc() + means, vars = nan_mean_var(X, axis=0, weights=weights) + else: + means = nanmean(X, axis=0, weights=weights) + vars = np.zeros(X.shape[1] if X.ndim == 2 else 1) return np.column_stack(( nanmin(X, axis=0), nanmax(X, axis=0), - nanmean(X, axis=0) if not weighted else weighted_mean(), - np.zeros(X.shape[1]), # variance not supported - X.shape[0] - non_zero, - non_zero)) + means, + vars, + nans, + X.shape[0] - nans)) else: - X_str = X.astype(str) - nans = ((X_str == "nan") | (X_str == "")).sum(axis=0) \ + if X.ndim == 1: + X = X[:, None] + nans = (pandas.isnull(X).sum(axis=0) + (X == "").sum(axis=0)) \ if X.size else np.zeros(X.shape[1]) return np.column_stack(( np.tile(np.inf, X.shape[1]), @@ -452,19 +447,74 @@ def nansum_sparse(x): return _apply_func(x, np.nansum, nansum_sparse, axis=axis) -def nanmean(x, axis=None): +def nanmean(x, axis=None, weights=None): """ Equivalent of np.nanmean that supports sparse or dense matrices. """ + if axis is None and weights is not None: + raise NotImplementedError("weights are only supported if axis is defined") + if not sp.issparse(x): - means = np.nanmean(x, axis=axis) + if weights is None: + means = bn.nanmean(x, axis=axis) + else: + if axis == 0: + weights = weights.reshape(-1, 1) + elif axis == 1: + weights = weights.reshape(1, -1) + else: + raise NotImplementedError + nanw = ~np.isnan(x) * weights # do not divide by non-used weights + means = bn.nansum(x * weights, axis=axis) / np.sum(nanw, axis=axis) elif axis is None: means, _ = mean_variance_axis(x, axis=0) means = np.nanmean(means) else: - means, _ = mean_variance_axis(x, axis=axis) + # mean_variance_axis is picky regarding the input type + if weights is not None: + weights = weights.astype(float) + means, _ = mean_variance_axis(x, axis=axis, weights=weights) return means +def nan_mean_var(x, axis=None, weights=None): + """ + Computes means and variance of dense and sparse matrices. + Supports weights. Based on mean_variance_axis. + """ + if axis is None: + raise NotImplementedError("axis=None is not supported") + + if not sp.issparse(x): + if weights is None: + means = bn.nanmean(x, axis=axis) + variances = bn.nanvar(x, axis=axis) + else: + if axis == 0: + weights = weights.reshape(-1, 1) + elif axis == 1: + weights = weights.reshape(1, -1) + else: + raise NotImplementedError + + nanw = ~np.isnan(x) * weights # do not divide by non-used weights + wsum = np.sum(nanw, axis=axis) + means = bn.nansum(x * weights, axis=axis) / wsum + + if axis == 0: + mr = means.reshape(1, -1) + elif axis == 1: + mr = means.reshape(-1, 1) + + variances = bn.nansum(((x - mr) ** 2) * weights, axis=axis) / wsum + else: + # mean_variance_axis is picky regarding the input type + if weights is not None: + weights = weights.astype(float) + means, variances = mean_variance_axis(x, axis=axis, weights=weights) + + return means, variances + + def nanvar(x, axis=None, ddof=0): """ Equivalent of np.nanvar that supports sparse or dense matrices. """ def nanvar_sparse(x): @@ -473,7 +523,7 @@ def nanvar_sparse(x): avg = np.nansum(x.data) / n_vals return (np.nansum((x.data - avg) ** 2) + avg ** 2 * n_zeros) / (n_vals - ddof) - return _apply_func(x, np.nanvar, nanvar_sparse, axis=axis) + return _apply_func(x, bn.nanvar, nanvar_sparse, axis=axis) def nanstd(x, axis=None, ddof=0): @@ -506,9 +556,12 @@ def nanmode(x, axis=0): returns zero). Also, this function returns count NaN if all values are NaN (scipy=1.3.0 returns some number).""" nans = np.isnan(np.array(x)).sum(axis=axis, keepdims=True) == x.shape[axis] - res = scipy.stats.stats.mode(x, axis) - return scipy.stats.stats.ModeResult(np.where(nans, np.nan, res.mode), - np.where(nans, np.nan, res.count)) + res = scipy.stats.mode(x, axis, keepdims=True) + # type(res) is ModeResult. ModeResult is defined in scipy.stats.stats; this + # namespace is deprecated, but ModeResult is not exported to scipy.stats + # Hence we use type(res) to avoid a warning. + return type(res)(np.where(nans, np.nan, res.mode), + np.where(nans, np.nan, res.count)) def unique(x, return_counts=False): diff --git a/Orange/tests/__init__.py b/Orange/tests/__init__.py index 33c9e0acb14..b27f28a115e 100644 --- a/Orange/tests/__init__.py +++ b/Orange/tests/__init__.py @@ -12,6 +12,9 @@ import numpy as np import Orange +if Orange.data.Table.LOCKING is None: + Orange.data.Table.LOCKING = True + @contextmanager def named_file(content, encoding=None, suffix=''): diff --git a/Orange/tests/datasets/binary-blob.tab b/Orange/tests/datasets/binary-blob.tab deleted file mode 100644 index baf0c725e68..00000000000 Binary files a/Orange/tests/datasets/binary-blob.tab and /dev/null differ diff --git a/Orange/tests/dummy_learners.py b/Orange/tests/dummy_learners.py index 133547ff9e7..8e1896f80de 100644 --- a/Orange/tests/dummy_learners.py +++ b/Orange/tests/dummy_learners.py @@ -33,8 +33,9 @@ def __init__(self, value, prob): class DummyMulticlassLearner(SklLearner): supports_multiclass = True - def check_learner_adequacy(self, domain): - return all(c.is_discrete for c in domain.class_vars) + def incompatibility_reason(self, domain): + reason = 'Not all class variables are discrete' + return None if all(c.is_discrete for c in domain.class_vars) else reason def fit(self, X, Y, W): rows, class_vars = Y.shape diff --git a/Orange/tests/sql/base.py b/Orange/tests/sql/base.py index c0ff2fa1645..c396274bc39 100644 --- a/Orange/tests/sql/base.py +++ b/Orange/tests/sql/base.py @@ -6,6 +6,7 @@ import inspect import numpy as np +import pandas as pd from Orange.data import Table @@ -204,7 +205,7 @@ def create_sql_table(self, data, sql_column_types=None, insert_values = ", ".join( "({})".format( - ", ".join("NULL" if v is None else "'{}'".format(v) + ", ".join("NULL" if pd.isna(v) else "'{}'".format(v) for v, t in zip(row, sql_column_types)) ) for row in data ) @@ -277,7 +278,7 @@ def create_sql_table(self, data, sql_column_types=None, insert_values = ", ".join( "({})".format( - ", ".join("NULL" if v is None else "'{}'".format(v) + ", ".join("NULL" if pd.isna(v) else "'{}'".format(v) for v, t in zip(row, sql_column_types)) ) for row in data ) @@ -331,24 +332,13 @@ class DataBaseTest: @classmethod def _check_db(cls, db): + ver = None if ">" in db: i = db.find(">") - if db[:i] in cls.db_conn and \ - cls.db_conn[db[:i]].version <= int(db[i + 1:]): - raise unittest.SkipTest( - "This test is only run database version higher then {}" - .format(db[i + 1:])) - else: - db = db[:i] + db, ver = db[:i], db[i:] elif "<" in db: i = db.find("<") - if db[:i] in cls.db_conn and \ - cls.db_conn[db[:i]].version >= int(db[i + 1:]): - raise unittest.SkipTest( - "This test is only run on database version lower then {}" - .format(db[i + 1:])) - else: - db = db[:i] + db, ver = db[:i], db[i:] if db in cls.db_conn: if not cls.db_conn[db].is_module: @@ -364,6 +354,16 @@ def _check_db(cls, db): else: raise Exception("Unsupported database") + if ver is not None: + if ver[0] == ">" and cls.db_conn[db].version <= int(ver[1:]): + raise unittest.SkipTest( + "This test is only run database version higher then {}" + .format(ver[1:])) + if ver[0] == "<" and cls.db_conn[db].version >= int(ver[1:]): + raise unittest.SkipTest( + "This test is only run on database version lower then {}" + .format(ver[1:])) + return db @classmethod diff --git a/Orange/tests/sql/test_sql_table.py b/Orange/tests/sql/test_sql_table.py index 748f7f13beb..54502236ffa 100644 --- a/Orange/tests/sql/test_sql_table.py +++ b/Orange/tests/sql/test_sql_table.py @@ -6,6 +6,7 @@ import unittest.mock import unittest import string +from datetime import datetime import numpy as np from numpy.testing import assert_almost_equal @@ -45,6 +46,11 @@ def sql_table_from_data(self, data, guess_values=True): self.drop_sql_table(table_name) + def test_approx_len(self): + if datetime.today() > datetime(2027, 1, 1): + # remove table.approx_len() function and this test + self.assertTrue(False) + @dbt.run_on(["postgres"]) def test_constructs_correct_attributes(self): data = list(zip(self.float_variable(21), @@ -209,6 +215,9 @@ def test_query_subset_of_rows(self): def test_getitem_single_value(self): table = SqlTable(self.conn, self.iris, inspect_values=True) self.assertAlmostEqual(table[0, 0], 5.1) + self.assertAlmostEqual(table[0, table.domain[0]], 5.1) + self.assertEqual(table[0, 4], "Iris-setosa") + self.assertEqual(table[0, table.domain[4]], "Iris-setosa") @dbt.run_on(["postgres", "mssql"]) def test_type_hints(self): @@ -492,6 +501,7 @@ def test_time_date(self): sql_table = SqlTable(conn, table_name, inspect_values=True) self.assertFirstAttrIsInstance(sql_table, TimeVariable) + self.drop_sql_table(table_name) @dbt.run_on(["postgres"]) def test_time_time(self): @@ -748,6 +758,26 @@ def test_pickling_restores_connection_pool(self): self.assertEqual(iris[0], iris2[0]) + @dbt.run_on(["postgres"]) + def test_pickling_respects_downloaded_state(self): + iris = SqlTable(self.conn, self.iris, inspect_values=True) + iris2 = pickle.loads(pickle.dumps(iris)) + # pylint: disable=protected-access + self.assertIsNone(iris._X) + self.assertIsNone(iris2._X) + self.assertIsNone(iris._ids) + self.assertIsNone(iris2._ids) + + # trigger download into X, Y, metas + iris.X.shape[0] # pylint: disable=pointless-statement + self.assertIsNotNone(iris._X) + self.assertIsNotNone(iris._ids) + iris2 = pickle.loads(pickle.dumps(iris)) + self.assertIsNotNone(iris2._X) + self.assertIsNotNone(iris2._ids) + np.testing.assert_equal(iris.X, iris2.X) + self.assertEqual(len(set(iris.ids) | set(iris2.ids)), 300) + @dbt.run_on(["postgres"]) def test_list_tables_with_schema(self): with self.backend.execute_sql_query("DROP SCHEMA IF EXISTS orange_tests CASCADE") as cur: @@ -764,6 +794,20 @@ def test_list_tables_with_schema(self): with self.backend.execute_sql_query("DROP SCHEMA IF EXISTS orange_tests CASCADE"): pass + @dbt.run_on(["postgres", "mssql"]) + def test_nan_frequency(self): + ar = np.random.random((4, 3)) + ar[:2, 1:] = np.nan + conn, table_name = self.create_sql_table(ar) + + table = SqlTable(conn, table_name, inspect_values=False) + table.domain = Domain(table.domain.attributes[:-1], + table.domain.attributes[-1]) + self.assertEqual(table.get_nan_frequency_class(), 0.5) + self.assertEqual(table.get_nan_frequency_attribute(), 0.25) + + self.drop_sql_table(table_name) + def assertFirstAttrIsInstance(self, table, variable_type): self.assertGreater(len(table.domain.variables), 0) attr = table.domain[0] diff --git a/Orange/tests/test_ada_boost.py b/Orange/tests/test_ada_boost.py index c10f3af63b9..e42f05d3cef 100644 --- a/Orange/tests/test_ada_boost.py +++ b/Orange/tests/test_ada_boost.py @@ -2,7 +2,12 @@ # pylint: disable=missing-docstring import unittest + import numpy as np +from packaging.version import Version + +import Orange + from Orange.data import Table from Orange.classification import SklTreeLearner from Orange.regression import SklTreeRegressionLearner @@ -11,6 +16,7 @@ SklAdaBoostRegressionLearner, ) from Orange.evaluation import CrossValidation, CA, RMSE +from Orange.util import OrangeDeprecationWarning class TestSklAdaBoostLearner(unittest.TestCase): @@ -27,14 +33,14 @@ def test_adaboost(self): self.assertGreater(ca, 0.9) self.assertLess(ca, 0.99) - def test_adaboost_base_estimator(self): - np.random.seed(0) + def test_adaboost_estimator(self): + np.random.seed(4) stump_estimator = SklTreeLearner(max_depth=1) tree_estimator = SklTreeLearner() stump = SklAdaBoostClassificationLearner( - base_estimator=stump_estimator, n_estimators=5) + estimator=stump_estimator, n_estimators=5) tree = SklAdaBoostClassificationLearner( - base_estimator=tree_estimator, n_estimators=5) + estimator=tree_estimator, n_estimators=5) cv = CrossValidation(k=4) results = cv(self.iris, [stump, tree]) ca = CA(results) @@ -68,12 +74,12 @@ def test_adaboost_reg(self): results = cv(self.housing, [learn]) _ = RMSE(results) - def test_adaboost_reg_base_estimator(self): + def test_adaboost_reg_estimator(self): np.random.seed(0) stump_estimator = SklTreeRegressionLearner(max_depth=1) tree_estimator = SklTreeRegressionLearner() - stump = SklAdaBoostRegressionLearner(base_estimator=stump_estimator) - tree = SklAdaBoostRegressionLearner(base_estimator=tree_estimator) + stump = SklAdaBoostRegressionLearner(estimator=stump_estimator) + tree = SklAdaBoostRegressionLearner(estimator=tree_estimator) cv = CrossValidation(k=3) results = cv(self.housing, [stump, tree]) rmse = RMSE(results) @@ -103,3 +109,13 @@ def test_predict_numpy_reg(self): def test_adaboost_adequacy_reg(self): learner = SklAdaBoostRegressionLearner() self.assertRaises(ValueError, learner, self.iris) + + def test_remove_deprecation(self): + if (Version(Orange.__version__).is_prerelease + and Version(Orange.__version__) >= Version("3.42")): + self.fail( + "SklAdaBoostClassificationLearner: `algorithm` was deprecated in " + "version 3.40. Please remove everything related to it." + ) + with self.assertWarns(OrangeDeprecationWarning): + SklAdaBoostClassificationLearner(algorithm="invalid")(self.iris) diff --git a/Orange/tests/test_base.py b/Orange/tests/test_base.py index 9b1a0462a8c..e91044de9b2 100644 --- a/Orange/tests/test_base.py +++ b/Orange/tests/test_base.py @@ -10,16 +10,19 @@ class DummyLearner(Learner): + def fit(self, *_, **__): return unittest.mock.Mock() class DummySklLearner(SklLearner): + def fit(self, *_, **__): return unittest.mock.Mock() class DummyLearnerPP(Learner): + preprocessors = (Randomize(),) @@ -88,27 +91,6 @@ def test_callback(self): class TestSklLearner(unittest.TestCase): - def test_sklearn_supports_weights(self): - """Check that the SklLearner correctly infers whether or not the - learner supports weights""" - - class DummySklLearner: - def fit(self, X, y, sample_weight=None): - pass - - class DummyLearner(SklLearner): - __wraps__ = DummySklLearner - - self.assertTrue(DummyLearner().supports_weights) - - class DummySklLearner: - def fit(self, X, y): - pass - - class DummyLearner(SklLearner): - __wraps__ = DummySklLearner - - self.assertFalse(DummyLearner().supports_weights) def test_linreg(self): self.assertTrue( diff --git a/Orange/tests/test_basic_stats.py b/Orange/tests/test_basic_stats.py index 3b5ec18792f..fb234a84e43 100644 --- a/Orange/tests/test_basic_stats.py +++ b/Orange/tests/test_basic_stats.py @@ -28,6 +28,10 @@ def test_domain_basic_stats(self): self.assertStatsEqual(domain_stats.stats, attr_stats + class_var_stats + meta_stats) + def test_empty_table(self): + domain_stats = DomainBasicStats(self.zoo[:0]) + self.assertEqual(len(domain_stats.stats), 17) + def test_speed(self): n, m = 10, 10000 data = Table.from_numpy(None, np.random.rand(n, m)) diff --git a/Orange/tests/test_classification.py b/Orange/tests/test_classification.py index 95081652ac0..a1fbf7dcf58 100644 --- a/Orange/tests/test_classification.py +++ b/Orange/tests/test_classification.py @@ -5,7 +5,6 @@ import pkgutil import unittest -import traceback import warnings import numpy as np @@ -23,6 +22,7 @@ SVMLearner, LinearSVMLearner, OneClassSVMLearner, TreeLearner, KNNLearner, SimpleRandomForestLearner, EllipticEnvelopeLearner, ThresholdLearner, CalibratedLearner) +from Orange.modelling import ColumnLearner from Orange.classification.rules import _RuleLearner from Orange.data import (ContinuousVariable, DiscreteVariable, Domain, Table) @@ -31,6 +31,10 @@ from Orange.tests.dummy_learners import DummyLearner, DummyMulticlassLearner from Orange.tests import test_filename +# While this could be determined automatically from __init__ signatures, +# it is better to do it explicitly +LEARNERS_WITH_ARGUMENTS = (ThresholdLearner, CalibratedLearner, ColumnLearner) + def all_learners(): classification_modules = pkgutil.walk_packages( @@ -215,8 +219,11 @@ def test_result_shape(self): """ iris = Table('iris') for learner in all_learners(): - # calibration, threshold learners' __init__ requires arguments - if learner in (ThresholdLearner, CalibratedLearner): + if learner in LEARNERS_WITH_ARGUMENTS: + continue + + # Skip learners that are incompatible with the dataset + if learner.incompatibility_reason(self, iris.domain): continue with self.subTest(learner.__name__): @@ -257,7 +264,12 @@ def test_result_shape_numpy(self): args = [] if learner in (ThresholdLearner, CalibratedLearner): args = [LogisticRegressionLearner()] + elif learner in LEARNERS_WITH_ARGUMENTS: + continue data = iris_bin if learner is ThresholdLearner else iris + # Skip learners that are incompatible with the dataset + if learner.incompatibility_reason(self, data.domain): + continue model = learner(*args)(data) transformed_iris = model.data_to_model_domain(data) @@ -269,6 +281,26 @@ def test_result_shape_numpy(self): (1, len(data.domain.class_var.values)), res.shape ) + def test_predict_proba(self): + data = Table("heart_disease") + for learner in all_learners(): + with self.subTest(learner.__name__): + # Skip slow tests + if issubclass(learner, _RuleLearner): + continue + if learner in (ThresholdLearner, CalibratedLearner): + model = learner(LogisticRegressionLearner())(data) + elif learner in LEARNERS_WITH_ARGUMENTS: + # note that above two also require arguments, but we + # provide them + continue + else: + model = learner()(data) + probs = model.predict_proba(data) + shape = (len(data), len(data.domain.class_var.values)) + self.assertEqual(probs.shape, shape) + self.assertTrue(np.all(np.sum(probs, axis=1) - 1 < 0.0001)) + class ExpandProbabilitiesTest(unittest.TestCase): def prepareTable(self, rows, attr, vars, class_var_domain): @@ -324,7 +356,8 @@ def test_multinomial(self): def test_nan_columns(self): data = Orange.data.Table("iris") - data.X[:, (1, 3)] = np.NaN + with data.unlocked(): + data.X[:, (1, 3)] = np.nan lr = LogisticRegressionLearner() cv = CrossValidation(k=2, store_models=True) res = cv(data, [lr]) @@ -364,13 +397,12 @@ class UnknownValuesInPrediction(unittest.TestCase): def test_unknown(self): table = Table("iris") tree = LogisticRegressionLearner()(table) - tree([1, 2, None]) + tree([1, 2, None, 4]) def test_missing_class(self): table = Table(test_filename("datasets/adult_sample_missing")) for learner in all_learners(): - # calibration, threshold learners' __init__ require arguments - if learner in (ThresholdLearner, CalibratedLearner): + if learner in LEARNERS_WITH_ARGUMENTS: continue # Skip slow tests if isinstance(learner, _RuleLearner): @@ -398,15 +430,17 @@ def test_all_learners_accessible_in_Orange_classification_namespace(self): def test_all_models_work_after_unpickling(self): datasets = [Table('iris'), Table('titanic')] for learner in list(all_learners()): - # calibration, threshold learners' __init__ require arguments - if learner in (ThresholdLearner, CalibratedLearner): + if learner in LEARNERS_WITH_ARGUMENTS: continue # Skip slow tests - if isinstance(learner, _RuleLearner): + if issubclass(learner, _RuleLearner): continue with self.subTest(learner.__name__): learner = learner() for ds in datasets: + # Skip learners that are incompatible with the dataset + if learner.incompatibility_reason(ds.domain): + continue model = learner(ds) s = pickle.dumps(model, 0) model2 = pickle.loads(s) @@ -419,10 +453,39 @@ def test_all_models_work_after_unpickling(self): err_msg='%s does not return same values when unpickled %s' % (learner.__class__.__name__, ds.name)) + def test_all_models_work_after_unpickling_pca(self): + datasets = [Table('iris'), Table('titanic')] + for learner in list(all_learners()): + if learner in LEARNERS_WITH_ARGUMENTS: + continue + # Skip slow tests + if issubclass(learner, _RuleLearner): + continue + # temporary exclusion of the ScoringSheet learner + if learner.__name__ == "ScoringSheetLearner": + continue + with self.subTest(learner.__name__): + learner = learner() + for ds in datasets: + pca_ds = Orange.projection.PCA()(ds)(ds) + # Skip learners that are incompatible with the dataset + if learner.incompatibility_reason(pca_ds.domain): + continue + model = learner(pca_ds) + s = pickle.dumps(model, 0) + model2 = pickle.loads(s) + + np.testing.assert_almost_equal( + Table.from_table(model.domain, ds).X, + Table.from_table(model2.domain, ds).X) + np.testing.assert_almost_equal( + model(ds), model2(ds), + err_msg='%s does not return same values when unpickled %s' + % (learner.__class__.__name__, ds.name)) + def test_adequacy_all_learners(self): for learner in all_learners(): - # calibration, threshold learners' __init__ requires arguments - if learner in (ThresholdLearner, CalibratedLearner): + if learner in LEARNERS_WITH_ARGUMENTS: continue with self.subTest(learner.__name__): learner = learner() @@ -431,8 +494,7 @@ def test_adequacy_all_learners(self): def test_adequacy_all_learners_multiclass(self): for learner in all_learners(): - # calibration, threshold learners' __init__ require arguments - if learner in (ThresholdLearner, CalibratedLearner): + if learner in LEARNERS_WITH_ARGUMENTS: continue with self.subTest(learner.__name__): learner = learner() diff --git a/Orange/tests/test_clustering_dbscan.py b/Orange/tests/test_clustering_dbscan.py index 3286f5a714d..714ca000838 100644 --- a/Orange/tests/test_clustering_dbscan.py +++ b/Orange/tests/test_clustering_dbscan.py @@ -42,13 +42,15 @@ def test_predict_numpy(self): self.assertEqual(len(self.iris), len(model.labels)) def test_predict_sparse_csc(self): - self.iris.X = csc_matrix(self.iris.X[::20]) + with self.iris.unlocked(): + self.iris.X = csc_matrix(self.iris.X[::20]) c = self.dbscan(self.iris) self.assertEqual(np.ndarray, type(c)) self.assertEqual(len(self.iris), len(c)) def test_predict_spares_csr(self): - self.iris.X = csr_matrix(self.iris.X[::20]) + with self.iris.unlocked(): + self.iris.X = csr_matrix(self.iris.X[::20]) c = self.dbscan(self.iris) self.assertEqual(np.ndarray, type(c)) self.assertEqual(len(self.iris), len(c)) diff --git a/Orange/tests/test_clustering_kmeans.py b/Orange/tests/test_clustering_kmeans.py index 7ff40d94992..fe92a1d7cef 100644 --- a/Orange/tests/test_clustering_kmeans.py +++ b/Orange/tests/test_clustering_kmeans.py @@ -44,13 +44,15 @@ def test_predict_numpy(self): self.assertEqual(len(self.iris), len(c.labels)) def test_predict_sparse_csc(self): - self.iris.X = csc_matrix(self.iris.X[::20]) + with self.iris.unlocked(): + self.iris.X = csc_matrix(self.iris.X[::20]) c = self.kmeans(self.iris) self.assertEqual(np.ndarray, type(c)) self.assertEqual(len(self.iris), len(c)) def test_predict_spares_csr(self): - self.iris.X = csr_matrix(self.iris.X[::20]) + with self.iris.unlocked(): + self.iris.X = csr_matrix(self.iris.X[::20]) c = self.kmeans(self.iris) self.assertEqual(np.ndarray, type(c)) self.assertEqual(len(self.iris), len(c)) @@ -136,6 +138,27 @@ def test_model_data_table_domain(self): # totally different domain - should fail self.assertRaises(DomainTransformationError, c, Table("housing")) + def test_model_eq_hash(self): + kmeans = KMeans(n_clusters=2, max_iter=10, random_state=42) + d = self.iris + k1 = kmeans.get_model(d) + k2 = kmeans.get_model(d) + + # results are the same + c1, c2 = k1(d), k2(d) + np.testing.assert_equal(c1, c2) + + # transformations are not because .projector is a different object + self.assertNotEqual(k1, k2) + self.assertNotEqual(k1.projector, k2.projector) + self.assertNotEqual(hash(k1), hash(k2)) + self.assertNotEqual(hash(k1.projector), hash(k2.projector)) + + # if projector was hacket to be the same, they match + k1.projector = k2.projector + self.assertEqual(k1, k2) + self.assertEqual(hash(k1), hash(k2)) + def test_deprecated_silhouette(self): with warnings.catch_warnings(record=True) as w: KMeans(compute_silhouette_score=True) diff --git a/Orange/tests/test_clustering_louvain.py b/Orange/tests/test_clustering_louvain.py index a65ba4a8edf..7c6f3dd6b1c 100644 --- a/Orange/tests/test_clustering_louvain.py +++ b/Orange/tests/test_clustering_louvain.py @@ -44,13 +44,15 @@ def test_predict_numpy(self): self.assertEqual(len(self.iris), len(c.labels)) def test_predict_sparse_csc(self): - self.iris.X = csc_matrix(self.iris.X[::5]) + with self.iris.unlocked(): + self.iris.X = csc_matrix(self.iris.X[::5]) c = self.louvain(self.iris) self.assertEqual(np.ndarray, type(c)) self.assertEqual(len(self.iris), len(c)) - def test_predict_spares_csr(self): - self.iris.X = csr_matrix(self.iris.X[::5]) + def test_predict_sparse_csr(self): + with self.iris.unlocked(): + self.iris.X = csr_matrix(self.iris.X[::5]) c = self.louvain(self.iris) self.assertEqual(np.ndarray, type(c)) self.assertEqual(len(self.iris), len(c)) diff --git a/Orange/tests/test_contingency.py b/Orange/tests/test_contingency.py index f866de16899..7bef15aefbd 100644 --- a/Orange/tests/test_contingency.py +++ b/Orange/tests/test_contingency.py @@ -1,6 +1,6 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring - +import copy import unittest from unittest.mock import Mock @@ -48,8 +48,9 @@ def test_discrete(self): def test_discrete_missing(self): d = data.Table("zoo") - d.Y[25] = float("nan") - d[0][0] = float("nan") + with d.unlocked(): + d.Y[25] = float("nan") + d[0][0] = float("nan") cont = contingency.Discrete(d, 0) assert_dist_equal(cont["amphibian"], [3, 0]) assert_dist_equal(cont, [[3, 0], [20, 0], [13, 0], [4, 4], @@ -60,8 +61,9 @@ def test_discrete_missing(self): [1, 0]) d = data.Table("zoo") - d.Y[2] = float("nan") - d[2]["predator"] = float("nan") + with d.unlocked(): + d.Y[2] = float("nan") + d[2]["predator"] = float("nan") cont = contingency.Discrete(d, "predator") assert_dist_equal(cont["fish"], [4, 8]) assert_dist_equal(cont, [[1, 3], [11, 9], [4, 8], [7, 1], @@ -71,12 +73,20 @@ def test_discrete_missing(self): np.testing.assert_almost_equal(cont.row_unknowns, [0, 0]) self.assertEqual(1, cont.unknowns) + def test_deepcopy(self): + cont = contingency.Discrete(self.zoo, 0) + dc = copy.deepcopy(cont) + self.assertEqual(dc, cont) + self.assertEqual(dc.col_variable, cont.col_variable) + self.assertEqual(dc.row_variable, cont.row_variable) + def test_array_with_unknowns(self): d = data.Table("zoo") - d.Y[2] = float("nan") - d.Y[6] = float("nan") - d[2]["predator"] = float("nan") - d[4]["predator"] = float("nan") + with d.unlocked(): + d.Y[2] = float("nan") + d.Y[6] = float("nan") + d[2]["predator"] = float("nan") + d[4]["predator"] = float("nan") cont = contingency.Discrete(d, "predator") assert_dist_equal(cont.array_with_unknowns, [[1, 3, 0], [11, 9, 0], [4, 8, 0], [7, 1, 0], @@ -84,10 +94,11 @@ def test_array_with_unknowns(self): def test_discrete_with_fallback(self): d = data.Table("zoo") - d.Y[25] = None - d.Y[24] = None - d.X[0, 0] = None - d.X[24, 0] = None + with d.unlocked(): + d.Y[25] = None + d.Y[24] = None + d.X[0, 0] = None + d.X[24, 0] = None default = contingency.Discrete(d, 0) d._compute_contingency = Mock(side_effect=NotImplementedError) @@ -123,7 +134,8 @@ def test_continuous(self): def test_continuous_missing(self): d = data.Table("iris") - d[1][1] = float("nan") + with d.unlocked(): + d[1][1] = float("nan") cont = contingency.Continuous(d, "sepal width") correct = [[2.3, 2.9, 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4], @@ -133,7 +145,8 @@ def test_continuous_missing(self): np.testing.assert_almost_equal(cont["Iris-setosa"], correct) self.assertEqual(cont.unknowns, 0) - d.Y[0] = float("nan") + with d.unlocked(): + d.Y[0] = float("nan") cont = contingency.Continuous(d, "sepal width") correct = [[2.2, 2.5, 2.6, 2.7, 2.8, 2.9, 3.0, 3.1, 3.2, 3.3, 3.4, 3.6, 3.8], [1, 4, 2, 4, 8, 2, 12, 4, 5, 3, 2, 1, 2]] @@ -146,7 +159,8 @@ def test_continuous_missing(self): 0., 0., 0., 0., 0., 0., 0.]) self.assertEqual(cont.unknowns, 0) - d.Y[1] = float("nan") + with d.unlocked(): + d.Y[1] = float("nan") cont = contingency.Continuous(d, "sepal width") np.testing.assert_almost_equal(cont.col_unknowns, [0, 0, 0]) np.testing.assert_almost_equal( @@ -156,7 +170,8 @@ def test_continuous_missing(self): self.assertEqual(cont.unknowns, 1) # this one was failing before since the issue in _contingecy.pyx - d.Y[:50] = np.zeros(50) * float("nan") + with d.unlocked(): + d.Y[:50] = np.zeros(50) * float("nan") cont = contingency.Continuous(d, "sepal width") np.testing.assert_almost_equal(cont.col_unknowns, [0, 0, 0]) np.testing.assert_almost_equal( @@ -171,7 +186,8 @@ def test_continuous_array_with_unknowns(): Test array_with_unknowns function """ d = data.Table("iris") - d.Y[:50] = np.zeros(50) * float("nan") + with d.unlocked(): + d.Y[:50] = np.zeros(50) * float("nan") cont = contingency.Continuous(d, "sepal width") correct_row_unknowns = [0., 0., 1., 0., 0., 0., 0., 0., 1., 6., 5., 5., 2., 9., 6., 2., 3., 4., 2., 1., 1., 1., 1.] @@ -200,8 +216,9 @@ def test_mixedtype_metas(self): cont = contingency.get_contingency(zoo, 2, t.domain.metas[1]) assert_dist_equal(cont["1"], [38, 5]) assert_dist_equal(cont, [[4, 54], [38, 5]]) - zoo[25][t.domain.metas[1]] = float("nan") - zoo[0][2] = float("nan") + with zoo.unlocked(): + zoo[25][t.domain.metas[1]] = float("nan") + zoo[0][2] = float("nan") cont = contingency.get_contingency(zoo, 2, t.domain.metas[1]) assert_dist_equal(cont["1"], [37, 5]) assert_dist_equal(cont, [[4, 53], [37, 5]]) @@ -235,6 +252,7 @@ def _construct_sparse(): 2, 5, 6, 13] indptr = [0, 11, 20, 23, 23, 27] X = sp.csr_matrix((sdata, indices, indptr), shape=(5, 20)) + X.data = X.data.copy() # make it the owner of it's data Y = np.array([[1, 2, 1, 0, 0]]).T return data.Table.from_numpy(domain, X, Y) @@ -255,7 +273,8 @@ def test_sparse(self): assert_dist_equal(cont["b"], [[1], [1]]) assert_dist_equal(cont[2], [[], []]) - d[4].set_class(1) + with d.unlocked(): + d[4].set_class(1) cont = contingency.Continuous(d, 13) assert_dist_equal(cont[0], [[], []]) assert_dist_equal(cont["b"], [[1, 1.1], [1, 1]]) @@ -333,9 +352,10 @@ def test_compute_contingency_row_attribute_sparse(self): Testing with sparse row variable since currently we do not test the situation when a row variable is sparse. """ - d = self.test9 # make X sparse - d.X = csr_matrix(d.X) + d = self.test9.copy() + with d.unlocked(): + d.X = csr_matrix(d.X) var1, var2 = d.domain[0], d.domain[1] cont = contingency.Discrete(d, var1, var2) assert_dist_equal(cont, [[1, 0], [1, 0], [1, 0], [1, 0], @@ -344,7 +364,9 @@ def test_compute_contingency_row_attribute_sparse(self): assert_dist_equal(cont, [[1, 1, 1, 1, 0, 0, 0, 0], [0, 0, 0, 0, 1, 1, 1, 1]]) - d.X = csc_matrix(d.X) + d = self.test9.copy() + with d.unlocked(): + d.X = csc_matrix(d.X) cont = contingency.Discrete(d, var1, var2) assert_dist_equal(cont, [[1, 0], [1, 0], [1, 0], [1, 0], [0, 1], [0, 1], [0, 1], [0, 1]]) @@ -365,7 +387,8 @@ def test_compute_contingency_invalid(self): c = contingency.get_contingency(d, X, C) self.assertEqual(c.counts.shape[0], 1024) - d.Y[5] = 1024 + with d.unlocked(): + d.Y[5] = 1024 with self.assertRaises(IndexError): contingency.get_contingency(d, X, C) diff --git a/Orange/tests/test_data_util.py b/Orange/tests/test_data_util.py index 55a81a1f48a..a9bda49427f 100644 --- a/Orange/tests/test_data_util.py +++ b/Orange/tests/test_data_util.py @@ -1,4 +1,5 @@ import unittest +import warnings from unittest.mock import Mock import numpy as np @@ -72,3 +73,92 @@ def test_single_call(self): #test with descendants of table DummyTable.from_table(c.domain, data) self.assertEqual(obj.compute_shared.call_count, 4) + + def test_compute_shared_eq_warning(self): + with warnings.catch_warnings(record=True) as warns: + DummyPlus(compute_shared=lambda *_: 42) + + class Valid: + def __eq__(self, other): + pass + + def __hash__(self): + pass + + DummyPlus(compute_shared=Valid()) + self.assertEqual(warns, []) + + class Invalid: + pass + + DummyPlus(compute_shared=Invalid()) + self.assertNotEqual(warns, []) + + with warnings.catch_warnings(record=True) as warns: + + class MissingHash: + def __eq__(self, other): + pass + + DummyPlus(compute_shared=MissingHash()) + self.assertNotEqual(warns, []) + + with warnings.catch_warnings(record=True) as warns: + + class MissingEq: + def __hash__(self): + pass + + DummyPlus(compute_shared=MissingEq()) + self.assertNotEqual(warns, []) + + with warnings.catch_warnings(record=True) as warns: + + class Subclass(Valid): + pass + + DummyPlus(compute_shared=Subclass()) + self.assertNotEqual(warns, []) + + def test_eq_hash(self): + x = Orange.data.ContinuousVariable("x") + y = Orange.data.ContinuousVariable("y") + x2 = Orange.data.ContinuousVariable("x") + assert x == x2 + assert hash(x) == hash(x2) + assert x != y + assert hash(x) != hash(y) + + c1 = SharedComputeValue(abs, x) + c2 = SharedComputeValue(abs, x2) + + d = SharedComputeValue(abs, y) + e = SharedComputeValue(len, x) + + self.assertNotEqual(c1, None) + + self.assertEqual(c1, c2) + self.assertEqual(hash(c1), hash(c2)) + + self.assertNotEqual(c1, d) + self.assertNotEqual(hash(c1), hash(d)) + + self.assertNotEqual(c1, e) + self.assertNotEqual(hash(c1), hash(e)) + + def test_eq_hash_inheritance(self): + class NoFlag: + pass + + class WithFlag: + InheritEq = True + + x = Orange.data.ContinuousVariable("x") + self.assertWarnsRegex( + UserWarning, ".*define __eq__ and __hash__.*", + SharedComputeValue, NoFlag(), x) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + SharedComputeValue(WithFlag(), x) + self.assertEqual(len(w), 0) diff --git a/Orange/tests/test_discretize.py b/Orange/tests/test_discretize.py index 119ac8c86dd..480cf179e6c 100644 --- a/Orange/tests/test_discretize.py +++ b/Orange/tests/test_discretize.py @@ -229,7 +229,8 @@ def test_transform(self): def test_remove_constant(self): table = data.Table('iris') - table[:, 0] = 1 + with table.unlocked(): + table[:, 0] = 1 discretize = Discretize(remove_const=True) new_table = discretize(table) self.assertNotEqual(len(table.domain.attributes), @@ -237,7 +238,8 @@ def test_remove_constant(self): def test_keep_constant(self): table = data.Table('iris') - table[:, 0] = 1 + with table.unlocked(): + table[:, 0] = 1 discretize = Discretize(remove_const=False) new_table = discretize(table) self.assertEqual(len(table.domain.attributes), diff --git a/Orange/tests/test_distances.py b/Orange/tests/test_distances.py index 57695cad1db..c8820bc5102 100644 --- a/Orange/tests/test_distances.py +++ b/Orange/tests/test_distances.py @@ -128,9 +128,9 @@ def assertErrorMsg(content, msg): assertErrorMsg("axis=1\n1\t3\n4", "distance file must begin with dimension") assertErrorMsg("3 col_labels\na\tb\n1\n\2\n3", - "mismatching number of column labels") + "mismatching number of column labels, 2 != 3") assertErrorMsg("3 col_labels\na\tb\tc\td\n1\n\2\n3", - "mismatching number of column labels") + "mismatching number of column labels, 4 != 3") assertErrorMsg("2\n 1\t2\t3\n 5", "too many columns in matrix row 1") assertErrorMsg("2 row_labels\na\t1\t2\t3\nb\t5", @@ -199,6 +199,16 @@ def test_numpy_type(self): with self.assertRaises(AssertionError): np.testing.assert_array_equal(dm1, dm2) + def test_symmetric(self): + self.assertFalse( + DistMatrix([[1, 2, 3], [4, 5, 6]]).is_symmetric() + ) + self.assertFalse( + DistMatrix([[1, 2], [4, 5]]).is_symmetric() + ) + self.assertTrue( + DistMatrix([[1, 2, 3], [2, 0, 4], [3, 4, 5]]).is_symmetric() + ) # noinspection PyTypeChecker class TestEuclidean(TestCase): @@ -576,6 +586,12 @@ def test_spearmanr_distance_many_examples(self): [0.50833333, 0., 0.38333333, 0.53333333], [0.075, 0.38333333, 0., 0.63333333], [0.61666667, 0.53333333, 0.63333333, 0.]])) + np.testing.assert_almost_equal( + self.dist(self.breast[:4], similarity=True), + 1 - 2 * np.array([[0., 0.50833333, 0.075, 0.61666667], + [0.50833333, 0., 0.38333333, 0.53333333], + [0.075, 0.38333333, 0., 0.63333333], + [0.61666667, 0.53333333, 0.63333333, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[:3], axis=0), np.array([[0., 0.25, 0., 0.25, 0.25, 0.25, 0.75, 0.25, 0.25], @@ -666,59 +682,63 @@ def test_spearmanrabsolute_distance_one_example(self): np.array([[0]])) np.testing.assert_almost_equal( self.dist(self.breast[0], self.breast[1]), - np.array([[0.49166666666666664]])) + 2 * np.array([[0.49166666666666664]])) np.testing.assert_almost_equal( self.dist(self.breast[0], self.breast[1], axis=1), - np.array([[0.49166666666666664]])) + 2 * np.array([[0.49166666666666664]])) def test_spearmanrabsolute_distance_many_examples(self): np.testing.assert_almost_equal( self.dist(self.breast[:2]), - np.array([[0., 0.49166667], - [0.49166667, 0.]])) + 2 * np.array([[0., 0.49166667], + [0.49166667, 0.]])) + np.testing.assert_almost_equal( + self.dist(self.breast[:2], similarity=True), + 1 - 2 * np.array([[0., 0.49166667], + [0.49166667, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[:3], axis=0), - np.array([[0., 0.25, 0., 0.25, 0.25, 0.25, 0.25, 0.25, 0.25], - [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], - [0., 0.25, 0., 0.25, 0.25, 0.25, 0.25, 0.25, 0.25], - [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], - [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], - [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], - [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0., 0.25, 0.], - [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], - [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0., 0.25, 0.]])) + 2 * np.array([[0., 0.25, 0., 0.25, 0.25, 0.25, 0.25, 0.25, 0.25], + [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], + [0., 0.25, 0., 0.25, 0.25, 0.25, 0.25, 0.25, 0.25], + [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], + [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], + [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], + [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0., 0.25, 0.], + [0.25, 0., 0.25, 0., 0., 0., 0.25, 0., 0.25], + [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0., 0.25, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[:3], self.breast[:4]), - np.array([[0., 0.49166667, 0.075, 0.38333333], - [0.49166667, 0., 0.38333333, 0.46666667], - [0.075, 0.38333333, 0., 0.36666667]])) + 2 * np.array([[0., 0.49166667, 0.075, 0.38333333], + [0.49166667, 0., 0.38333333, 0.46666667], + [0.075, 0.38333333, 0., 0.36666667]])) np.testing.assert_almost_equal( self.dist(self.breast[3], self.breast[:4]), - np.array([[0.3833333, 0.4666667, 0.3666667, 0.]])) + 2 * np.array([[0.3833333, 0.4666667, 0.3666667, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[:4], self.breast[3]), - np.array([[0.3833333], - [0.4666667], - [0.3666667], - [0.]])) + 2 * np.array([[0.3833333], + [0.4666667], + [0.3666667], + [0.]])) def test_spearmanrabsolute_distance_numpy(self): np.testing.assert_almost_equal( self.dist(self.breast[0].x, self.breast[1].x, axis=1), - np.array([[0.49166666666666664]])) + 2 * np.array([[0.49166666666666664]])) np.testing.assert_almost_equal( self.dist(self.breast[:2].X), - np.array([[0., 0.49166667], + 2 * np.array([[0., 0.49166667], [0.49166667, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[3].x, self.breast[:4].X), - np.array([[0.3833333, 0.4666667, 0.3666667, 0.]])) + 2 * np.array([[0.3833333, 0.4666667, 0.3666667, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[:4].X, self.breast[3].x), - np.array([[0.3833333], - [0.4666667], - [0.3666667], - [0.]])) + 2 * np.array([[0.3833333], + [0.4666667], + [0.3666667], + [0.]])) # noinspection PyTypeChecker @@ -754,6 +774,10 @@ def test_pearsonr_distance_many_examples(self): self.dist(self.breast[:2]), np.array([[0., 0.48462294], [0.48462294, 0.]])) + np.testing.assert_almost_equal( + self.dist(self.breast[:2], similarity=True), + 1 - 2 * np.array([[0., 0.48462294], + [0.48462294, 0.]])) # pylint: disable=line-too-long # Because it looks better np.testing.assert_almost_equal( @@ -848,57 +872,61 @@ def test_pearsonrabsolute_distance_one_example(self): np.array([[0]])) np.testing.assert_almost_equal( self.dist(self.breast[0], self.breast[1]), - np.array([[0.48462293898088876]])) + 2 * np.array([[0.48462293898088876]])) np.testing.assert_almost_equal( self.dist(self.breast[0], self.breast[1], axis=1), - np.array([[0.48462293898088876]])) + 2 * np.array([[0.48462293898088876]])) def test_pearsonrabsolute_distance_many_examples(self): np.testing.assert_almost_equal( self.dist(self.breast[:2]), - np.array([[0., 0.48462294], - [0.48462294, 0.]])) + np.array([[0., 0.9692459], + [0.9692459, 0.]])) + np.testing.assert_almost_equal( + self.dist(self.breast[:2], similarity=True), + 1 - np.array([[0., 0.9692459], + [0.9692459, 0.]])) # pylint: disable=line-too-long # Because it looks better np.testing.assert_almost_equal( self.dist(self.breast[:20], axis=0), - np.array([[0., 0.10239274, 0.12786763, 0.13435117, 0.15580385, 0.27429811, 0.21006195, 0.24072005, 0.42847752], - [0.10239274, 0., 0.01695375, 0.10313851, 0.1138925, 0.16978203, 0.1155948, 0.08043531, 0.43326547], - [0.12786763, 0.01695375, 0., 0.16049178, 0.13692762, 0.21784201, 0.11607395, 0.06493949, 0.46590168], - [0.13435117, 0.10313851, 0.16049178, 0., 0.07181648, 0.15585667, 0.13891172, 0.21622332, 0.37404826], - [0.15580385, 0.1138925, 0.13692762, 0.07181648, 0., 0.16301705, 0.17324382, 0.21452448, 0.42283252], - [0.27429811, 0.16978203, 0.21784201, 0.15585667, 0.16301705, 0., 0.25512861, 0.29560909, 0.42766076], - [0.21006195, 0.1155948, 0.11607395, 0.13891172, 0.17324382, 0.25512861, 0., 0.14419442, 0.42023881], - [0.24072005, 0.08043531, 0.06493949, 0.21622332, 0.21452448, 0.29560909, 0.14419442, 0., 0.45930368], - [0.42847752, 0.43326547, 0.46590168, 0.37404826, 0.42283252, 0.42766076, 0.42023881, 0.45930368, 0.]])) + 2 * np.array([[0., 0.10239274, 0.12786763, 0.13435117, 0.15580385, 0.27429811, 0.21006195, 0.24072005, 0.42847752], + [0.10239274, 0., 0.01695375, 0.10313851, 0.1138925, 0.16978203, 0.1155948, 0.08043531, 0.43326547], + [0.12786763, 0.01695375, 0., 0.16049178, 0.13692762, 0.21784201, 0.11607395, 0.06493949, 0.46590168], + [0.13435117, 0.10313851, 0.16049178, 0., 0.07181648, 0.15585667, 0.13891172, 0.21622332, 0.37404826], + [0.15580385, 0.1138925, 0.13692762, 0.07181648, 0., 0.16301705, 0.17324382, 0.21452448, 0.42283252], + [0.27429811, 0.16978203, 0.21784201, 0.15585667, 0.16301705, 0., 0.25512861, 0.29560909, 0.42766076], + [0.21006195, 0.1155948, 0.11607395, 0.13891172, 0.17324382, 0.25512861, 0., 0.14419442, 0.42023881], + [0.24072005, 0.08043531, 0.06493949, 0.21622332, 0.21452448, 0.29560909, 0.14419442, 0., 0.45930368], + [0.42847752, 0.43326547, 0.46590168, 0.37404826, 0.42283252, 0.42766076, 0.42023881, 0.45930368, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[:3], self.breast[:4]), - np.array([[0., 0.48462294, 0.10133593, 0.4983256], - [0.48462294, 0., 0.32783865, 0.42682613], - [0.10133593, 0.32783865, 0., 0.36210365]])) + 2 * np.array([[0., 0.48462294, 0.10133593, 0.4983256], + [0.48462294, 0., 0.32783865, 0.42682613], + [0.10133593, 0.32783865, 0., 0.36210365]])) np.testing.assert_almost_equal( self.dist(self.breast[2], self.breast[:3]), - np.array([[0.10133593, 0.32783865, 0.]])) + 2 * np.array([[0.10133593, 0.32783865, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[:2], self.breast[3]), - np.array([[0.4983256], - [0.42682613]])) + 2 * np.array([[0.4983256], + [0.42682613]])) def test_pearsonrabsolute_distance_numpy(self): np.testing.assert_almost_equal( self.dist(self.breast[0].x, self.breast[1].x, axis=1), - np.array([[0.48462293898088876]])) + 2 * np.array([[0.48462293898088876]])) np.testing.assert_almost_equal( self.dist(self.breast[:2].X), - np.array([[0., 0.48462294], - [0.48462294, 0.]])) + 2 * np.array([[0., 0.48462294], + [0.48462294, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[2].x, self.breast[:3].X), - np.array([[0.10133593, 0.32783865, 0.]])) + 2 * np.array([[0.10133593, 0.32783865, 0.]])) np.testing.assert_almost_equal( self.dist(self.breast[:2].X, self.breast[3].x), - np.array([[0.4983256], - [0.42682613]])) + 2 * np.array([[0.4983256], + [0.42682613]])) # noinspection PyTypeChecker diff --git a/Orange/tests/test_distribution.py b/Orange/tests/test_distribution.py index 1242999dc37..e271276f3c1 100644 --- a/Orange/tests/test_distribution.py +++ b/Orange/tests/test_distribution.py @@ -1,7 +1,8 @@ # Test methods with long descriptive names can omit docstrings # Test internal methods # pylint: disable=missing-docstring, protected-access - +import copy +import pickle import unittest from unittest.mock import Mock import warnings @@ -99,8 +100,9 @@ def test_fallback(self): def test_fallback_with_weights_and_nan(self): d = data.Table("zoo") - d.set_weights(np.random.uniform(0., 1., size=len(d))) - d.Y[::10] = np.nan + with d.unlocked(): + d.set_weights(np.random.uniform(0., 1., size=len(d))) + d.Y[::10] = np.nan default = distribution.Discrete(d, "type") d._compute_distributions = Mock(side_effect=NotImplementedError) @@ -110,6 +112,32 @@ def test_fallback_with_weights_and_nan(self): np.asarray(fallback), np.asarray(default)) np.testing.assert_almost_equal(fallback.unknowns, default.unknowns) + def test_pickle(self): + d = data.Table("zoo") + d1 = distribution.Discrete(d, 0) + dc = pickle.loads(pickle.dumps(d1)) + # This always worked because `other` wasn't required to have `unknowns` + self.assertEqual(d1, dc) + # This failed before implementing `__reduce__` + self.assertEqual(dc, d1) + self.assertEqual(hash(d1), hash(dc)) + # Test that `dc` has the required attributes + self.assertEqual(dc.variable, d1.variable) + self.assertEqual(dc.unknowns, d1.unknowns) + + def test_deepcopy(self): + d = data.Table("zoo") + d1 = distribution.Discrete(d, 0) + dc = copy.deepcopy(d1) + # This always worked because `other` wasn't required to have `unknowns` + self.assertEqual(d1, dc) + # This failed before implementing `__deepcopy__` + self.assertEqual(dc, d1) + self.assertEqual(hash(d1), hash(dc)) + # Test that `dc` has the required attributes + self.assertEqual(dc.variable, d1.variable) + self.assertEqual(dc.unknowns, d1.unknowns) + def test_equality(self): d = data.Table("zoo") d1 = distribution.Discrete(d, 0) @@ -206,7 +234,8 @@ def test_min_max(self): def test_array_with_unknowns(self): d = data.Table("zoo") - d.Y[0] = np.nan + with d.unlocked(): + d.Y[0] = np.nan disc = distribution.Discrete(d, "type") self.assertIsInstance(disc, np.ndarray) self.assertEqual(disc.unknowns, 1) @@ -285,6 +314,30 @@ def test_construction(self): self.assertEqual(disc2.unknowns, 0) assert_dist_equal(disc2, dd) + def test_pickle(self): + d1 = distribution.Continuous(self.iris, 0) + dc = pickle.loads(pickle.dumps(d1)) + # This always worked because `other` wasn't required to have `unknowns` + self.assertEqual(d1, dc) + # This failed before implementing `__reduce__` + self.assertEqual(dc, d1) + self.assertEqual(hash(d1), hash(dc)) + # Test that `dc` has the required attributes + self.assertEqual(dc.variable, d1.variable) + self.assertEqual(dc.unknowns, d1.unknowns) + + def test_deepcopy(self): + d1 = distribution.Continuous(self.iris, 0) + dc = copy.deepcopy(d1) + # This always worked because `other` wasn't required to have `unknowns` + self.assertEqual(d1, dc) + # This failed before implementing `__deepcopy__` + self.assertEqual(dc, d1) + self.assertEqual(hash(d1), hash(dc)) + # Test that `dc` has the required attributes + self.assertEqual(dc.variable, d1.variable) + self.assertEqual(dc.unknowns, d1.unknowns) + def test_hash(self): d = self.iris petal_length = d.columns.petal_length @@ -473,7 +526,8 @@ def assert_dist_and_unknowns(computed, goal_dist): assert_dist_and_unknowns(ddist[18], [[0, 2], [4, 1]]) assert_dist_and_unknowns(ddist[19], zeros) - d.set_weights(np.array([1, 2, 3, 4, 5])) + with d.unlocked(): + d.set_weights(np.array([1, 2, 3, 4, 5])) ddist = distribution.get_distributions(d) self.assertEqual(len(ddist), 20) @@ -508,7 +562,9 @@ def test_compute_distributions_metas(self): # repeat with nan values assert d.metas.dtype.kind == "O" assert d.metas[0, 1] == 0 - d.metas[0, 1] = np.nan + + with d.unlocked(): + d.metas[0, 1] = np.nan dist, nanc = d._compute_distributions([variable])[0] assert_dist_equal(dist, [2, 3, 2]) self.assertEqual(nanc, 1) diff --git a/Orange/tests/test_doctest.py b/Orange/tests/test_doctest.py index 624291ef968..87fe234d00a 100644 --- a/Orange/tests/test_doctest.py +++ b/Orange/tests/test_doctest.py @@ -1,10 +1,6 @@ import sys import os -import unittest from doctest import DocTestSuite, ELLIPSIS, NORMALIZE_WHITESPACE -from distutils.version import LooseVersion - -import numpy SKIP_DIRS = ( # Skip modules which import and initialize stuff that require QApplication @@ -55,24 +51,12 @@ def clear(self): def suite(package): """Assemble test suite for doctests in path (recursively)""" from importlib import import_module - # numpy 1.14 changed array str/repr (NORMALIZE_WHITESPACE does not - # handle this). When 1.15 is released update all docstrings and skip the - # tests for < 1.14. - npversion = LooseVersion(numpy.__version__) - if npversion >= LooseVersion("1.14"): - def setUp(test): - raise unittest.SkipTest("Skip doctest on numpy >= 1.14.0") - else: - def setUp(test): - pass - for module in find_modules(package.__file__): try: module = import_module(module) yield DocTestSuite(module, globs=Context(module.__dict__.copy()), - optionflags=ELLIPSIS | NORMALIZE_WHITESPACE, - setUp=setUp) + optionflags=ELLIPSIS | NORMALIZE_WHITESPACE) except ValueError: pass # No doctests in module except ImportError: diff --git a/Orange/tests/test_domain.py b/Orange/tests/test_domain.py index db8f846dedd..1c79c0d2a8d 100644 --- a/Orange/tests/test_domain.py +++ b/Orange/tests/test_domain.py @@ -1,6 +1,5 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring -import warnings from time import time from numbers import Real from itertools import starmap, chain @@ -15,7 +14,6 @@ from Orange.data.domain import filter_visible from Orange.preprocess import Continuize, Impute from Orange.tests.base import create_pickling_tests -from Orange.util import OrangeDeprecationWarning def create_domain(*ss): @@ -249,7 +247,7 @@ def test_get_item_error(self): def test_index_error(self): d = Domain((age, gender, income), metas=(ssn, race)) - for idx in (3, np.int(3), -3, np.int(-3), incomeA, "no_such_thing"): + for idx in (3, np.int64(3), -3, np.int32(-3), incomeA, "no_such_thing"): with self.assertRaises(ValueError): d.index(idx) @@ -272,21 +270,14 @@ def test_contains(self): [] in d def test_iter(self): - with warnings.catch_warnings(record=True): - warnings.simplefilter("error") - - d = Domain((age, gender, income), metas=(ssn,)) - with self.assertRaises(OrangeDeprecationWarning): - list(d) - - warnings.simplefilter("ignore") - self.assertEqual([var for var in d], [age, gender, income]) + d = Domain((age, gender, income), metas=(ssn,)) + self.assertEqual(list(d), [age, gender, income, ssn]) - d = Domain((age, ), metas=(ssn,)) - self.assertEqual([var for var in d], [age]) + d = Domain((age, ), metas=(ssn,)) + self.assertEqual(list(d), [age, ssn]) - d = Domain((), metas=(ssn,)) - self.assertEqual([var for var in d], []) + d = Domain((), metas=(ssn,)) + self.assertEqual(list(d), [ssn]) def test_str(self): cases = ( @@ -439,7 +430,7 @@ def test_preprocessor_chaining(self): domain = Domain([DiscreteVariable("a", values="01"), DiscreteVariable("b", values="01")], DiscreteVariable("y", values="01")) - table = Table.from_list(domain, [[0, 1], [1, np.NaN]], [0, 1]) + table = Table.from_list(domain, [[0, 1], [1, np.nan]], [0, 1]) pre1 = Continuize()(Impute()(table)) pre2 = table.transform(pre1.domain) np.testing.assert_almost_equal(pre1.X, pre2.X) @@ -450,24 +441,74 @@ def test_different_domains_with_same_attributes_are_equal(self): self.assertEqual(domain1, domain2) var1 = ContinuousVariable('var1') - domain1.attributes = (var1,) + domain1 = Domain([var1]) self.assertNotEqual(domain1, domain2) - domain2.attributes = (var1,) + domain2 = Domain([var1]) self.assertEqual(domain1, domain2) - domain1.class_vars = (var1,) + var2 = ContinuousVariable('var2') + domain1 = Domain([var1], [var2]) self.assertNotEqual(domain1, domain2) - domain2.class_vars = (var1,) + domain2 = Domain([var1], [var2]) self.assertEqual(domain1, domain2) - domain1._metas = (var1,) + var3 = ContinuousVariable('var3') + domain1 = Domain([var1], [var2], [var3]) self.assertNotEqual(domain1, domain2) - domain2._metas = (var1,) + domain2 = Domain([var1], [var2], [var3]) self.assertEqual(domain1, domain2) + def test_eq_cached(self): + + class ComputeValueEqOnce: + calls = 0 + + def __eq__(self, other): + if self.calls > 0: + raise RuntimeError() + self.calls += 1 + return type(self) is type(other) + + def __hash__(self): + return hash(type(self)) + + var1 = ContinuousVariable('var1', compute_value=ComputeValueEqOnce()) + var1a = ContinuousVariable('var1', compute_value=ComputeValueEqOnce()) + domain1 = Domain([var1]) + domain2 = Domain([var1a]) + self.assertTrue(domain1 == domain2) + + # the second call would crash if __eq__ was not cached + self.assertTrue(domain1 == domain2) + + # modify the cache, see if that has an effect + domain1._eq_cache[(domain2,)] = False # pylint: disable=protected-access + self.assertFalse(domain1 == domain2) + + def test_eq_cache_not_grow(self): + var = ContinuousVariable('var') + domain = Domain([var]) + domains = [Domain([var]) for _ in range(10)] + for d in domains: + self.assertTrue(domain == d) + + # pylint: disable=protected-access,pointless-statement + + # __eq__ results to all ten domains should be cached + for d in domains: + domain._eq_cache[(d,)] + + dn = Domain([var]) + self.assertTrue(domain == dn) + # the last compared domain should be cached + domain._eq_cache[(dn,)] + # but the first compared should be lost in cache + with self.assertRaises(KeyError): + domain._eq_cache[(domains[0],)] + def test_domain_conversion_is_fast_enough(self): attrs = [ContinuousVariable("f%i" % i) for i in range(10000)] class_vars = [ContinuousVariable("c%i" % i) for i in range(10)] @@ -548,6 +589,7 @@ def test_get_item_similar_vars(self): metas=[var1, var2] ) # pylint: disable=protected-access + domain._ensure_indices() self.assertDictEqual( {-1: -1, -2: -2, var1: -1, var2: -2, var1.name: -1, var2.name: -2}, domain._indices diff --git a/Orange/tests/test_evaluation_scoring.py b/Orange/tests/test_evaluation_scoring.py index bd89504a747..64d1ed18a20 100644 --- a/Orange/tests/test_evaluation_scoring.py +++ b/Orange/tests/test_evaluation_scoring.py @@ -2,16 +2,19 @@ # pylint: disable=missing-docstring import unittest + import numpy as np from Orange.data import DiscreteVariable, ContinuousVariable, Domain from Orange.data import Table -from Orange.classification import LogisticRegressionLearner, SklTreeLearner, NaiveBayesLearner,\ - MajorityLearner +from Orange.classification import LogisticRegressionLearner, SklTreeLearner, \ + NaiveBayesLearner, MajorityLearner, RandomForestLearner from Orange.evaluation import AUC, CA, Results, Recall, \ - Precision, TestOnTrainingData, scoring, LogLoss, F1, CrossValidation + Precision, TestOnTrainingData, scoring, LogLoss, F1, CrossValidation, \ + MatthewsCorrCoefficient, TestOnTestData from Orange.evaluation.scoring import Specificity from Orange.preprocess import discretize, Discretize +from Orange.regression import MeanLearner from Orange.tests import test_filename @@ -242,7 +245,7 @@ def test_call(self): def test_bayes(self): x = np.random.randint(2, size=(100, 5)) col = np.random.randint(5) - y = x[:, col].copy().reshape(100, 1) + y = x[:, col].reshape(100, 1).copy() t = Table.from_numpy(None, x, y) t = Discretize( method=discretize.EqualWidth(n=3))(t) @@ -250,7 +253,9 @@ def test_bayes(self): res = TestOnTrainingData()(t, [nb]) np.testing.assert_almost_equal(CA(res), [1]) - t.Y[-20:] = 1 - t.Y[-20:] + t = Table.from_numpy(None, t.X, t.Y.copy()) + with t.unlocked(): + t.Y[-20:] = 1 - t.Y[-20:] res = TestOnTrainingData()(t, [nb]) self.assertGreaterEqual(CA(res)[0], 0.75) self.assertLess(CA(res)[0], 1) @@ -315,20 +320,6 @@ def compute_auc(self, actual, predicted): return AUC(results)[0] -class TestComputeCD(unittest.TestCase): - def test_compute_CD(self): - avranks = [1.9, 3.2, 2.8, 3.3] - cd = scoring.compute_CD(avranks, 30) - np.testing.assert_almost_equal(cd, 0.856344) - - cd = scoring.compute_CD(avranks, 30, test="bonferroni-dunn") - np.testing.assert_almost_equal(cd, 0.798) - - # Do what you will, just don't crash - scoring.graph_ranks(avranks, "abcd", cd) - scoring.graph_ranks(avranks, "abcd", cd, cdmethod=0) - - class TestLogLoss(unittest.TestCase): def test_log_loss(self): data = Table('iris') @@ -357,6 +348,48 @@ def test_log_loss_calc(self): self.assertAlmostEqual(ll_calc, ll_orange[0]) +class TestMatthewsCorrCoefficient(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.heart = Table("heart_disease") + cls.iris = Table("iris") + cls.housing = Table("housing") + cls.scorer = MatthewsCorrCoefficient() + + def test_mcc_binary(self): + rf = RandomForestLearner(random_state=0) + results = TestOnTrainingData()(self.heart, [rf]) + mcc = self.scorer(results) + self.assertGreater(mcc, 0.95) + + def test_mcc_multiclass(self): + rf = RandomForestLearner(random_state=0) + results = TestOnTrainingData()(self.iris, [rf]) + mcc = self.scorer(results) + self.assertGreater(mcc, 0.95) + + def test_mcc_random(self): + majority = MajorityLearner() + results = TestOnTrainingData()(self.iris, [majority]) + mcc = self.scorer(results) + self.assertEqual(mcc, 0) + + def test_mcc_neg(self): + rf = RandomForestLearner(random_state=0) + test_data = self.heart.copy() + mask = test_data.Y == 0 + test_data.Y[mask] = 1 + test_data.Y[~mask] = 0 + results = TestOnTestData()(self.heart, test_data, [rf]) + mcc = self.scorer(results) + self.assertLess(mcc, -0.95) + + def test_mcc_continuous(self): + majority = MeanLearner() + results = TestOnTrainingData()(self.housing, [majority]) + self.assertRaises(ValueError, self.scorer, results) + + class TestSpecificity(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/Orange/tests/test_evaluation_testing.py b/Orange/tests/test_evaluation_testing.py index 561be10c6aa..e2c598198a2 100644 --- a/Orange/tests/test_evaluation_testing.py +++ b/Orange/tests/test_evaluation_testing.py @@ -10,9 +10,10 @@ from Orange.evaluation.testing import Validation from Orange.regression import LinearRegressionLearner, MeanLearner from Orange.data import Table, Domain, DiscreteVariable -from Orange.evaluation import (Results, CrossValidation, LeaveOneOut, TestOnTrainingData, +from Orange.evaluation import (Results, CrossValidation, LeaveOneOut, + TestOnTrainingData, TestOnTestData, ShuffleSplit, sample, RMSE, - CrossValidationFeature) + CrossValidationFeature, MAPE) from Orange.preprocess import discretize, preprocess @@ -46,7 +47,9 @@ def setUpClass(cls): cls.iris = Table('iris') cls.nrows = 200 cls.ncols = 5 - cls.random_table = random_data(cls.nrows, cls.ncols) + + def setUp(self): + self.random_table = random_data(self.nrows, self.ncols) def run_test_failed(self, method, succ_calls): # Can't use mocking helpers here (wrong result type for Majority, @@ -189,6 +192,11 @@ def test_continuous(self): self.housing, [LinearRegressionLearner()]) self.assertLess(RMSE(res), 5) + def test_mape_percentage(self): + res = CrossValidation(k=3)( + self.housing, [LinearRegressionLearner()]) + self.assertGreater(MAPE(res), 1) + def test_folds(self): res = CrossValidation(k=5)(self.random_table, [NaiveBayesLearner()]) self.check_folds(res, 5, self.nrows) @@ -258,7 +266,8 @@ def test_miss_majority(): res = cv(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0][:49], 0) - x[-4:] = np.zeros((4, 3)) + with data.unlocked(data.X): + x[-4:] = np.zeros((4, 3)) res = cv(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0][:49], 0) @@ -335,7 +344,8 @@ def add_meta_fold(data, f): ndata = data.transform(domain) vals = np.tile(range(f), len(data)//f + 1)[:len(data)] vals = vals.reshape((-1, 1)) - ndata[:, fat] = vals + with ndata.unlocked(ndata.metas): + ndata[:, fat] = vals return ndata def test_init(self): @@ -358,7 +368,8 @@ def test_unknown(self): t = self.random_table t = self.add_meta_fold(t, 3) fat = t.domain.metas[0] - t[0][fat] = float("nan") + with t.unlocked(t.metas): + t[0][fat] = float("nan") res = CrossValidationFeature(feature=fat)(t, [NaiveBayesLearner()]) self.assertNotIn(0, res.row_indices) @@ -440,11 +451,13 @@ def test_miss_majority(): res = LeaveOneOut()(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0][:49], 0) - x[49] = 0 + with data.unlocked(data.X): + x[49] = 0 res = LeaveOneOut()(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0][:49], 0) - x[25:] = 1 + with data.unlocked(data.X): + x[25:] = 1 data = Table.from_numpy(None, x, y) res = LeaveOneOut()(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0], @@ -516,11 +529,13 @@ def test_miss_majority(): res = TestOnTrainingData()(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0][:49], 0) - x[49] = 0 + with data.unlocked(data.X): + x[49] = 0 res = TestOnTrainingData()(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0][:49], 0) - x[25:] = 1 + with data.unlocked(data.X): + x[25:] = 1 data = Table.from_numpy(None, x, y) res = TestOnTrainingData()(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0], res.predicted[0][0]) @@ -604,11 +619,13 @@ def test_miss_majority(): res = TestOnTrainingData()(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0][:49], 0) - x[49] = 0 + with data.unlocked(data.X): + x[49] = 0 res = TestOnTrainingData()(data, [MajorityLearner()]) np.testing.assert_equal(res.predicted[0][:49], 0) - x[25:] = 1 + with data.unlocked(data.X): + x[25:] = 1 y = x[:, -1] data = Table.from_numpy(None, x, y) res = TestOnTrainingData()(data, [MajorityLearner()]) diff --git a/Orange/tests/test_filter.py b/Orange/tests/test_filter.py index 325b4ae6d3a..c032b8d26cf 100644 --- a/Orange/tests/test_filter.py +++ b/Orange/tests/test_filter.py @@ -69,6 +69,27 @@ def test_is_defined_filter_instance(self): self.assertTrue(filter_(instance_with_missing)) self.assertFalse(filter_(instance_without_missing)) + def test_eq_hash(self): + f1 = IsDefined() + f1n = IsDefined(negate=True) + f1nc = IsDefined(negate=True, columns=("a", "b")) + g1 = IsDefined() + g1n = IsDefined(negate=True) + g1nc = IsDefined(negate=True, columns=("a", "b")) + + self.assertEqual(f1, g1) + self.assertEqual(f1n, g1n) + self.assertEqual(f1nc, g1nc) + self.assertNotEqual(f1, None) + self.assertNotEqual(f1, [1, 2, 3]) + self.assertNotEqual(f1, f1n) + self.assertNotEqual(f1, f1nc) + self.assertNotEqual(f1nc, f1n) + + self.assertEqual(hash(f1), hash(g1)) + self.assertEqual(hash(f1n), hash(g1n)) + self.assertEqual(hash(f1nc), hash(g1nc)) + @patch('Orange.data.Table._filter_is_defined', NIMOCK) def test_is_defined_filter_not_implemented(self): self.test_is_defined_filter_table() @@ -356,10 +377,12 @@ def test_operators(self): flt = FilterString("name", FilterString.IsDefined) self.assertTrue(flt(self.inst)) for s in ["?", "nan"]: - self.inst["name"] = s + with self.data.unlocked(): + self.inst["name"] = s flt = FilterString("name", FilterString.IsDefined) self.assertTrue(flt(self.inst)) - self.inst["name"] = "" + with self.data.unlocked(): + self.inst["name"] = "" flt = FilterString("name", FilterString.IsDefined) self.assertFalse(flt(self.inst)) diff --git a/Orange/tests/test_fitter.py b/Orange/tests/test_fitter.py index c3aa03993a5..78a19e26987 100644 --- a/Orange/tests/test_fitter.py +++ b/Orange/tests/test_fitter.py @@ -158,9 +158,11 @@ class DummyFitter(Fitter): def _change_kwargs(self, kwargs, problem_type): if problem_type == self.CLASSIFICATION: - kwargs['param'] = kwargs.get('classification_param') + if 'classification_param' in kwargs: + kwargs['param'] = kwargs['classification_param'] else: - kwargs['param'] = kwargs.get('regression_param') + if 'regression_param' in kwargs: + kwargs['param'] = kwargs['regression_param'] return kwargs learner = DummyFitter() diff --git a/Orange/tests/test_freeviz.py b/Orange/tests/test_freeviz.py index 4e07119359b..5504e76c0fa 100644 --- a/Orange/tests/test_freeviz.py +++ b/Orange/tests/test_freeviz.py @@ -18,7 +18,8 @@ def setUpClass(cls): def test_basic(self): table = self.iris.copy() - table[3, 3] = np.nan + with table.unlocked(): + table[3, 3] = np.nan freeviz = FreeViz() model = freeviz(table) proj = model(table) diff --git a/Orange/tests/test_fss.py b/Orange/tests/test_fss.py index 6b8b1e7f503..164207f3767 100644 --- a/Orange/tests/test_fss.py +++ b/Orange/tests/test_fss.py @@ -3,7 +3,9 @@ import unittest -from Orange.data import Table, Variable +import numpy as np + +from Orange.data import Table from Orange.preprocess.score import ANOVA, Gini, UnivariateLinearRegression, \ Chi2 from Orange.preprocess import SelectBestFeatures, Impute, SelectRandomFeatures @@ -82,7 +84,8 @@ def test_discrete_scores_on_continuous_features(self): self.assertEqual(len(scores), 4) score = method(d1, c.petal_length) - self.assertIsInstance(score, float) + self.assertEqual(score.ndim, 0) # a scalar + self.assertTrue(np.issubdtype(score.dtype, float)) def test_continuous_scores_on_discrete_features(self): data = Impute()(self.imports) diff --git a/Orange/tests/test_impute.py b/Orange/tests/test_impute.py index 3b3ec9be3fa..82d217ebe70 100644 --- a/Orange/tests/test_impute.py +++ b/Orange/tests/test_impute.py @@ -7,9 +7,9 @@ import scipy.sparse as sp from Orange import preprocess -from Orange.preprocess import impute +from Orange.preprocess import impute, SklImpute from Orange import data -from Orange.data import Unknown, Table +from Orange.data import Unknown, Table, Domain from Orange.classification import MajorityLearner, SimpleTreeLearner from Orange.regression import MeanLearner @@ -208,7 +208,8 @@ def test_sparse(self): """ table = self._create_table() domain = table.domain - table.X = sp.csr_matrix(table.X) + with table.unlocked(): + table.X = sp.csr_matrix(table.X) v1, v2 = impute.AsValue()(table, domain[1]) self.assertTrue(np.all(np.isfinite(v2.compute_value(table)))) @@ -292,6 +293,27 @@ def test_bad_domain(self): self.assertRaises(ValueError, imputer, data=table, variable=table.domain[0]) + def test_missing_imputed_columns(self): + housing = Table("housing") + + learner = SimpleTreeLearner(min_instances=10, max_depth=10) + method = preprocess.impute.Model(learner) + + ivar = method(housing, housing.domain.attributes[0]) + imputed = housing.transform( + Domain([ivar], + housing.domain.class_var) + ) + removed_imputed = imputed.transform( + Domain([], housing.domain.class_var)) + + r = removed_imputed.transform(imputed.domain) + + no_class = removed_imputed.transform(Domain(removed_imputed.domain.attributes, None)) + model_prediction_for_unknowns = ivar.compute_value.model(no_class[0]) + + np.testing.assert_equal(r.X, model_prediction_for_unknowns) + class TestRandom(unittest.TestCase): def test_replacement(self): @@ -328,3 +350,44 @@ def test_imputer(self): auto = data.Table(test_filename('datasets/imports-85.tab')) auto2 = preprocess.Impute()(auto) self.assertFalse(np.isnan(auto2.X).any()) + + +class TestSklImpute(unittest.TestCase): + + def setUp(self): + nan = np.nan + X = [ + [1.0, nan, 0.0], + [2.0, 1.0, 3.0], + [nan, nan, nan] + ] + self.imputed_mean = [ + [1.0, 1.0, 0.0], + [2.0, 1.0, 3.0], + [1.5, 1.0, 1.5] + ] + domain = data.Domain((data.ContinuousVariable(n) for n in "ABC")) + self.table = data.Table.from_numpy(domain, np.array(X)) + + def test_values(self): + imputed = SklImpute()(self.table) + np.testing.assert_equal(imputed.X, self.imputed_mean) + + def test_sparse(self): + sparse = self.table.to_sparse() + self.assertTrue(sp.issparse(sparse.X)) + imputed = SklImpute()(sparse) + self.assertTrue(sp.issparse(imputed.X)) + np.testing.assert_equal(imputed.X.todense(), self.imputed_mean) + + def test_transform(self): + imputed = SklImpute()(self.table) + transformed = self.table.transform(imputed.domain) + np.testing.assert_equal(transformed.X, self.imputed_mean) + + def test_transform_sparse(self): + sparse = self.table.to_sparse() + imputed = SklImpute()(sparse) + self.assertTrue(sp.issparse(sparse.X)) + transformed = sparse.transform(imputed.domain) + np.testing.assert_equal(transformed.X.todense(), self.imputed_mean) diff --git a/Orange/tests/test_io.py b/Orange/tests/test_io.py index 3bcb71d9155..8d3097fb8ff 100644 --- a/Orange/tests/test_io.py +++ b/Orange/tests/test_io.py @@ -1,9 +1,6 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring - -import io import os -import pickle import shutil import tempfile import unittest @@ -12,10 +9,9 @@ from Orange import data -from Orange.data.io import FileFormat, TabReader, CSVReader, PickleReader -from Orange.data.io_base import PICKLE_PROTOCOL +from Orange.data.io import FileFormat, TabReader, CSVReader, PickleReader, ExcelReader from Orange.data.table import get_sample_datasets_dir -from Orange.data import Table +from Orange.data import Table, StringVariable, Domain from Orange.tests import test_dirname from Orange.util import OrangeDeprecationWarning @@ -124,12 +120,13 @@ def test_empty_columns(self): 1, 0, 1, 2, """ - c = io.StringIO(samplefile) + with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp: + tmp.write(samplefile) with self.assertWarns(UserWarning) as cm: - table = CSVReader(c).read() + table = CSVReader(tmp.name).read() + os.unlink(tmp.name) self.assertEqual(len(table.domain.attributes), 2) - self.assertEqual(cm.warning.args[0], - "Columns with no headers were removed.") + self.assertEqual(cm.warning.args[0], "Columns with no headers were removed.") def test_type_annotations(self): class FooFormat(FileFormat): @@ -192,19 +189,31 @@ def test_load_pickle(self): self.assertEqual(attributes_count, len(data3.domain.attributes)) self.assertEqual(attributes_count, len(data4.domain.attributes)) - def test_pickle_version(self): + def test_update_origin(self): """ - Orange uses a fixed PICKLE_PROTOCOL (currently set to 4) - for pickling data files and possibly elsewhere for consistent - behaviour across different python versions (e.g. 3.6 - 3.8). - When the default protocol is increased in a future version of python - we should consider increasing this constant to match it as well. + Test if origin attributes is changed if path doesn't exist. For example + when file moved to another computer. It tested only one scenario + all other scenarios are tested as part of update_origin function tests. """ - # we should use a version that is at least as high as the default. - # it could be higher for older (but supported) python versions - self.assertGreaterEqual(PICKLE_PROTOCOL, pickle.DEFAULT_PROTOCOL) - # we should not use a version that is not supported - self.assertLessEqual(PICKLE_PROTOCOL, pickle.HIGHEST_PROTOCOL) + with tempfile.TemporaryDirectory() as dir_name: + os.mkdir(os.path.join(dir_name, "subdir")) + + var = StringVariable("Files") + var.attributes["origin"] = "/a/b/c/d/subdir" + table = Table.from_list(Domain([], metas=[var]), ["f1", "f2"]) + + for reader in (CSVReader, TabReader, PickleReader, ExcelReader): + dataset = os.path.join(dir_name, f"dataset{reader.EXTENSIONS[0]}") + if reader is PickleReader: + reader.write_file(dataset, table) + else: + reader.write_file(dataset, table, with_annotations=True) + + table = Table.from_file(dataset) + self.assertEqual( + os.path.join(dir_name, "subdir"), + table.domain["Files"].attributes["origin"], + ) if __name__ == "__main__": diff --git a/Orange/tests/test_knn.py b/Orange/tests/test_knn.py index d0627650e30..36355897181 100644 --- a/Orange/tests/test_knn.py +++ b/Orange/tests/test_knn.py @@ -31,6 +31,22 @@ def test_predict_single_instance(self): clf(ins) val, prob = clf(ins, clf.ValueProbs) + def test_nan(self): + lrn1 = KNNRegressionLearner(n_neighbors=1) + lrn3 = KNNRegressionLearner(n_neighbors=3) + X = np.arange(1, 7)[:, None] + Y = np.array([np.nan, np.nan, np.nan, 1, 1, 1]) + attr = (ContinuousVariable("Feat 1"),) + class_var = (ContinuousVariable("Class"),) + domain = Domain(attr, class_var) + data = Table(domain, X, Y) + clf = lrn1(data) + predictions = clf(data) + self.assertEqual(predictions[0], 1.0) + clf = lrn3(data) + predictions = clf(data) + self.assertEqual(predictions[3], 1.0) + def test_random(self): nrows, ncols = 1000, 5 x = np.random.randint(-20, 51, (nrows, ncols)) @@ -49,7 +65,7 @@ def test_random(self): clf = lrn(t) z = clf(x2) correct = (z == y2.flatten()) - ca = sum(correct) / len(correct) + ca = np.mean(correct) self.assertGreater(ca, 0.1) self.assertLess(ca, 0.3) @@ -67,3 +83,7 @@ def test_KNN_regression(self): results = cv(self.housing, learners) mse = MSE(results) self.assertLess(mse[1], mse[0]) + + def test_supports_weights(self): + self.assertFalse(KNNLearner().supports_weights) + self.assertFalse(KNNRegressionLearner().supports_weights) diff --git a/Orange/tests/test_linear_regression.py b/Orange/tests/test_linear_regression.py index 97c28b23fae..5ed13f94614 100644 --- a/Orange/tests/test_linear_regression.py +++ b/Orange/tests/test_linear_regression.py @@ -118,3 +118,6 @@ def test_linear_regression_repr(self): learner2 = eval(repr_text) self.assertIsInstance(learner2, LinearRegressionLearner) + + def test_supports_weights(self): + self.assertTrue(LinearRegressionLearner().supports_weights) diff --git a/Orange/tests/test_logistic_regression.py b/Orange/tests/test_logistic_regression.py index 4ab3cdc3a68..ab4957a618b 100644 --- a/Orange/tests/test_logistic_regression.py +++ b/Orange/tests/test_logistic_regression.py @@ -1,6 +1,7 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring +from datetime import datetime import unittest import numpy as np @@ -9,6 +10,7 @@ from Orange.data import Table, ContinuousVariable, Domain from Orange.classification import LogisticRegressionLearner, Model from Orange.evaluation import CrossValidation, CA +from Orange.util import OrangeDeprecationWarning class TestLogisticRegressionLearner(unittest.TestCase): @@ -149,5 +151,8 @@ def test_auto_solver(self): # liblinear is default for l2 penalty lr = LogisticRegressionLearner(penalty="l1", solver="auto") skl_clf = lr._initialize_wrapped() - self.assertEqual(skl_clf.solver, "liblinear") + self.assertEqual(skl_clf.solver, "saga") self.assertEqual(skl_clf.penalty, "l1") + + def test_supports_weights(self): + self.assertTrue(LogisticRegressionLearner().supports_weights) diff --git a/Orange/tests/test_majority.py b/Orange/tests/test_majority.py index 4b41acff84c..0aa12333cc7 100644 --- a/Orange/tests/test_majority.py +++ b/Orange/tests/test_majority.py @@ -49,14 +49,17 @@ def test_empty(self): def test_missing(self): iris = Table('iris') learn = MajorityLearner() - for e in iris[: len(iris) // 2: 2]: - e.set_class("?") + sub_table = iris[: len(iris) // 2: 2].copy() + with sub_table.unlocked(): + for e in sub_table: + e.set_class("?") clf = learn(iris) y = clf(iris) self.assertTrue((y == 2).all()) - for e in iris: - e.set_class("?") + with iris.unlocked(): + for e in iris: + e.set_class("?") clf = learn(iris) y = clf(iris) self.assertEqual(y.all(), 1) @@ -83,3 +86,6 @@ def test_returns_random_class(self): break else: self.fail("Majority always returns the same value.") + + def test_supports_weights(self): + self.assertFalse(MajorityLearner().supports_weights) diff --git a/Orange/tests/test_manifold.py b/Orange/tests/test_manifold.py index cf332ea22d6..fe15808211f 100644 --- a/Orange/tests/test_manifold.py +++ b/Orange/tests/test_manifold.py @@ -1,6 +1,7 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring - +import pickle +import platform import unittest import numpy as np @@ -56,26 +57,26 @@ def test_mds_pca_init(self): n_components=2, dissimilarity=Euclidean, init_type='PCA', n_init=1) X = projector(self.iris).embedding_ - np.testing.assert_array_almost_equal(X[0], result) + np.testing.assert_allclose(X[0], result, rtol=1e-2) projector = MDS( n_components=2, dissimilarity='precomputed', init_type='PCA', n_init=1) X = projector(Euclidean(self.iris)).embedding_ - np.testing.assert_array_almost_equal(X[0], result) + np.testing.assert_allclose(X[0], result, rtol=1e-2) projector = MDS( n_components=2, dissimilarity='euclidean', init_type='PCA', n_init=1) X = projector(self.iris).embedding_ - np.testing.assert_array_almost_equal(X[0], result) + np.testing.assert_allclose(X[0], result, rtol=1e-2) projector = MDS( n_components=6, dissimilarity='euclidean', init_type='PCA', n_init=1) X = projector(self.iris[:5]).embedding_ - result = np.array([-0.31871, -0.064644, 0.015653, -1.5e-08, -4.3e-11, 0]) - np.testing.assert_array_almost_equal(np.abs(X[0]), np.abs(result)) + result = np.array([-0.31871, -0.064644, 0.015653, 0, 0, 0]) + np.testing.assert_allclose(np.abs(X[0]), np.abs(result), rtol=1e-2) def test_isomap(self): for i in range(1, 4): @@ -245,3 +246,22 @@ def test_fft_correctness(self): knn.fit(model.embedding_, self.iris.Y) predicted = knn.predict(model.embedding_) self.assertTrue(accuracy_score(predicted, self.iris.Y) > 0.95) + + @unittest.skipIf(platform.system() == "Windows", "Files locked on Windows") + def test_pickle(self): + for neighbors in ("exact", "approx"): + tsne = TSNE(early_exaggeration_iter=0, n_iter=10, perplexity=30, + neighbors=neighbors, random_state=0) + model = tsne(self.iris[::2]) + + loaded_model = pickle.loads(pickle.dumps(model)) + + new_embedding = loaded_model(self.iris[1::2]).X + + knn = KNeighborsClassifier(n_neighbors=5) + knn.fit(new_embedding, self.iris[1::2].Y) + predicted = knn.predict(new_embedding) + self.assertTrue( + accuracy_score(predicted, self.iris[1::2].Y) > 0.95, + msg=f"Pickling failed with `neighbors={neighbors}`", + ) diff --git a/Orange/tests/test_naive_bayes.py b/Orange/tests/test_naive_bayes.py index 37c5a52d372..1ccf32ce7fc 100644 --- a/Orange/tests/test_naive_bayes.py +++ b/Orange/tests/test_naive_bayes.py @@ -10,15 +10,40 @@ from Orange.classification import NaiveBayesLearner from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable from Orange.evaluation import CrossValidation, CA +from Orange.tests import test_filename # This class is used to force predict_storage to fall back to the slower # procedure instead of calling `predict` -from Orange.tests import test_filename - - class NotATable(Table): # pylint: disable=too-many-ancestors,abstract-method - pass + @classmethod + def from_file(cls, *args, **kwargs): + table = super().from_file(*args, **kwargs) + return cls(table) + + +def assert_predictions_equal(data, model, exp_probs): + exp_vals = np.argmax(np.atleast_2d(exp_probs), axis=1) + np.testing.assert_almost_equal(model(data, ret=model.Probs), exp_probs) + np.testing.assert_equal(model(data), exp_vals) + values, probs = model(data, ret=model.ValueProbs) + np.testing.assert_almost_equal(probs, exp_probs) + np.testing.assert_equal(values, exp_vals) + + +def assert_model_equal(model, results): + np.testing.assert_almost_equal( + model.class_prob, + results[0]) + np.testing.assert_almost_equal( + np.exp(model.log_cont_prob[0]) * model.class_prob[:, None], + results[1]) + np.testing.assert_almost_equal( + np.exp(model.log_cont_prob[1]) * model.class_prob[:, None], + results[2]) + np.testing.assert_almost_equal( + np.exp(model.log_cont_prob[2]) * model.class_prob[:, None], + results[3]) class TestNaiveBayesLearner(unittest.TestCase): @@ -97,17 +122,22 @@ def test_compare_results_of_predict_and_predict_storage(self): def test_predictions(self): self._test_predictions(sparse=None) - self._test_predictions_with_absent_class(sparse=None) + self._test_predictions(sparse=None, absent_class=True) + self._test_predict_missing_attributes(sparse=None) def test_predictions_csr_matrix(self): self._test_predictions(sparse=sp.csr_matrix) - self._test_predictions_with_absent_class(sparse=sp.csr_matrix) + self._test_predictions(sparse=sp.csr_matrix, absent_class=True) + self._test_predict_missing_attributes(sparse=sp.csr_matrix) def test_predictions_csc_matrix(self): self._test_predictions(sparse=sp.csc_matrix) - self._test_predictions_with_absent_class(sparse=sp.csc_matrix) + self._test_predictions(sparse=sp.csc_matrix, absent_class=True) + self._test_predict_missing_attributes(sparse=sp.csc_matrix) - def _test_predictions(self, sparse): + @staticmethod + def _create_prediction_data(sparse, absent_class=False): + """ The following was computed manually """ x = np.array([ [1, 0, 0], [0, np.nan, 0], @@ -121,27 +151,13 @@ def _test_predictions(self, sparse): x = sparse(x) y = np.array([0, 0, 0, 1, 1, 1, 2, 2]) - domain = Domain( - [DiscreteVariable("a", values="ab"), - DiscreteVariable("b", values="abc"), - DiscreteVariable("c", values="a")], - DiscreteVariable("y", values="abc")) - data = Table.from_numpy(domain, x, y) - - model = self.learner(data) - np.testing.assert_almost_equal( - model.class_prob, - [4/11, 4/11, 3/11] - ) - np.testing.assert_almost_equal( - np.exp(model.log_cont_prob[0]) * model.class_prob[:, None], - [[3/7, 2/7], [2/7, 3/7], [2/7, 2/7]]) - np.testing.assert_almost_equal( - np.exp(model.log_cont_prob[1]) * model.class_prob[:, None], - [[2/5, 1/3, 1/5], [2/5, 1/3, 2/5], [1/5, 1/3, 2/5]]) - np.testing.assert_almost_equal( - np.exp(model.log_cont_prob[2]) * model.class_prob[:, None], - [[4/11], [4/11], [3/11]]) + class_var = DiscreteVariable("y", values="abc") + results = [ + [4/11, 4/11, 3/11], + [[3/7, 2/7], [2/7, 3/7], [2/7, 2/7]], + [[2/5, 1/3, 1/5], [2/5, 1/3, 2/5], [1/5, 1/3, 2/5]], + [[4/11], [4/11], [3/11]] + ] test_x = np.array([[a, b, 0] for a in [0, 1] for b in [0, 1, 2]]) # Classifiers reject csc matrices in the base class @@ -150,7 +166,7 @@ def _test_predictions(self, sparse): if sparse is not None and sparse is not sp.csc_matrix: test_x = sparse(test_x) test_y = np.full((6, ), np.nan) - # The following was computed manually, too + exp_probs = np.array([ [0.47368421052632, 0.31578947368421, 0.21052631578947], [0.39130434782609, 0.26086956521739, 0.34782608695652], @@ -160,153 +176,87 @@ def _test_predictions(self, sparse): [0.15000000000000, 0.45000000000000, 0.40000000000000] ]) + if absent_class: + y = np.array([0, 0, 0, 2, 2, 2, 3, 3]) + class_var = DiscreteVariable("y", values="abcd") + for i, row in enumerate(results): + row.insert(1, i and [0]*len(row[0])) + exp_probs = np.insert(exp_probs, 1, 0, axis=1) + + domain = Domain( + [DiscreteVariable("a", values="ab"), + DiscreteVariable("b", values="abc"), + DiscreteVariable("c", values="a")], + class_var) + data = Table.from_numpy(domain, x, y) + + return data, domain, results, test_x, test_y, exp_probs + + def _test_predictions(self, sparse, absent_class=False): + (data, domain, results, + test_x, test_y, exp_probs) = self._create_prediction_data(sparse, absent_class) + + model = self.learner(data) + assert_model_equal(model, results) + # Test the faster algorithm for Table (numpy matrices) test_data = Table.from_numpy(domain, test_x, test_y) - probs = model(test_data, ret=model.Probs) - np.testing.assert_almost_equal(exp_probs, probs) - values = model(test_data) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - values, probs = model(test_data, ret=model.ValueProbs) - np.testing.assert_almost_equal(exp_probs, probs) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) + assert_predictions_equal(test_data, model, exp_probs) # Test the slower algorithm for non-Table data (iteration in Python) test_data = NotATable.from_numpy(domain, test_x, test_y) - probs = model(test_data, ret=model.Probs) - np.testing.assert_almost_equal(exp_probs, probs) - values = model(test_data) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - values, probs = model(test_data, ret=model.ValueProbs) - np.testing.assert_almost_equal(exp_probs, probs) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) + assert_predictions_equal(test_data, model, exp_probs) # Test prediction directly on numpy - probs = model(test_x, ret=model.Probs) - np.testing.assert_almost_equal(exp_probs, probs) - values = model(test_x) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - values, probs = model(test_x, ret=model.ValueProbs) - np.testing.assert_almost_equal(exp_probs, probs) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) + assert_predictions_equal(test_x, model, exp_probs) # Test prediction on instances for inst, exp_prob in zip(test_data, exp_probs): - np.testing.assert_almost_equal( - model(inst, ret=model.Probs), - exp_prob) - self.assertEqual(model(inst), np.argmax(exp_prob)) - value, prob = model(inst, ret=model.ValueProbs) - np.testing.assert_almost_equal(prob, exp_prob) - self.assertEqual(value, np.argmax(exp_prob)) + assert_predictions_equal(inst, model, exp_prob) # Test prediction by directly calling predict. This is needed to test # csc_matrix, but doesn't hurt others if sparse is sp.csc_matrix: test_x = sparse(test_x) values, probs = model.predict(test_x) - np.testing.assert_almost_equal(exp_probs, probs) + np.testing.assert_almost_equal(probs, exp_probs) np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - def _test_predictions_with_absent_class(self, sparse): - """Empty classes should not affect predictions""" + @staticmethod + def _create_missing_attributes(sparse): x = np.array([ [1, 0, 0], - [0, np.nan, 0], [0, 1, 0], [0, 0, 0], - [1, 2, 0], + [0, 1, 0], [1, 1, 0], [1, 2, 0], - [0, 1, 0]]) + [1, 2, np.nan]]) if sparse is not None: x = sparse(x) + y = np.array([1, 0, 0, 0, 1, 1, 1]) + + test_x = np.array([[np.nan, np.nan, np.nan], + [np.nan, 0, np.nan], + [0, np.nan, np.nan]]) + if sparse is not None and sparse is not sp.csc_matrix: + test_x = sparse(test_x) + exp_probs = np.array([[(3 + 1) / (7 + 2), (4 + 1) / (7 + 2)], + [(1 + 1) / (2 + 2), (1 + 1) / (2 + 2)], + [(3 + 1) / (3 + 2), (0 + 1) / (3 + 2)]]) - y = np.array([0, 0, 0, 2, 2, 2, 3, 3]) domain = Domain( [DiscreteVariable("a", values="ab"), DiscreteVariable("b", values="abc"), DiscreteVariable("c", values="a")], - DiscreteVariable("y", values="abcd")) - data = Table.from_numpy(domain, x, y) + DiscreteVariable("y", values="AB")) + return Table.from_numpy(domain, x, y), test_x, exp_probs + def _test_predict_missing_attributes(self, sparse): + data, test_x, exp_probs = self._create_missing_attributes(sparse) model = self.learner(data) - np.testing.assert_almost_equal( - model.class_prob, - [4/11, 0, 4/11, 3/11] - ) - np.testing.assert_almost_equal( - np.exp(model.log_cont_prob[0]) * model.class_prob[:, None], - [[3/7, 2/7], [0, 0], [2/7, 3/7], [2/7, 2/7]]) - np.testing.assert_almost_equal( - np.exp(model.log_cont_prob[1]) * model.class_prob[:, None], - [[2/5, 1/3, 1/5], [0, 0, 0], [2/5, 1/3, 2/5], [1/5, 1/3, 2/5]]) - np.testing.assert_almost_equal( - np.exp(model.log_cont_prob[2]) * model.class_prob[:, None], - [[4/11], [0], [4/11], [3/11]]) - - test_x = np.array([[a, b, 0] for a in [0, 1] for b in [0, 1, 2]]) - # Classifiers reject csc matrices in the base class - # Naive bayesian classifier supports them if predict_storage is - # called directly, which we do below - if sparse is not None and sparse is not sp.csc_matrix: - test_x = sparse(test_x) - test_y = np.full((6, ), np.nan) - # The following was computed manually, too - exp_probs = np.array([ - [0.47368421052632, 0, 0.31578947368421, 0.21052631578947], - [0.39130434782609, 0, 0.26086956521739, 0.34782608695652], - [0.24324324324324, 0, 0.32432432432432, 0.43243243243243], - [0.31578947368421, 0, 0.47368421052632, 0.21052631578947], - [0.26086956521739, 0, 0.39130434782609, 0.34782608695652], - [0.15000000000000, 0, 0.45000000000000, 0.40000000000000] - ]) - - # Test the faster algorithm for Table (numpy matrices) - test_data = Table.from_numpy(domain, test_x, test_y) - probs = model(test_data, ret=model.Probs) - np.testing.assert_almost_equal(exp_probs, probs) - values = model(test_data) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - values, probs = model(test_data, ret=model.ValueProbs) - np.testing.assert_almost_equal(exp_probs, probs) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - - # Test the slower algorithm for non-Table data (iteration in Python) - test_data = NotATable.from_numpy(domain, test_x, test_y) - probs = model(test_data, ret=model.Probs) - np.testing.assert_almost_equal(exp_probs, probs) - values = model(test_data) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - values, probs = model(test_data, ret=model.ValueProbs) - np.testing.assert_almost_equal(exp_probs, probs) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - - # Test prediction directly on numpy probs = model(test_x, ret=model.Probs) - np.testing.assert_almost_equal(exp_probs, probs) - values = model(test_x) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - values, probs = model(test_x, ret=model.ValueProbs) - np.testing.assert_almost_equal(exp_probs, probs) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) - - # Test prediction on instances - for inst, exp_prob in zip(test_data, exp_probs): - np.testing.assert_almost_equal( - model(inst, ret=model.Probs), - exp_prob) - self.assertEqual(model(inst), np.argmax(exp_prob)) - value, prob = model(inst, ret=model.ValueProbs) - np.testing.assert_almost_equal(prob, exp_prob) - self.assertEqual(value, np.argmax(exp_prob)) - - # Test prediction by directly calling predict. This is needed to test - # csc_matrix, but doesn't hurt others - if sparse is sp.csc_matrix: - test_x = sparse(test_x) - values, probs = model.predict(test_x) - np.testing.assert_almost_equal(exp_probs, probs) - np.testing.assert_equal(values, np.argmax(exp_probs, axis=1)) + np.testing.assert_almost_equal(probs, exp_probs) def test_no_attributes(self): y = np.array([0, 0, 0, 1, 1, 1, 2, 2]) @@ -326,6 +276,9 @@ def test_no_targets(self): data = Table.from_numpy(domain, x, y) self.assertRaises(ValueError, self.learner, data) + def test_supports_weights(self): + self.assertFalse(NaiveBayesLearner().supports_weights) + if __name__ == "__main__": unittest.main() diff --git a/Orange/tests/test_neural_network.py b/Orange/tests/test_neural_network.py index e16750e6eab..6515aa246b7 100644 --- a/Orange/tests/test_neural_network.py +++ b/Orange/tests/test_neural_network.py @@ -63,3 +63,7 @@ def test_NN_regression_predict_single_instance(self): clf = lrn(self.housing) for ins in self.housing[::20]: clf(ins) + + def test_supports_weights(self): + self.assertFalse(NNRegressionLearner().supports_weights) + self.assertFalse(NNClassificationLearner().supports_weights) diff --git a/Orange/tests/test_normalize.py b/Orange/tests/test_normalize.py index d58e9daae9f..c35f98acfde 100644 --- a/Orange/tests/test_normalize.py +++ b/Orange/tests/test_normalize.py @@ -115,11 +115,12 @@ def test_normalize_sparse(self): self.assertEqual((normalized.X != solution).nnz, 0) # raise error for non-zero offsets - data.X = sp.csr_matrix(np.array([ - [0, 0, 0], - [0, 1, 3], - [0, 2, 4], - ])) + with data.unlocked(): + data.X = sp.csr_matrix(np.array([ + [0, 0, 0], + [0, 1, 3], + [0, 2, 4], + ])) with self.assertRaises(ValueError): normalizer(data) diff --git a/Orange/tests/test_orangetree.py b/Orange/tests/test_orangetree.py index a1d4a4e5a03..1d436444568 100644 --- a/Orange/tests/test_orangetree.py +++ b/Orange/tests/test_orangetree.py @@ -5,6 +5,7 @@ import numpy as np import scipy.sparse as sp +from Orange.classification._tree_scorers import find_threshold_entropy from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable from Orange.classification.tree import \ @@ -34,7 +35,7 @@ def test_full_tree(self): learn = self.TreeLearner(**self.no_pruning_args) clf = learn(table) pred = clf(table) - self.assertTrue(np.all(table.Y.flatten() == pred)) + np.testing.assert_equal(table.Y.flatten(), pred) def test_min_samples_split(self): clf = self.TreeLearner( @@ -448,3 +449,36 @@ def test_compile_and_run_cont_sparse(self): [14, 2, 1]], dtype=float )) np.testing.assert_equal(model.get_values(x), expected_values) + + +class TestScorers(unittest.TestCase): + + def test_find_threshold_entropy(self): + x = np.array([1, 2, 3, 4], dtype=float) + y = np.array([0, 0, 1, 1], dtype=float) + ind = np.argsort(x, kind="stable") + e, t = find_threshold_entropy(x, y, ind, 2, 1) + self.assertAlmostEqual(e, 1) + self.assertEqual(t, 2.0) + + def test_find_threshold_entropy_repeated(self): + x = np.array([1, 1, 1, 2, 2, 2], dtype=float) + y = np.array([0, 0, 0, 0, 1, 1], dtype=float) + ind = np.argsort(x, kind="stable") + e, t = find_threshold_entropy(x, y, ind, 2, 1) + self.assertAlmostEqual(e, 0.459147917027245) + self.assertEqual(t, 1.0) + + x = np.array([1, 1, 1, 2, 2, 2], dtype=float) + y = np.array([0, 0, 1, 1, 1, 1], dtype=float) + ind = np.argsort(x, kind="stable") + e, t = find_threshold_entropy(x, y, ind, 2, 1) + self.assertAlmostEqual(e, 0.459147917027245) + self.assertEqual(t, 1.0) + + x = np.array([1, 1, 1, 2, 2, 2], dtype=float) + y = np.array([0, 1, 1, 1, 1, 1], dtype=float) + ind = np.argsort(x, kind="stable") + e, t = find_threshold_entropy(x, y, ind, 2, 1) + self.assertAlmostEqual(e, 0.19087450462110966) + self.assertEqual(t, 1.0) diff --git a/Orange/tests/test_pca.py b/Orange/tests/test_pca.py index 2375a6239e2..ca59483a461 100644 --- a/Orange/tests/test_pca.py +++ b/Orange/tests/test_pca.py @@ -2,15 +2,12 @@ # pylint: disable=missing-docstring import pickle import unittest -from unittest.mock import MagicMock import numpy as np -from sklearn import __version__ as sklearn_version -from sklearn.utils import check_random_state -from Orange.data import Table, Domain +from Orange.data import Table from Orange.preprocess import Continuize, Normalize -from Orange.projection import pca, PCA, SparsePCA, IncrementalPCA, TruncatedSVD +from Orange.projection import PCA, SparsePCA, IncrementalPCA, TruncatedSVD from Orange.tests import test_filename @@ -66,97 +63,6 @@ def __rnd_pca_test_helper(self, data, n_com, min_xpl_var): proj = np.dot(data.X - pca_model.mean_, pca_model.components_.T) np.testing.assert_almost_equal(pca_model(data).X, proj) - def test_improved_randomized_pca_properly_called(self): - # It doesn't matter what we put into the matrix - x_ = np.random.normal(0, 1, (100, 20)) - x = Table.from_numpy(Domain.from_numpy(x_), x_) - - pca.randomized_pca = MagicMock(wraps=pca.randomized_pca) - PCA(10, svd_solver="randomized", random_state=42)(x) - pca.randomized_pca.assert_called_once() - - pca.randomized_pca.reset_mock() - PCA(10, svd_solver="arpack", random_state=42)(x) - pca.randomized_pca.assert_not_called() - - def test_improved_randomized_pca_dense_data(self): - """Randomized PCA should work well on dense data.""" - random_state = check_random_state(42) - - # Let's take a tall, skinny matrix - x_ = random_state.normal(0, 1, (100, 20)) - x = Table.from_numpy(Domain.from_numpy(x_), x_) - - pca = PCA(10, svd_solver="full", random_state=random_state)(x) - rpca = PCA(10, svd_solver="randomized", random_state=random_state)(x) - - np.testing.assert_almost_equal( - pca.components_, rpca.components_, decimal=8 - ) - np.testing.assert_almost_equal( - pca.explained_variance_, rpca.explained_variance_, decimal=8 - ) - np.testing.assert_almost_equal( - pca.singular_values_, rpca.singular_values_, decimal=8 - ) - - # And take a short, fat matrix - x_ = random_state.normal(0, 1, (20, 100)) - x = Table.from_numpy(Domain.from_numpy(x_), x_) - - pca = PCA(10, svd_solver="full", random_state=random_state)(x) - rpca = PCA(10, svd_solver="randomized", random_state=random_state)(x) - - np.testing.assert_almost_equal( - pca.components_, rpca.components_, decimal=8 - ) - np.testing.assert_almost_equal( - pca.explained_variance_, rpca.explained_variance_, decimal=8 - ) - np.testing.assert_almost_equal( - pca.singular_values_, rpca.singular_values_, decimal=8 - ) - - def test_improved_randomized_pca_sparse_data(self): - """Randomized PCA should work well on dense data.""" - random_state = check_random_state(42) - - # Let's take a tall, skinny matrix - x_ = random_state.negative_binomial(1, 0.5, (100, 20)) - x = Table.from_numpy(Domain.from_numpy(x_), x_).to_sparse() - - pca = PCA(10, svd_solver="full", random_state=random_state)(x.to_dense()) - rpca = PCA(10, svd_solver="randomized", random_state=random_state)(x) - - np.testing.assert_almost_equal( - pca.components_, rpca.components_, decimal=8 - ) - np.testing.assert_almost_equal( - pca.explained_variance_, rpca.explained_variance_, decimal=8 - ) - np.testing.assert_almost_equal( - pca.singular_values_, rpca.singular_values_, decimal=8 - ) - - # And take a short, fat matrix - x_ = random_state.negative_binomial(1, 0.5, (20, 100)) - x = Table.from_numpy(Domain.from_numpy(x_), x_).to_sparse() - - pca = PCA(10, svd_solver="full", random_state=random_state)(x.to_dense()) - rpca = PCA(10, svd_solver="randomized", random_state=random_state)(x) - - np.testing.assert_almost_equal( - pca.components_, rpca.components_, decimal=8 - ) - np.testing.assert_almost_equal( - pca.explained_variance_, rpca.explained_variance_, decimal=8 - ) - np.testing.assert_almost_equal( - pca.singular_values_, rpca.singular_values_, decimal=8 - ) - - @unittest.skipIf(sklearn_version.startswith('0.20'), - "https://github.com/scikit-learn/scikit-learn/issues/12234") def test_incremental_pca(self): data = self.ionosphere self.__ipca_test_helper(data, n_com=3, min_xpl_var=0.49) @@ -257,3 +163,39 @@ def test_max_components(self): self.assertEqual(len(pca.explained_variance_ratio_), 20) pca = PCA(n_components=10)(data) self.assertEqual(len(pca.explained_variance_ratio_), 10) + + def test_eq_hash(self): + d = np.random.RandomState(0).rand(20, 20) + data = Table.from_numpy(None, d) + p1 = PCA()(data) + p2 = PCA()(data) + np.testing.assert_equal(p1(data).X, p2(data).X) + + # even though results are the same, these transformations + # are different because the PCA object is + self.assertNotEqual(p1, p2) + self.assertNotEqual(p1.domain, p2.domain) + self.assertNotEqual(hash(p1), hash(p2)) + self.assertNotEqual(hash(p1.domain), hash(p2.domain)) + + def test_eq_hash_fake_same_projection(self): + d = np.random.RandomState(0).rand(20, 20) + data = Table.from_numpy(None, d) + p1 = PCA()(data) + p2 = PCA()(data) + + # copy projection + p2.domain[0].compute_value.compute_shared.projection = \ + p1.domain[0].compute_value.compute_shared.projection + p2.proj = p1.proj + # reset hash caches because object were hacked + # pylint: disable=protected-access + p1.domain._hash = None + p2.domain._hash = None + p1.domain[0].compute_value.compute_shared._hash = None + p2.domain[0].compute_value.compute_shared._hash = None + + self.assertEqual(p1, p2) + self.assertEqual(p1.domain, p2.domain) + self.assertEqual(hash(p1), hash(p2)) + self.assertEqual(hash(p1.domain), hash(p2.domain)) diff --git a/Orange/tests/test_preprocess.py b/Orange/tests/test_preprocess.py index b9076894ec7..6e190901009 100644 --- a/Orange/tests/test_preprocess.py +++ b/Orange/tests/test_preprocess.py @@ -69,7 +69,8 @@ def test_nothing_to_remove(self): class TestRemoveNaNRows(unittest.TestCase): def test_remove_row(self): data = Table("iris") - data.X[0, 0] = np.nan + with data.unlocked(): + data.X[0, 0] = np.nan pp_data = RemoveNaNRows()(data) self.assertEqual(len(pp_data), len(data) - 1) self.assertFalse(np.isnan(pp_data.X).any()) @@ -78,21 +79,24 @@ def test_remove_row(self): class TestRemoveNaNColumns(unittest.TestCase): def test_column_filtering(self): data = Table("iris") - data.X[:, (1, 3)] = np.NaN + with data.unlocked(): + data.X[:, (1, 3)] = np.nan new_data = RemoveNaNColumns()(data) self.assertEqual(len(new_data.domain.attributes), len(data.domain.attributes) - 2) data = Table("iris") - data.X[0, 0] = np.NaN + with data.unlocked(): + data.X[0, 0] = np.nan new_data = RemoveNaNColumns()(data) self.assertEqual(len(new_data.domain.attributes), len(data.domain.attributes)) def test_column_filtering_sparse(self): data = Table("iris") - data.X = csr_matrix(data.X) + with data.unlocked(): + data.X = csr_matrix(data.X) new_data = RemoveNaNColumns()(data) self.assertEqual(data, new_data) @@ -169,7 +173,8 @@ def test_dense_pps(self): np.testing.assert_array_equal(out, true_out) def test_sparse_pps(self): - self.data.X = csr_matrix(self.data.X) + with self.data.unlocked(): + self.data.X = csr_matrix(self.data.X) out = AdaptiveNormalize()(self.data) true_out = Scale(center=Scale.NoCentering, scale=Scale.Span)(self.data) np.testing.assert_array_equal(out, true_out) @@ -183,9 +188,11 @@ def setUp(self): self.data = Table.from_numpy(domain, np.zeros((3, 2))) def test_0_dense(self): - self.data[1:, 1] = 7 - true_out = self.data[:, 1] - true_out.X = true_out.X.reshape(-1, 1) + with self.data.unlocked(): + self.data[1:, 1] = 7 + true_out = self.data[:, 1].copy() + with true_out.unlocked(true_out.X): + true_out.X = true_out.X.reshape(-1, 1) out = RemoveSparse(0.5, True)(self.data) np.testing.assert_array_equal(out, true_out) @@ -193,10 +200,12 @@ def test_0_dense(self): np.testing.assert_array_equal(out, true_out) def test_0_sparse(self): - self.data[1:, 1] = 7 - true_out = self.data[:, 1] - self.data.X = csr_matrix(self.data.X) - true_out.X = csr_matrix(true_out.X) + with self.data.unlocked(): + self.data[1:, 1] = 7 + true_out = self.data[:, 1].copy() + self.data.X = csr_matrix(self.data.X) + with true_out.unlocked(true_out.X): + true_out.X = csr_matrix(true_out.X) out = RemoveSparse(0.5, True)(self.data).X np.testing.assert_array_equal(out, true_out) @@ -204,10 +213,12 @@ def test_0_sparse(self): np.testing.assert_array_equal(out, true_out) def test_nan_dense(self): - self.data[1:, 1] = np.nan - self.data.X[:, 0] = 7 - true_out = self.data[:, 0] - true_out.X = true_out.X.reshape(-1, 1) + with self.data.unlocked(): + self.data[1:, 1] = np.nan + self.data.X[:, 0] = 7 + true_out = self.data[:, 0].copy() + with true_out.unlocked(true_out.X): + true_out.X = true_out.X.reshape(-1, 1) out = RemoveSparse(0.5, False)(self.data) np.testing.assert_array_equal(out, true_out) @@ -215,12 +226,14 @@ def test_nan_dense(self): np.testing.assert_array_equal(out, true_out) def test_nan_sparse(self): - self.data[1:, 1] = np.nan - self.data.X[:, 0] = 7 - true_out = self.data[:, 0] - true_out.X = true_out.X.reshape(-1, 1) - self.data.X = csr_matrix(self.data.X) - true_out.X = csr_matrix(true_out.X) + with self.data.unlocked(): + self.data[1:, 1] = np.nan + self.data.X[:, 0] = 7 + true_out = self.data[:, 0].copy() + with true_out.unlocked(true_out.X): + true_out.X = true_out.X.reshape(-1, 1) + true_out.X = csr_matrix(true_out.X) + self.data.X = csr_matrix(self.data.X) out = RemoveSparse(0.5, False)(self.data) np.testing.assert_array_equal(out, true_out) diff --git a/Orange/tests/test_radviz.py b/Orange/tests/test_radviz.py index 27e817f9ee6..7bd658fcbb1 100644 --- a/Orange/tests/test_radviz.py +++ b/Orange/tests/test_radviz.py @@ -11,7 +11,8 @@ class TestRadViz(unittest.TestCase): @classmethod def setUpClass(cls): cls.iris = Table("iris") - cls.iris[3, 3] = np.nan + with cls.iris.unlocked(): + cls.iris[3, 3] = np.nan cls.titanic = Table("titanic") def test_radviz(self): diff --git a/Orange/tests/test_random_forest.py b/Orange/tests/test_random_forest.py index c5abda57b97..9f61d9b7324 100644 --- a/Orange/tests/test_random_forest.py +++ b/Orange/tests/test_random_forest.py @@ -111,3 +111,32 @@ def test_get_regression_trees(self): self.assertEqual(len(model.trees), n) tree = model.trees[0] tree(self.housing[0]) + + def test_max_features_cls(self): + data = Table("heart_disease") + forest_1 = RandomForestLearner(random_state=0, max_features=1) + model_1 = forest_1(data[1:]) + + forest_2 = RandomForestLearner(random_state=0, max_features=1.) + model_2 = forest_2(data[1:]) + diff = np.sum(np.abs(model_1(data[:1], ret=model_2.Probs) - + model_2(data[:1], ret=model_2.Probs))) + self.assertGreaterEqual(diff, 0.2) + + def test_max_features_reg(self): + data = self.housing + forest_1 = RandomForestRegressionLearner(random_state=0, max_features=1) + model_1 = forest_1(data[2:]) + + forest_2 = RandomForestRegressionLearner(random_state=0, max_features=1.) + model_2 = forest_2(data[2:]) + self.assertNotEqual(model_1(data[:2]).tolist(), + model_2(data[:2]).tolist()) + + def test_supports_weights(self): + self.assertTrue(RandomForestRegressionLearner().supports_weights) + self.assertTrue(RandomForestLearner().supports_weights) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/tests/test_regression.py b/Orange/tests/test_regression.py index 444f959e9fb..5edae24d981 100644 --- a/Orange/tests/test_regression.py +++ b/Orange/tests/test_regression.py @@ -7,8 +7,8 @@ import traceback import Orange -from Orange.data import Table, Variable -from Orange.regression import Learner +from Orange.data import Table +from Orange.regression import Learner, CurveFitLearner from Orange.tests import test_filename @@ -28,35 +28,37 @@ def all_learners(): yield class_ -class TestRegression(unittest.TestCase): +def init_learner(learner, table): + if learner == CurveFitLearner: + return CurveFitLearner( + lambda x, a: x[:, -1] * a, [], + [table.domain.attributes[-1].name] + ) + return learner() + +class TestRegression(unittest.TestCase): def test_adequacy_all_learners(self): + table = Table("iris") for learner in all_learners(): - try: - learner = learner() - table = Table("iris") - self.assertRaises(ValueError, learner, table) - except TypeError as err: - traceback.print_exc() - continue + learner = init_learner(learner, table) + with self.assertRaises(ValueError): + learner(table) def test_adequacy_all_learners_multiclass(self): + table = Table(test_filename("datasets/test8.tab")) for learner in all_learners(): - try: - learner = learner() - table = Table(test_filename("datasets/test8.tab")) - self.assertRaises(ValueError, learner, table) - except TypeError as err: - traceback.print_exc() - continue + learner = init_learner(learner, table) + with self.assertRaises(ValueError): + learner(table) def test_missing_class(self): table = Table(test_filename("datasets/imports-85.tab")) for learner in all_learners(): - try: - learner = learner() - model = learner(table) - model(table) - except TypeError: - traceback.print_exc() - continue + learner = init_learner(learner, table) + model = learner(table) + model(table) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/tests/test_rules.py b/Orange/tests/test_rules.py index ea86494d5cf..a66d202a1d0 100644 --- a/Orange/tests/test_rules.py +++ b/Orange/tests/test_rules.py @@ -112,6 +112,19 @@ def testCN2Learner(self): predictions = classifier.predict(self.titanic.X) self.assertEqual(len(predictions), self.titanic.X.shape[0]) + def testCN2PrefersEquality(self): + learner = CN2Learner() + classifier = learner(self.titanic) + operators = [s.op for rule in classifier.rule_list for s in rule.selectors] + self.assertEqual(operators.count('!='), 4) + self.assertEqual(operators.count('=='), 23) + + def testCN2RestrictEquality(self): + learner = CN2Learner(restrict_equality=True) + classifier = learner(self.titanic) + operators = [s.op for rule in classifier.rule_list for s in rule.selectors] + self.assertEqual(operators.count('!='), 0) + def testUnorderedCN2Learner(self): learner = CN2UnorderedLearner() diff --git a/Orange/tests/test_score_feature.py b/Orange/tests/test_score_feature.py index 1e27c872e9a..60a92946299 100644 --- a/Orange/tests/test_score_feature.py +++ b/Orange/tests/test_score_feature.py @@ -114,12 +114,13 @@ def test_relieff(self): # some leeway for randomness in relieff random instance selection self.assertIn('tear_rate', found) # Ensure it doesn't crash on missing target class values - old_breast.Y[0] = np.nan + with old_breast.unlocked(): + old_breast.Y[0] = np.nan weights = ReliefF()(old_breast, None) np.testing.assert_array_equal( - ReliefF(random_state=1)(self.breast, None), - ReliefF(random_state=1)(self.breast, None) + ReliefF()(self.breast, None), + ReliefF()(self.breast, None) ) def test_rrelieff(self): @@ -167,3 +168,29 @@ def test_learner_with_transformation(self): data = PCA(n_components=2)(iris)(iris) scores = learner.score_data(data) np.testing.assert_almost_equal(scores, [[0.7760495, 0.2239505]]) + + def test_learner_transform_without_variable(self): + data = self.housing + + def preprocessor_random_column(data): + # a compute_value without .variable + def random_column(d): + return np.random.RandomState(42).rand(len(d)) + nat = ContinuousVariable("nat", compute_value=random_column) + ndom = Domain(data.domain.attributes + (nat,), data.domain.class_vars) + return data.transform(ndom) + + learner = RandomForestLearner(random_state=42, + preprocessors=[]) + scores1 = learner.score_data(preprocessor_random_column(data)) + + learner = RandomForestLearner(random_state=42, + preprocessors=[preprocessor_random_column]) + # the following line caused an infinite loop due to a bug fix in this commit + scores2 = learner.score_data(data) + + np.testing.assert_equal(scores1[0][:-1], scores2[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/tests/test_sgd.py b/Orange/tests/test_sgd.py index dbeb9d7c746..7197575c5fe 100644 --- a/Orange/tests/test_sgd.py +++ b/Orange/tests/test_sgd.py @@ -34,6 +34,9 @@ def test_coefficients(self): mod = lrn(Table("housing")) self.assertEqual(len(mod.coefficients), len(mod.domain.attributes)) + def test_supports_weights(self): + self.assertTrue(SGDRegressionLearner().supports_weights) + class TestSGDClassificationLearner(unittest.TestCase): @classmethod @@ -72,3 +75,6 @@ def test_predictions_shapes(self): mod = lrn(self.iris) self.assertTupleEqual((50, 3), mod(self.iris[:50], mod.Probs).shape) self.assertTupleEqual((50,), mod(self.iris[:50], mod.Value).shape) + + def test_supports_weights(self): + self.assertTrue(SGDClassificationLearner().supports_weights) diff --git a/Orange/tests/test_softmax_regression.py b/Orange/tests/test_softmax_regression.py index 87c554d68bf..77b40dd45db 100644 --- a/Orange/tests/test_softmax_regression.py +++ b/Orange/tests/test_softmax_regression.py @@ -23,8 +23,9 @@ def test_SoftmaxRegression(self): def test_SoftmaxRegressionPreprocessors(self): table = self.iris.copy() - table.X[:, 2] = table.X[:, 2] * 0.001 - table.X[:, 3] = table.X[:, 3] * 0.001 + with table.unlocked(): + table.X[:, 2] = table.X[:, 2] * 0.001 + table.X[:, 3] = table.X[:, 3] * 0.001 learners = [SoftmaxRegressionLearner(preprocessors=[]), SoftmaxRegressionLearner()] cv = CrossValidation(k=10) diff --git a/Orange/tests/test_sparse_table.py b/Orange/tests/test_sparse_table.py index d4a1dbcfee6..ae518348854 100644 --- a/Orange/tests/test_sparse_table.py +++ b/Orange/tests/test_sparse_table.py @@ -33,13 +33,15 @@ def test_value_assignment(self): def test_str(self): iris = Table('iris') - iris.X, iris.Y = csr_matrix(iris.X), csr_matrix(iris.Y) + with iris.unlocked(): + iris.X, iris.Y = csr_matrix(iris.X), csr_matrix(iris.Y) str(iris) def test_Y_setter_1d(self): iris = Table('iris') assert iris.Y.shape == (150,) - iris.Y = csr_matrix(iris.Y) + with iris.unlocked(): + iris.Y = csr_matrix(iris.Y) # We expect the Y shape to match the X shape, which is (150, 4) in iris self.assertEqual(iris.Y.shape, (150,)) @@ -48,8 +50,9 @@ def test_Y_setter_2d(self): assert iris.Y.shape == (150,) # Convert iris.Y to (150, 1) shape new_y = iris.Y[:, np.newaxis] - iris.Y = np.hstack((new_y, new_y)) - iris.Y = csr_matrix(iris.Y) + with iris.unlocked(): + iris.Y = np.hstack((new_y, new_y)) + iris.Y = csr_matrix(iris.Y) # We expect the Y shape to match the X shape, which is (150, 4) in iris self.assertEqual(iris.Y.shape, (150, 2)) @@ -57,7 +60,8 @@ def test_Y_setter_2d_single_instance(self): iris = Table('iris')[:1] # Convert iris.Y to (1, 1) shape new_y = iris.Y[:, np.newaxis] - iris.Y = np.hstack((new_y, new_y)) - iris.Y = csr_matrix(iris.Y) + with iris.unlocked_reference(): + iris.Y = np.hstack((new_y, new_y)) + iris.Y = csr_matrix(iris.Y) # We expect the Y shape to match the X shape, which is (1, 4) in iris self.assertEqual(iris.Y.shape, (1, 2)) diff --git a/Orange/tests/test_stack.py b/Orange/tests/test_stack.py index c52ff229d21..48ccdcbc1ed 100644 --- a/Orange/tests/test_stack.py +++ b/Orange/tests/test_stack.py @@ -1,7 +1,7 @@ import unittest from Orange.data import Table -from Orange.ensembles.stack import StackedFitter +from Orange.ensembles.stack import StackedFitter, StackedLearner from Orange.evaluation import CA, CrossValidation, MSE from Orange.modelling import KNNLearner, TreeLearner @@ -26,3 +26,16 @@ def test_regression(self): mse = MSE()(results) self.assertLess(mse[0], mse[1]) self.assertLess(mse[0], mse[2]) + + def test_timeseries(self): + def aggregate(data): + assert type(data) is Table + + class CustomTable(Table): + pass + + sl = StackedLearner([TreeLearner(), KNNLearner()], + aggregate=aggregate) + + data = CustomTable(self.iris) + sl(data) diff --git a/Orange/tests/test_statistics.py b/Orange/tests/test_statistics.py index e3807573d46..b6ed4d56814 100644 --- a/Orange/tests/test_statistics.py +++ b/Orange/tests/test_statistics.py @@ -1,4 +1,5 @@ # pylint: disable=no-self-use +import time import unittest import warnings from itertools import chain @@ -8,11 +9,13 @@ from scipy.sparse import csr_matrix, issparse, lil_matrix, csc_matrix, \ SparseEfficiencyWarning +from Orange.data import Table, Domain, ContinuousVariable from Orange.data.util import assure_array_dense +from Orange.statistics.distribution import get_distributions_for_columns from Orange.statistics.util import bincount, countnans, contingency, digitize, \ mean, nanmax, nanmean, nanmedian, nanmin, nansum, nanunique, stats, std, \ unique, var, nanstd, nanvar, nanmode, nan_to_num, FDR, isnan, any_nan, \ - all_nan + all_nan, nan_mean_var from sklearn.utils import check_random_state @@ -104,19 +107,26 @@ def test_stats(self): def test_stats_sparse(self): X = csr_matrix(np.identity(5)) - np.testing.assert_equal(stats(X), [[0, 1, .2, 0, 4, 1], - [0, 1, .2, 0, 4, 1], - [0, 1, .2, 0, 4, 1], - [0, 1, .2, 0, 4, 1], - [0, 1, .2, 0, 4, 1]]) + np.testing.assert_equal(stats(X), [[0, 1, .2, 0, 0, 5], + [0, 1, .2, 0, 0, 5], + [0, 1, .2, 0, 0, 5], + [0, 1, .2, 0, 0, 5], + [0, 1, .2, 0, 0, 5]]) # assure last two columns have just zero elements X = X[:3] - np.testing.assert_equal(stats(X), [[0, 1, 1/3, 0, 2, 1], - [0, 1, 1/3, 0, 2, 1], - [0, 1, 1/3, 0, 2, 1], - [0, 0, 0, 0, 3, 0], - [0, 0, 0, 0, 3, 0]]) + np.testing.assert_equal(stats(X), [[0, 1, 1/3, 0, 0, 3], + [0, 1, 1/3, 0, 0, 3], + [0, 1, 1/3, 0, 0, 3], + [0, 0, 0, 0, 0, 3], + [0, 0, 0, 0, 0, 3]]) + + r = stats(X, compute_variance=True) + np.testing.assert_almost_equal(r, [[0, 1, 1/3, 2/9, 0, 3], + [0, 1, 1/3, 2/9, 0, 3], + [0, 1, 1/3, 2/9, 0, 3], + [0, 0, 0, 0, 0, 3], + [0, 0, 0, 0, 0, 3]]) def test_stats_weights(self): X = np.arange(4).reshape(2, 2).astype(float) @@ -127,13 +137,28 @@ def test_stats_weights(self): X = np.arange(4).reshape(2, 2).astype(object) np.testing.assert_equal(stats(X, weights), stats(X)) + def test_stats_nans_neutral_weights(self): + X = np.arange(4).reshape(2, 2).astype(float) + X[0, 0] = np.nan + np.testing.assert_equal(stats(X, weights=np.array([1, 1])), stats(X)) + + def test_stats_nans_neutral_weights_sparse(self): + X = np.arange(4).reshape(2, 2).astype(float) + X = csr_matrix(X) + X[0, 0] = np.nan + np.testing.assert_equal(stats(X, weights=np.array([1, 1])), stats(X)) + def test_stats_weights_sparse(self): X = np.arange(4).reshape(2, 2).astype(float) X = csr_matrix(X) weights = np.array([1, 3]) - np.testing.assert_equal(stats(X, weights), [[0, 2, 1.5, 0, 1, 1], + np.testing.assert_equal(stats(X, weights), [[0, 2, 1.5, 0, 0, 2], [1, 3, 2.5, 0, 0, 2]]) + np.testing.assert_equal(stats(X, weights, compute_variance=True), + [[0, 2, 1.5, 0.75, 0, 2], + [1, 3, 2.5, 0.75, 0, 2]]) + def test_stats_non_numeric(self): X = np.array([ ["", "a", np.nan, 0], @@ -145,6 +170,76 @@ def test_stats_non_numeric(self): [np.inf, -np.inf, 0, 0, 2, 1], [np.inf, -np.inf, 0, 0, 0, 3]]) + def test_stats_nancounts(self): + arr = np.array([[1, 4, 9], + [-2, 10, 0], + [0, np.nan, np.nan], + [0, np.nan, 0]]) + + expected = [[-2, 1, -0.25, (1.25 ** 2 + 1.75 ** 2 + .25 ** 2 + .25 ** 2) / 4, 0, 4], + [4, 10, 7, 3 ** 2, 2, 2], + [0, 9, 3, (6 ** 2 + 3 ** 2 + 3 ** 2) / 3, 1, 3]] + np.testing.assert_almost_equal(stats(arr, compute_variance=True), expected) + + sparr = csc_matrix(arr) + np.testing.assert_almost_equal(stats(sparr, compute_variance=True), expected) + + sparr = sparr.tocsr() + np.testing.assert_almost_equal(stats(sparr, compute_variance=True), expected) + + weights = np.array([1, 2, 0, 3]) + e0 = (1 * 1 - 2 * 2 + 0 * 0 + 3 * 0) / (1 + 2 + 0 + 3) + e1 = (1 * 4 + 2 * 10) / 3 + e2 = (1 * 9 + 2 * 0 + 3 * 0) / 6 + expected = [[-2, 1, e0, ((e0 - 1) ** 2 + 2 * (e0 + 2) ** 2 + 3 * e0 ** 2) / 6, 0, 4], + [4, 10, e1, ((e1 - 4) ** 2 + 2 * (e1 - 10) ** 2) / 3, 2, 2], + [0, 9, e2, ((e2 - 9) ** 2 + 2 * e2 ** 2 + 3 * e2 ** 2) / 6, 1, 3]] + + np.testing.assert_almost_equal( + stats(arr, weights=weights, compute_variance=True), expected) + + sparr = csc_matrix(arr) + np.testing.assert_almost_equal( + stats(sparr, weights=weights, compute_variance=True), expected) + + sparr = sparr.tocsr() + np.testing.assert_almost_equal( + stats(sparr, weights=weights, compute_variance=True), expected) + + def test_stats_empty(self): + X = np.array([]) + np.testing.assert_equal(stats(X), [[np.inf, -np.inf, 0, 0, 0, 0]]) + + X = np.zeros((0,)) + np.testing.assert_equal(stats(X), [[np.inf, -np.inf, 0, 0, 0, 0]]) + + X = np.zeros((0, 4)) + np.testing.assert_equal(stats(X), [[np.inf, -np.inf, 0, 0, 0, 0]] * 4) + + + + def test_stats_long_string_mem_use(self): + X = np.full((1000, 1000), "a", dtype=object) + t = time.time() + stats(X) + t_a = time.time() - t # time for an array with constant-len strings + + # Add one very long string + X[0, 0] = "a"*2000 + + # The implementation of stats() in Orange 3.30.2 used .astype("str") + # internally. X.astype("str") would take ~1000x the memory as X, + # because its type would be " 2: raise AssertionError() + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/tests/test_table.py b/Orange/tests/test_table.py index db18026d09c..a86e27ba82a 100644 --- a/Orange/tests/test_table.py +++ b/Orange/tests/test_table.py @@ -3,8 +3,10 @@ import copy import os +import pickle import random import unittest +import warnings from unittest.mock import Mock, MagicMock, patch from itertools import chain from math import isnan @@ -15,11 +17,12 @@ import scipy.sparse as sp from Orange import data -from Orange.data import (filter, Unknown, Variable, Table, DiscreteVariable, +from Orange.data import (filter, Unknown, Table, DiscreteVariable, ContinuousVariable, Domain, StringVariable) from Orange.data.util import SharedComputeValue from Orange.tests import test_dirname -from Orange.data.table import _optimize_indices +from Orange.data.table import _optimize_indices, _select_from_selection, \ + _FromTableConversion class TableTestCase(unittest.TestCase): @@ -47,8 +50,6 @@ def test_filename(self): self.assertTrue(d.__file__.endswith("test2.tab")) # platform dependent def test_indexing(self): - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") @@ -111,8 +112,6 @@ def test_indexing(self): self.assertEqual(d[np.int_(0)][np.int_(metae)], "i") def test_indexing_example(self): - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") @@ -149,8 +148,6 @@ def test_indexing_example(self): self.assertEqual(e[np.int_(metae)], "i") def test_indexing_assign_value(self): - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") @@ -160,48 +157,65 @@ def test_indexing_assign_value(self): metaa = d.domain.index("a") self.assertEqual(d[0, "a"], "A") - d[0, "a"] = "B" + + with d.unlocked(): + d[0, "a"] = "B" self.assertEqual(d[0, "a"], "B") - d[0]["a"] = "A" + with d.unlocked(): + d[0]["a"] = "A" self.assertEqual(d[0, "a"], "A") - d[0, vara] = "B" + with d.unlocked(): + d[0, vara] = "B" self.assertEqual(d[0, "a"], "B") - d[0][vara] = "A" + with d.unlocked(): + d[0][vara] = "A" self.assertEqual(d[0, "a"], "A") - d[0, metaa] = "B" + with d.unlocked(): + d[0, metaa] = "B" self.assertEqual(d[0, "a"], "B") - d[0][metaa] = "A" + with d.unlocked(): + d[0][metaa] = "A" self.assertEqual(d[0, "a"], "A") - d[0, np.int_(metaa)] = "B" + with d.unlocked(): + d[0, np.int_(metaa)] = "B" self.assertEqual(d[0, "a"], "B") - d[0][np.int_(metaa)] = "A" + with d.unlocked(): + d[0][np.int_(metaa)] = "A" self.assertEqual(d[0, "a"], "A") # regular varb = d.domain["b"] self.assertEqual(d[0, "b"], 0) - d[0, "b"] = 42 + with d.unlocked(): + d[0, "b"] = 42 self.assertEqual(d[0, "b"], 42) - d[0]["b"] = 0 + with d.unlocked(): + d[0]["b"] = 0 self.assertEqual(d[0, "b"], 0) - d[0, varb] = 42 + with d.unlocked(): + d[0, varb] = 42 self.assertEqual(d[0, "b"], 42) - d[0][varb] = 0 + with d.unlocked(): + d[0][varb] = 0 self.assertEqual(d[0, "b"], 0) - d[0, 0] = 42 + with d.unlocked(): + d[0, 0] = 42 self.assertEqual(d[0, "b"], 42) - d[0][0] = 0 + with d.unlocked(): + d[0][0] = 0 self.assertEqual(d[0, "b"], 0) - d[0, np.int_(0)] = 42 + with d.unlocked(): + d[0, np.int_(0)] = 42 self.assertEqual(d[0, "b"], 42) - d[0][np.int_(0)] = 0 + with d.unlocked(): + d[0][np.int_(0)] = 0 self.assertEqual(d[0, "b"], 0) def test_indexing_assign_example(self): @@ -209,44 +223,48 @@ def almost_equal_list(s, t): for e, f in zip(s, t): self.assertAlmostEqual(e, f) - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") self.assertFalse(isnan(d[0, "a"])) - d[0] = ["3.14", "1", "f"] + with d.unlocked(): + d[0] = ["3.14", "1", "f"] almost_equal_list(d[0].values(), [3.14, "1", "f"]) self.assertTrue(isnan(d[0, "a"])) - d[0] = [3.15, 1, "t"] + + with d.unlocked(): + d[0] = [3.15, 1, "t"] almost_equal_list(d[0].values(), [3.15, "0", "t"]) - d[np.int_(0)] = [3.15, 2, "f"] + + with d.unlocked(): + d[np.int_(0)] = [3.15, 2, "f"] almost_equal_list(d[0].values(), [3.15, 2, "f"]) - with self.assertRaises(ValueError): + with d.unlocked(), self.assertRaises(ValueError): d[0] = ["3.14", "1"] - with self.assertRaises(ValueError): + with d.unlocked(), self.assertRaises(ValueError): d[np.int_(0)] = ["3.14", "1"] ex = data.Instance(d.domain, ["3.16", "1", "f"]) - d[0] = ex + with d.unlocked(): + d[0] = ex almost_equal_list(d[0].values(), [3.16, "1", "f"]) ex = data.Instance(d.domain, ["3.16", 2, "t"]) - d[np.int_(0)] = ex + with d.unlocked(): + d[np.int_(0)] = ex almost_equal_list(d[0].values(), [3.16, 2, "t"]) ex = data.Instance(d.domain, ["3.16", "1", "f"]) ex["e"] = "mmmapp" - d[0] = ex + with d.unlocked(): + d[0] = ex almost_equal_list(d[0].values(), [3.16, "1", "f"]) self.assertEqual(d[0, "e"], "mmmapp") def test_slice(self): - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") @@ -267,27 +285,27 @@ def test_slice(self): self.assertEqual([e[0] for e in x], [2.26, 3.333, Unknown]) def test_assign_slice_value(self): - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") - d[2:5, 0] = 42 + with d.unlocked(): + d[2:5, 0] = 42 self.assertEqual([e[0] for e in d], [0, 1.1, 42, 42, 42, 2.25, 2.26, 3.333, Unknown]) - d[:3, "b"] = 43 + with d.unlocked(): + d[:3, "b"] = 43 self.assertEqual([e[0] for e in d], [43, 43, 43, 42, 42, 2.25, 2.26, 3.333, None]) - d[-2:, d.domain[0]] = 44 + with d.unlocked(): + d[-2:, d.domain[0]] = 44 self.assertEqual([e[0] for e in d], [43, 43, 43, 42, 42, 2.25, 2.26, 44, 44]) - d[2:5, "a"] = "A" + with d.unlocked(): + d[2:5, "a"] = "A" self.assertEqual([e["a"] for e in d], list("ABAAACCDE")) def test_multiple_indices(self): - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") @@ -302,29 +320,28 @@ def test_multiple_indices(self): self.assertEqual([e[0] for e in x], [2.22, 2.25, 1.1]) def test_assign_multiple_indices_value(self): - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") - d[1:4, "b"] = 42 + with d.unlocked(): + d[1:4, "b"] = 42 self.assertEqual([e[0] for e in d], [0, 42, 42, 42, 2.24, 2.25, 2.26, 3.333, None]) - d[range(5, 2, -1), "b"] = None + with d.unlocked(): + d[range(5, 2, -1), "b"] = None self.assertEqual([e[d.domain[0]] for e in d], [0, 42, 42, None, "?", "", 2.26, 3.333, None]) def test_set_multiple_indices_example(self): - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore") d = data.Table("datasets/test2") vals = [e[0] for e in d] - d[[1, 2, 5]] = [42, None, None] + with d.unlocked(): + d[[1, 2, 5]] = [42, None, None] vals[1] = vals[2] = vals[5] = 42 self.assertEqual([e[0] for e in d], vals) @@ -340,16 +357,23 @@ def test_bool(self): def test_checksum(self): d = data.Table("zoo") - d[42, 3] = 0 + with d.unlocked(): + d[42, 3] = 0 crc1 = d.checksum(False) - d[42, 3] = 1 + + with d.unlocked(): + d[42, 3] = 1 crc2 = d.checksum(False) self.assertNotEqual(crc1, crc2) - d[42, 3] = 0 + + with d.unlocked(): + d[42, 3] = 0 crc3 = d.checksum(False) self.assertEqual(crc1, crc3) + _ = d[42, "name"] - d[42, "name"] = "non-animal" + with d.unlocked(): + d[42, "name"] = "non-animal" crc4 = d.checksum(False) self.assertEqual(crc1, crc4) crc4 = d.checksum(True) @@ -363,10 +387,11 @@ def test_total_weight(self): d = data.Table("zoo") self.assertEqual(d.total_weight(), len(d)) - d.set_weights(0) - d[0].weight = 0.1 - d[10].weight = 0.2 - d[-1].weight = 0.3 + with d.unlocked(): + d.set_weights(0) + d[0].weight = 0.1 + d[10].weight = 0.2 + d[-1].weight = 0.3 self.assertAlmostEqual(d.total_weight(), 0.6) def test_has_missing(self): @@ -374,15 +399,18 @@ def test_has_missing(self): self.assertFalse(d.has_missing()) self.assertFalse(d.has_missing_class()) - d[10, 3] = "?" + with d.unlocked(): + d[10, 3] = "?" self.assertTrue(d.has_missing()) self.assertFalse(d.has_missing_class()) - d[10].set_class("?") + with d.unlocked(): + d[10].set_class("?") self.assertTrue(d.has_missing()) self.assertTrue(d.has_missing_class()) - d = data.Table("datasets/test3") + with d.unlocked(): + d = data.Table("datasets/test3") self.assertFalse(d.has_missing()) self.assertFalse(d.has_missing_class()) @@ -390,22 +418,28 @@ def test_shuffle(self): d = data.Table("zoo") crc = d.checksum() names = set(str(x["name"]) for x in d) + ids = d.ids - d.shuffle() + with d.unlocked_reference(): + d.shuffle() self.assertNotEqual(crc, d.checksum()) self.assertSetEqual(names, set(str(x["name"]) for x in d)) + self.assertTrue(np.any(ids - d.ids != 0)) crc2 = d.checksum() x = d[2:10] crcx = x.checksum() - d.shuffle() + with d.unlocked_reference(): + d.shuffle() self.assertNotEqual(crc2, d.checksum()) self.assertEqual(crcx, x.checksum()) crc2 = d.checksum() - x.shuffle() + with x.unlocked_reference(): + x.shuffle() self.assertNotEqual(crcx, x.checksum()) self.assertEqual(crc2, d.checksum()) + self.assertLess(set(x.ids), set(ids)) @staticmethod def not_less_ex(ex1, ex2): @@ -443,7 +477,8 @@ def test_copy(self): self.assertTrue(np.all(t.X == copy.X)) self.assertTrue(np.all(t.Y == copy.Y)) self.assertTrue(np.all(t.metas == copy.metas)) - copy[0] = [1, 1, 1, 1, 1, 1, 1, 1] + with copy.unlocked(): + copy[0] = [1, 1, 1, 1] self.assertFalse(np.all(t.X == copy.X)) self.assertFalse(np.all(t.Y == copy.Y)) self.assertFalse(np.all(t.metas == copy.metas)) @@ -461,8 +496,13 @@ def test_copy_sparse(self): self.assertNotEqual(id(t.metas), id(copy.metas)) # ensure that copied sparse arrays do not share data - t.X[0, 0] = 42 + # and that both are unlockable + with t.unlocked(): + t.X[0, 0] = 42 self.assertEqual(copy.X[0, 0], 5.1) + with copy.unlocked(): + copy.X[0, 0] = 43 + self.assertEqual(t.X[0, 0], 42) def test_concatenate(self): d1 = data.Domain( @@ -528,7 +568,8 @@ def test_concatenate(self): self.assertEqual(t123.name, "t2") self.assertEqual(t123.attributes, {"a": 42, "c": 43, "b": 45}) - t2.Y = np.atleast_2d(t2.Y).T + with t2.unlocked(t2.Y): + t2.Y = np.atleast_2d(t2.Y).T t12 = data.Table.concatenate((t1, t2)) self.assertEqual(t12.domain, t1.domain) np.testing.assert_almost_equal(t12.X, np.vstack((x1, x2))) @@ -549,7 +590,8 @@ def test_concatenate_exceptions(self): def test_concatenate_sparse(self): iris = Table("iris") - iris.X = sp.csc_matrix(iris.X) + with iris.unlocked(): + iris.X = sp.csc_matrix(iris.X) new = Table.concatenate([iris, iris]) self.assertEqual(len(new), 300) self.assertTrue(sp.issparse(new.X), "Concatenated X is not sparse.") @@ -558,8 +600,6 @@ def test_concatenate_sparse(self): self.assertEqual(len(new.ids), 300) def test_pickle(self): - import pickle - d = data.Table("zoo") s = pickle.dumps(d) d2 = pickle.loads(s) @@ -575,6 +615,20 @@ def test_pickle(self): self.assertEqual(d.checksum(include_metas=False), d2.checksum(include_metas=False)) + def test_pickle_setstate(self): + d = data.Table("zoo") + s = pickle.dumps(d) + with patch("Orange.data.Table.__setstate__", Mock()) as mock: + pickle.loads(s) + state = mock.call_args[0][0] + for k in ["X", "_Y", "metas"]: + self.assertIn(k, state) + self.assertEqual(state[k].ndim, 2) + self.assertIn("W", state) + for k in ["_X", "Y", "_metas", "_W"]: + self.assertNotIn(k, state) + + def test_translate_through_slice(self): d = data.Table("iris") dom = data.Domain(["petal length", "sepal length", "iris"], @@ -639,7 +693,8 @@ def test_saveTab(self): os.remove("test-zoo.tab.metadata") d = data.Table("zoo") - d.set_weights(range(len(d))) + with d.unlocked(): + d.set_weights(range(len(d))) d.save("test-zoo-weights.tab") dd = data.Table("test-zoo-weights") try: @@ -668,27 +723,40 @@ def test_save_pickle(self): finally: os.remove("iris.pickle") + def test_read_pickle_ids(self): + table = data.Table("iris") + try: + table.save("iris.pickle") + table1 = data.Table.from_file("iris.pickle") + table2 = data.Table.from_file("iris.pickle") + self.assertEqual(len(set(table1.ids) | set(table2.ids)), 300) + finally: + os.remove("iris.pickle") + def test_from_numpy(self): - a = np.arange(20, dtype="d").reshape((4, 5)) + a = np.arange(20, dtype="d").reshape((4, 5)).copy() + m = np.arange(4, dtype="d").reshape((4, 1)).copy() a[:, -1] = [0, 0, 0, 1] dom = data.Domain([data.ContinuousVariable(x) for x in "abcd"], - data.DiscreteVariable("e", values=("no", "yes"))) - table = data.Table(dom, a) - for i in range(4): - self.assertEqual(table[i].get_class(), "no" if i < 3 else "yes") - for j in range(5): - self.assertEqual(a[i, j], table[i, j]) - table[i, j] = random.random() - self.assertEqual(a[i, j], table[i, j]) - - with self.assertRaises(IndexError): - table[0, -5] = 5 + data.DiscreteVariable("e", values=("no", "yes")), + metas=[data.ContinuousVariable(x) for x in "f"]) + table = data.Table(dom, a, metas=m) + with table.unlocked(): + for i in range(4): + self.assertEqual(table[i].get_class(), "no" if i < 3 else "yes") + for j in range(5): + self.assertEqual(a[i, j], table[i, j]) + + with table.unlocked(), self.assertRaises(IndexError): + table[0, -6] = 5 def test_filter_is_defined(self): d = data.Table("iris") - d[1, 4] = Unknown + with d.unlocked(): + d[1, 4] = Unknown self.assertTrue(isnan(d[1, 4])) - d[140, 0] = Unknown + with d.unlocked(): + d[140, 0] = Unknown e = filter.IsDefined()(d) self.assertEqual(len(e), len(d) - 2) self.assertEqual(e[0], d[0]) @@ -699,9 +767,11 @@ def test_filter_is_defined(self): def test_filter_has_class(self): d = data.Table("iris") - d[1, 4] = Unknown + with d.unlocked(): + d[1, 4] = Unknown self.assertTrue(isnan(d[1, 4])) - d[140, 0] = Unknown + with d.unlocked(): + d[140, 0] = Unknown e = filter.HasClass()(d) self.assertEqual(len(e), len(d) - 1) self.assertEqual(e[0], d[0]) @@ -817,7 +887,8 @@ def test_filter_value_continuous(self): x = filter.Values([f])(d) self.assertEqual(len(x), len(d)) - d[:30, v.petal_length] = Unknown + with d.unlocked(): + d[:30, v.petal_length] = Unknown x = filter.Values([f])(d) self.assertEqual(len(x), len(d) - 30) @@ -893,7 +964,8 @@ def test_valueFilter_discrete(self): f = filter.FilterDiscrete(v.hair, values=None) self.assertEqual(len(filter.Values([f])(d)), len(d)) - d[:5, v.hair] = Unknown + with d.unlocked(): + d[:5, v.hair] = Unknown self.assertEqual(len(filter.Values([f])(d)), len(d) - 5) def test_valueFilter_string_is_defined(self): @@ -979,7 +1051,8 @@ def test_valueFilter_string_case_sens(self): def test_valueFilter_string_case_insens(self): d = data.Table("zoo") - d[d[:, "name"].metas[:, 0] == "girl", "name"] = "GIrl" + with d.unlocked(): + d[d[:, "name"].metas[:, 0] == "girl", "name"] = "GIrl" col = d[:, "name"].metas[:, 0] @@ -1103,7 +1176,8 @@ def test_attributes(self): table2 = table[:4] self.assertEqual(table2.attributes[1], "test") table2.attributes[1] = "modified" - self.assertEqual(table.attributes[1], "modified") + self.assertEqual(table.attributes[1], "test") + self.assertEqual(table2.attributes[1], "modified") # TODO Test conjunctions and disjunctions of conditions @@ -1111,13 +1185,18 @@ def test_is_sparse(self): table = data.Table("iris") self.assertFalse(table.is_sparse()) - table.X = sp.csr_matrix(table.X) - self.assertTrue(table.is_sparse()) + with table.unlocked(): + table.X = sp.csr_matrix(table.X) + self.assertTrue(table.is_sparse()) def test_repr_sparse_with_one_row(self): table = data.Table("iris")[:1] - table.X = sp.csr_matrix(table.X) - repr(table) # make sure repr does not crash + with table.unlocked_reference(): + table.X = sp.csr_matrix(table.X) + r = repr(table) # make sure repr does not crash + self.assertEqual(r.replace("\n", ""), + "[[sepal length=5.1, sepal width=3.5, " + "petal length=1.4, petal width=0.2 | Iris-setosa]]") def test_inf(self): a = np.array([[2, 0, 0, 0], @@ -1127,6 +1206,34 @@ def test_inf(self): tab = data.Table.from_numpy(None, a) self.assertEqual(tab.get_nan_frequency_attribute(), 3/12) + def test_str(self): + iris = Table("iris") + # instance + self.assertEqual("[5.1, 3.5, 1.4, 0.2 | Iris-setosa]", str(iris[0])) + # table + table_str = str(iris) + lines = table_str.split('\n') + self.assertEqual(150, len(lines)) + self.assertEqual("[[5.1, 3.5, 1.4, 0.2 | Iris-setosa],", lines[0]) + self.assertEqual(" [5.9, 3.0, 5.1, 1.8 | Iris-virginica]]", lines[-1]) + + def test_str_sparse(self): + iris = Table("iris") + with iris.unlocked_reference(): + iris.X = sp.csr_matrix(iris.X) + # instance + s0 = "[sepal length=5.1, sepal width=3.5, " \ + "petal length=1.4, petal width=0.2 | Iris-setosa]" + self.assertEqual(s0, str(iris[0])) + # table + table_str = str(iris) + lines = table_str.split('\n') + self.assertEqual(150, len(lines)) + self.assertEqual("[" + s0 + ",", lines[0]) + slast = "[sepal length=5.9, sepal width=3.0, " \ + "petal length=5.1, petal width=1.8 | Iris-virginica]" + self.assertEqual(" " + slast + "]", lines[-1]) + def column_sizes(table): return (len(table.domain.attributes), @@ -1141,6 +1248,15 @@ class TableTests(unittest.TestCase): nrows = 10 row_indices = (1, 5, 7, 9) + @classmethod + def setUpClass(cls): + cls.saved_max_rows_at_once = _FromTableConversion.max_rows_at_once + _FromTableConversion.max_rows_at_once = cls.nrows - 1 + + @classmethod + def tearDownClass(cls): + _FromTableConversion.max_rows_at_once = cls.saved_max_rows_at_once + def setUp(self): self.data = np.random.random((self.nrows, len(self.attributes))) self.class_data = np.random.random((self.nrows, len(self.class_vars))) @@ -1152,11 +1268,13 @@ def setUp(self): def mock_domain(self, with_classes=False, with_metas=False): attributes = self.attributes + class_var = self.class_vars[0] if with_classes else None class_vars = self.class_vars if with_classes else [] metas = self.metas if with_metas else [] variables = attributes + class_vars return MagicMock(data.Domain, attributes=attributes, + class_var=class_var, class_vars=class_vars, metas=metas, variables=variables) @@ -1632,24 +1750,38 @@ def test_can_filter_rows_with_list(self): self.assert_table_with_filter_matches( new_table, self.table, rows=indices) - @patch.object(Table, "from_table_rows", wraps=Table.from_table_rows) - def test_can_filter_row_with_slice_from_table_rows(self, from_table_rows): + def test_can_filter_row_with_slice_from_table_rows(self): # calling from_table with the same domain will forward to from_table_rows - for slice_ in self.interesting_slices: - from_table_rows.reset_mock() - new_table = data.Table.from_table( - self.domain, self.table, row_indices=slice_) - self.assert_table_with_filter_matches( - new_table, self.table, rows=slice_) - from_table_rows.assert_called() + # and thus _FromTableConversion.convert should not be called + with patch.object(_FromTableConversion, "convert") as convert: + for slice_ in self.interesting_slices: + new_table = data.Table.from_table( + self.domain, self.table, row_indices=slice_) + self.assert_table_with_filter_matches( + new_table, self.table, rows=slice_) + convert.assert_not_called() def test_can_filter_row_with_slice_from_table(self): - # calling from_table with a domain copy will use indexing in from_table - for slice_ in self.interesting_slices: - new_table = data.Table.from_table( - self.domain.copy(), self.table, row_indices=slice_) - self.assert_table_with_filter_matches( - new_table, self.table, rows=slice_) + + # a utility class needed for mocking of the convert method in this case + class MockedConversion(_FromTableConversion): + objects = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.convert = Mock(wraps=self.convert) + self.objects.append(self) + + # calling from_table with a domain copy will use indexing in from_table and + # _FromTableConversion.convert + with patch("Orange.data.table._FromTableConversion", MockedConversion): + for slice_ in self.interesting_slices: + new_table = data.Table.from_table( + self.domain.copy(), self.table, row_indices=slice_) + self.assert_table_with_filter_matches( + new_table, self.table, rows=slice_) + self.assertEqual(len(MockedConversion.objects), 1) + MockedConversion.objects.clear() def test_can_use_attributes_as_new_columns(self): a, _, _ = column_sizes(self.table) @@ -1721,6 +1853,36 @@ def test_creates_table_with_given_domain_and_row_filter(self): self.assert_table_with_filter_matches( new_table, self.table[:0], xcols=order[:a], ycols=order[a:a+c], mcols=order[a+c:]) + def test_from_table_with_boolean_row_filter(self): + a, c, m = column_sizes(self.table) + domain = self.table.domain + + sel = [False]*len(self.table) + sel[2] = True + + with patch.object(Table, "from_table_rows", wraps=Table.from_table_rows) \ + as from_table_rows: + new_table = Table.from_table(self.table.domain, self.table, row_indices=sel) + from_table_rows.assert_called() + self.assert_table_with_filter_matches( + new_table, self.table[2:3]) + + new_domain1 = Domain(domain.attributes[:1], domain.class_vars[:1], domain.metas[:1]) + with patch.object(Table, "from_table_rows", wraps=Table.from_table_rows) \ + as from_table_rows: + new_table = Table.from_table(new_domain1, self.table, row_indices=sel) + from_table_rows.assert_not_called() + self.assert_table_with_filter_matches( + new_table, self.table[2:3], + xcols=[0], ycols=[a], mcols=[a+c+m-1]) + + new_domain2 = Domain(domain.attributes[:1] + (ContinuousVariable("new"),), + domain.class_vars[:1], domain.metas[:1]) + new_table = Table.from_table(new_domain2, self.table, row_indices=sel) + self.assert_table_with_filter_matches( + new_table.transform(new_domain1), self.table[2:3], + xcols=[0], ycols=[a], mcols=[a+c+m-1]) + def test_from_table_sparse_move_some_to_empty_metas(self): iris = data.Table("iris").to_sparse() new_domain = data.domain.Domain( @@ -1785,6 +1947,63 @@ def test_from_table_sparse_move_to_nonempty_metas(self): self.assertEqual(back_brown.X.shape, brown.X.shape) self.assertEqual(back_brown.metas.shape, brown.metas.shape) + def test_from_table_partwise(self): + def sum_x(d): + return d.X.sum(axis=1) + sum_x = Mock(wraps=sum_x) + + def sum_y(d): + return d.Y.sum(axis=1) + sum_y = Mock(wraps=sum_y) + + def sum_metas(d): + return d.metas.sum(axis=1) + sum_metas = Mock(wraps=sum_metas) + + sum_x_var = ContinuousVariable("sum_x", compute_value=sum_x) + sum_y_var = ContinuousVariable("sum_y", compute_value=sum_y) + sum_metas_var = ContinuousVariable("sum_metas", compute_value=sum_metas) + target_domain = Domain([sum_x_var], [sum_y_var], [sum_metas_var]) + + def assure_sum(): + np.testing.assert_equal(orig.X.sum(axis=1), transformed.X[:,0]) + np.testing.assert_equal(orig.Y.sum(axis=1), transformed.Y) + np.testing.assert_equal(orig.metas.sum(axis=1), transformed.metas[:,0]) + + def long_table(rows): + avars = [ContinuousVariable(n) for n in "abcdef"] + vals = np.random.RandomState(0).random((rows, 6)) + domain = Domain(avars[:2], avars[2:4], avars[4:]) + return Table.from_numpy(domain, X=vals[:, :2], Y=vals[:, 2:4], metas=vals[:, 4:]) + + max_rows = _FromTableConversion.max_rows_at_once + + # domain conversion fits into a single part + orig = long_table(max_rows) + transformed = Table.from_table(target_domain, orig) + assure_sum() + sum_x.assert_called_once() + sum_y.assert_called_once() + sum_metas.assert_called_once() + + sum_x.reset_mock() + sum_y.reset_mock() + sum_metas.reset_mock() + + # domain conversion does not fit a single part + orig = long_table(max_rows + 1) + transformed = Table.from_table(target_domain, orig) + assure_sum() + self.assertEqual(sum_x.call_count, 2) + self.assertEqual(sum_y.call_count, 2) + self.assertEqual(sum_metas.call_count, 2) + self.assertEqual(len(sum_x.call_args_list[0][0][0]), max_rows) + self.assertEqual(len(sum_x.call_args_list[1][0][0]), 1) + self.assertEqual(len(sum_y.call_args_list[0][0][0]), max_rows) + self.assertEqual(len(sum_y.call_args_list[1][0][0]), 1) + self.assertEqual(len(sum_metas.call_args_list[0][0][0]), max_rows) + self.assertEqual(len(sum_metas.call_args_list[1][0][0]), 1) + def test_from_table_shared_compute_value(self): iris = data.Table("iris").to_sparse() d1 = Domain( @@ -1827,6 +2046,46 @@ def assert_table_with_filter_matches( np.testing.assert_almost_equal(new_table.metas, magic[rows, mcols]) np.testing.assert_almost_equal(new_table.W, old_table.W[rows]) + def test_attributes_copied(self): + """Table created from table attributes dict copied""" + self.table.attributes = {"A": "Test", "B": []} + + # from_table + new_table = self.table.from_table(self.table.domain, self.table) + self.assertDictEqual(new_table.attributes, {"A": "Test", "B": []}) + new_table.attributes["A"] = "Changed" + new_table.attributes["B"].append(1) + self.assertDictEqual(new_table.attributes, {"A": "Changed", "B": [1]}) + # attributes dict of old table not be changed since new dist is a copy + self.assertDictEqual(self.table.attributes, {"A": "Test", "B": []}) + + # from_table_rows + new_table = self.table.from_table_rows(self.table, [1, 2]) + self.assertDictEqual(new_table.attributes, {"A": "Test", "B": []}) + new_table.attributes["A"] = "Changed" + new_table.attributes["B"].append(1) + self.assertDictEqual(new_table.attributes, {"A": "Changed", "B": [1]}) + # attributes dict of old table not be changed since new dist is a copy + self.assertDictEqual(self.table.attributes, {"A": "Test", "B": []}) + + def test_attributes_copied_once(self): + A = Mock() + A.__deepcopy__ = Mock() + self.table.attributes = {"A": A} + + # a single direct transformation + self.table.from_table(self.table.domain, self.table) + self.assertEqual(1, A.__deepcopy__.call_count) + A.__deepcopy__.reset_mock() + + # hierarchy of transformations + ndom = Domain([a.copy(compute_value=lambda x: x.transform(Domain([a]))) + for a in self.table.domain.attributes]) + self.table.from_table(ndom, self.table) + self.assertEqual(1, A.__deepcopy__.call_count) + # HISTORIC: before only the outermost transformation deepcopied the + # attributes, here were 23 calls to __deepcopy__ instead of 1 + def isspecial(s): return isinstance(s, slice) or s is Ellipsis @@ -1900,7 +2159,6 @@ def test_can_select_a_single_row(self): np.testing.assert_almost_equal( np.array(list(row)), new_row) - def test_can_select_a_subset_of_rows_and_columns(self): for r in self.rows: for c in self.multiple_columns: @@ -1933,7 +2191,6 @@ def test_can_select_a_subset_of_rows_and_columns(self): np.testing.assert_almost_equal(table.metas, self.table.metas[r, metas]) - def test_optimize_indices(self): # ordinary conversion self.assertEqual(_optimize_indices([1, 2, 3], 4), slice(1, 4, 1)) @@ -1944,8 +2201,14 @@ def test_optimize_indices(self): np.testing.assert_equal(_optimize_indices([1, 2, 4], 5), [1, 2, 4]) np.testing.assert_equal(_optimize_indices((1, 2, 4), 5), [1, 2, 4]) - # leave boolean arrays - np.testing.assert_equal(_optimize_indices([True, False, True], 3), [True, False, True]) + # internally convert boolean arrays into indices + np.testing.assert_equal(_optimize_indices([False, False, False, False], 4), []) + np.testing.assert_equal(_optimize_indices([True, False, True, True], 4), [0, 2, 3]) + np.testing.assert_equal(_optimize_indices([True, False, True], 3), slice(0, 4, 2)) + with self.assertRaises(IndexError): + _optimize_indices([True, False, True], 2) + with self.assertRaises(IndexError): + _optimize_indices([True, False, True], 4) # do not convert if step is negative np.testing.assert_equal(_optimize_indices([4, 2, 0], 5), [4, 2, 0]) @@ -1961,6 +2224,23 @@ def test_optimize_indices(self): self.assertEqual(_optimize_indices([1], 2), slice(1, 2, 1)) self.assertEqual(_optimize_indices([-2], 5), slice(-2, -3, -1)) + def test_select_from_selection(self): + fn = _select_from_selection + self.assertEqual(fn(slice(10), slice(11), 10), + slice(0, 10, 1)) + self.assertEqual(fn(slice(10), slice(None, 10, 2), 10), + slice(0, 10, 2)) + self.assertEqual(fn(slice(None, 10, 2), slice(None, 10, 2), 10), + slice(0, 10, 4)) + self.assertEqual(fn(slice(None, None, -1), slice(0, 9, None), 10), + slice(9, 0, -1)) # [9, 8, 7, 6, 5, 4, 3, 2, 1] + self.assertEqual(fn(slice(None, None, -1), slice(9, 10, None), 10), + slice(0, None, -1)) # [0] + self.assertEqual(fn(slice(None, 10, 2), slice(None, None, -1), 10), + slice(8, None, -2)) + self.assertEqual(fn(slice(None, 10, 2), slice(None, None, -2), 10), + slice(8, None, -4)) + class TableElementAssignmentTest(TableTests): def setUp(self): @@ -1971,20 +2251,24 @@ def setUp(self): data.Table(self.domain, self.data, self.class_data, self.meta_data) def test_can_assign_values(self): - self.table[0, 0] = 42. + with self.table.unlocked(): + self.table[0, 0] = 42. self.assertAlmostEqual(self.table.X[0, 0], 42.) def test_can_assign_values_to_classes(self): a, _, _ = column_sizes(self.table) - self.table[0, a] = 42. + with self.table.unlocked(): + self.table[0, a] = 42. self.assertAlmostEqual(self.table.Y[0], 42.) def test_can_assign_values_to_metas(self): - self.table[0, -1] = 42. + with self.table.unlocked(): + self.table[0, -1] = 42. self.assertAlmostEqual(self.table.metas[0, 0], 42.) def test_can_assign_rows_to_rows(self): - self.table[0] = self.table[1] + with self.table.unlocked(): + self.table[0] = self.table[1] np.testing.assert_almost_equal( self.table.X[0], self.table.X[1]) np.testing.assert_almost_equal( @@ -1996,7 +2280,8 @@ def test_can_assign_lists(self): a, _, _ = column_sizes(self.table) new_example = [float(i) for i in range(len(self.attributes + self.class_vars))] - self.table[0] = new_example + with self.table.unlocked(): + self.table[0] = new_example np.testing.assert_almost_equal( self.table.X[0], np.array(new_example[:a])) np.testing.assert_almost_equal( @@ -2007,7 +2292,8 @@ def test_can_assign_np_array(self): new_example = \ np.array([float(i) for i in range(len(self.attributes + self.class_vars))]) - self.table[0] = new_example + with self.table.unlocked(): + self.table[0] = new_example np.testing.assert_almost_equal(self.table.X[0], new_example[:a]) np.testing.assert_almost_equal(self.table.Y[0], new_example[a:]) @@ -2081,56 +2367,75 @@ def test_value_indexing(self): def test_row_assignment(self): new_value = 2. - for i in range(self.nrows): - new_row = [new_value] * len(self.data[i]) - self.table[i] = np.array(new_row) - self.assertEqual(list(self.table[i]), new_row) + with self.table.unlocked(): + for i in range(self.nrows): + new_row = [new_value] * len(self.data[i]) + self.table[i] = np.array(new_row) + self.assertEqual(list(self.table[i]), new_row) def test_value_assignment(self): new_value = 0. - for i in range(self.nrows): - for j in range(len(self.table[i])): - self.table[i, j] = new_value - self.assertEqual(self.table[i, j], new_value) - - def test_subclasses(self): - from pathlib import Path - - class _ExtendedTable(data.Table): - pass - - data_file = _ExtendedTable('iris') - data_url = _ExtendedTable.from_url( - Path(os.path.dirname(__file__), 'datasets/test1.tab').as_uri()) - - self.assertIsInstance(data_file, _ExtendedTable) - self.assertIsInstance(data_url, _ExtendedTable) + with self.table.unlocked(): + for i in range(self.nrows): + for j in range(len(self.table[i])): + self.table[i, j] = new_value + self.assertEqual(self.table[i, j], new_value) class TestTableStats(TableTests): def test_get_nan_frequency(self): + metas = [DiscreteVariable("x", values=tuple("abc")), StringVariable("s")] + meta_data = np.array([list(range(self.nrows)), ["x"] * self.nrows]).T domain = self.create_domain(self.attributes, self.class_vars) - table = data.Table(domain, self.data, self.class_data) - self.assertEqual(table.get_nan_frequency_attribute(), 0) - self.assertEqual(table.get_nan_frequency_class(), 0) - - table.X[1, 2] = table.X[4, 5] = np.nan - self.assertEqual(table.get_nan_frequency_attribute(), 2 / table.X.size) - self.assertEqual(table.get_nan_frequency_class(), 0) - - table.Y[3:6] = np.nan - self.assertEqual(table.get_nan_frequency_attribute(), 2 / table.X.size) - self.assertEqual(table.get_nan_frequency_class(), 3 / table.Y.size) - - table.X[1, 2] = table.X[4, 5] = 0 - self.assertEqual(table.get_nan_frequency_attribute(), 0) - self.assertEqual(table.get_nan_frequency_class(), 3 / table.Y.size) + domain = Domain(domain.attributes, domain.class_vars, metas) + table = data.Table(domain, self.data, self.class_data, meta_data) + + def test_counts(at, cl, me): + x, y, metas = table.X, table.Y, table.metas + for _ in range(2): + self.assertEqual(table.get_nan_count_attribute(), at) + self.assertEqual(table.get_nan_count_class(), cl) + self.assertEqual(table.get_nan_count_metas(), me) + self.assertEqual(table.get_nan_frequency_attribute(), at / np.prod(x.shape)) + self.assertEqual(table.get_nan_frequency_class(), cl / np.prod(y.shape)) + self.assertEqual(table.get_nan_frequency_metas(), me / np.prod(metas.shape)) + with table.unlocked(): + table.X = sp.csr_matrix(x) + table.Y = sp.csr_matrix(y) + with table.unlocked(): + table.X, table.Y = x, y + + test_counts(0, 0, 0) + + with table.unlocked(): + table.X[1, 2] = table.X[4, 5] = np.nan + test_counts(2, 0, 0) + + with table.unlocked(): + table.Y[3:6] = np.nan + test_counts(2, 3, 0) + + with table.unlocked(): + table.X[1, 2] = table.X[4, 5] = 0 + test_counts(0, 3, 0) + + with table.unlocked(): + table.metas[1, 0] = table.metas[3, 0] = np.nan + test_counts(0, 3, 2) + + with table.unlocked(): + table.metas[5, 1] = "" + test_counts(0, 3, 3) def test_get_nan_frequency_empty_table(self): domain = self.create_domain(self.attributes, self.class_vars) table = data.Table.from_domain(domain) + self.assertEqual(table.get_nan_count_attribute(), 0) + self.assertEqual(table.get_nan_count_class(), 0) + self.assertEqual(table.get_nan_count_metas(), 0) self.assertEqual(table.get_nan_frequency_attribute(), 0) self.assertEqual(table.get_nan_frequency_class(), 0) + self.assertEqual(table.get_nan_frequency_metas(), 0) class TestRowInstance(unittest.TestCase): @@ -2139,45 +2444,56 @@ def test_assignment(self): inst = table[2] self.assertIsInstance(inst, data.RowInstance) - inst[1] = 0 + with table.unlocked(): + inst[1] = 0 self.assertEqual(table[2, 1], 0) - inst[1] = 1 + with table.unlocked(): + inst[1] = 1 self.assertEqual(table[2, 1], 1) - inst.set_class("mammal") + with table.unlocked(): + inst.set_class("mammal") self.assertEqual(table[2, len(table.domain.attributes)], "mammal") - inst.set_class("fish") + with table.unlocked(): + inst.set_class("fish") self.assertEqual(table[2, len(table.domain.attributes)], "fish") - inst[-1] = "Foo" + with table.unlocked(): + inst[-1] = "Foo" self.assertEqual(table[2, -1], "Foo") def test_iteration_with_assignment(self): table = data.Table("iris") - for i, row in enumerate(table): - row[0] = i + with table.unlocked(): + for i, row in enumerate(table): + row[0] = i np.testing.assert_array_equal(table.X[:, 0], np.arange(len(table))) def test_sparse_assignment(self): X = np.eye(4) - Y = X[2] + Y = X[2].copy() table = data.Table.from_numpy(None, X, Y) row = table[1] self.assertFalse(sp.issparse(row.sparse_x)) self.assertEqual(row[0], 0) self.assertEqual(row[1], 1) - table.X = sp.csr_matrix(table.X) - table._Y = sp.csr_matrix(table._Y) + with table.unlocked(): + table.X = sp.csr_matrix(table.X) + table.Y = sp.csr_matrix(table.Y) sparse_row = table[1] self.assertTrue(sp.issparse(sparse_row.sparse_x)) self.assertEqual(sparse_row[0], 0) self.assertEqual(sparse_row[1], 1) - sparse_row[1] = 0 + + with table.unlocked(): + sparse_row[1] = 0 self.assertEqual(sparse_row[1], 0) self.assertEqual(table.X[1, 1], 0) self.assertEqual(table[2][4], 1) - table[2][4] = 0 + + with table.unlocked(): + table[2][4] = 0 self.assertEqual(table[2][4], 0) @@ -3030,6 +3346,11 @@ def test_transpose_class_metas_attributes_remove_inst(self): self.assertDictEqual(table.domain.attributes[0].attributes, {"attr1": "a1", "attr2": "aa1"}) + def test_transpose_name(self): + table = Table("iris") + transposed = Table.transpose(table) + self.assertEqual(table.name, transposed.name) + def _compare_tables(self, table1, table2): self.assertEqual(table1.n_rows, table2.n_rows) np.testing.assert_array_equal(table1.X, table2.X) diff --git a/Orange/tests/test_third_party.py b/Orange/tests/test_third_party.py index b583fff486e..7657c385eac 100644 --- a/Orange/tests/test_third_party.py +++ b/Orange/tests/test_third_party.py @@ -1,10 +1,11 @@ from unittest import TestCase -from pkg_resources import parse_version +from packaging.version import Version class TestPkgResources(TestCase): def test_parse_version(self): - self.assertGreater(parse_version('3.4.1'), parse_version('3.4.0')) - self.assertGreater(parse_version('3.4.1'), parse_version('3.4.dev')) - self.assertGreater(parse_version('3.4.0'), parse_version('3.4~1')) + self.assertGreater(Version('3.4.1'), Version('3.4.0')) + self.assertGreater(Version('3.4.1'), Version('3.4.dev')) + self.assertGreater(Version('3.4.1'), Version('3.4.1.dev')) + self.assertLess(Version('3.4.1'), Version('3.4.2.dev')) diff --git a/Orange/tests/test_transformation.py b/Orange/tests/test_transformation.py index 674b7fe1983..a9adbd2b8c3 100644 --- a/Orange/tests/test_transformation.py +++ b/Orange/tests/test_transformation.py @@ -1,3 +1,4 @@ +import pickle import unittest import numpy as np @@ -43,6 +44,17 @@ def test_transform_fails(self): trans = Transformation(self.data.domain[2]) self.assertRaises(NotImplementedError, trans, self.data) + def test_pickling_target_domain(self): + data = self.data + trans = self.TransformationMock(data.domain[2]) + self.assertIn("_target_domain", trans.__dict__) + # _target_domain should not be pickled + state = trans.__getstate__() + self.assertNotIn("_target_domain", state) + # _target_domain should be recreated when unpickled + unpickled = pickle.loads(pickle.dumps(trans)) + self.assertIn("_target_domain", unpickled.__dict__) + class IdentityTest(unittest.TestCase): def test_identity(self): @@ -87,3 +99,13 @@ def test_transform(self): np.testing.assert_array_equal( lookup.transform(col), np.array([2, 0, 2, 1, np.nan, 1], dtype=np.float64)) + + def test_hash_nan(self): + """ + Hash should be always the same for same lookup + Test introduced because of bug in numpy (PY3.10) and was present when nan + in lookup table: https://github.com/numpy/numpy/issues/21210 + """ + lookup = Lookup(None, np.array([1, 2, np.nan, 2])) + hashes = [hash(lookup) for _ in range(10)] + self.assertTrue(all(x == hashes[0] for x in hashes)) diff --git a/Orange/tests/test_tree.py b/Orange/tests/test_tree.py index b3342cda68e..a274d79014a 100644 --- a/Orange/tests/test_tree.py +++ b/Orange/tests/test_tree.py @@ -28,6 +28,10 @@ def test_regression(self): pred = model(table) self.assertTrue(np.all(table.Y.flatten() == pred)) + def test_supports_weights(self): + self.assertTrue(SklTreeRegressionLearner().supports_weights) + self.assertTrue(SklTreeLearner().supports_weights) + class TestTreeLearner(unittest.TestCase): def test_uses_preprocessors(self): @@ -38,6 +42,9 @@ def test_uses_preprocessors(self): tree(iris) mock_preprocessor.assert_called_with(iris) + def test_supports_weights(self): + self.assertFalse(TreeLearner().supports_weights) + class TestDecisionTreeClassifier(unittest.TestCase): @classmethod diff --git a/Orange/tests/test_txt_reader.py b/Orange/tests/test_txt_reader.py index 4d115e1493a..5a6c92e2f29 100644 --- a/Orange/tests/test_txt_reader.py +++ b/Orange/tests/test_txt_reader.py @@ -1,15 +1,13 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring - import unittest from tempfile import NamedTemporaryFile import os -import io import warnings from Orange.data import Table, ContinuousVariable, DiscreteVariable from Orange.data.io import CSVReader -from Orange.tests import test_filename +from Orange.tests import test_filename, named_file tab_file = """\ Feature 1\tFeature 2\tFeature 3 @@ -80,21 +78,20 @@ def test_read_csv(self): self.read_easy(csv_file_nh, "Feature ") def test_read_csv_with_na(self): - c = io.StringIO(csv_file_missing) - table = CSVReader(c).read() + with NamedTemporaryFile(mode="w", delete=False) as tmp: + tmp.write(csv_file_missing) + + table = CSVReader(tmp.name).read() + os.unlink(tmp.name) f1, f2 = table.domain.variables self.assertIsInstance(f1, ContinuousVariable) self.assertIsInstance(f2, DiscreteVariable) def test_read_nonutf8_encoding(self): - with self.assertRaises(ValueError) as cm: - data = Table(test_filename('datasets/binary-blob.tab')) - self.assertIn('NUL', cm.exception.args[0]) - with self.assertRaises(ValueError): with warnings.catch_warnings(): warnings.filterwarnings('error') - data = Table(test_filename('datasets/invalid_characters.tab')) + Table(test_filename('datasets/invalid_characters.tab')) def test_noncontinous_marked_continuous(self): file = NamedTemporaryFile("wt", delete=False) @@ -126,3 +123,13 @@ def test_csv_sniffer(self): data = reader.read() self.assertEqual(len(data), 8) self.assertEqual(len(data.domain.variables) + len(data.domain.metas), 15) + + def test_utf_8_sig(self): + with named_file(csv_file, encoding="utf-8-sig") as f: + reader = CSVReader(f) + data = reader.read() + self.assertEqual(data.domain[0].name, "Feature 1") + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/tests/test_url_reader.py b/Orange/tests/test_url_reader.py index c40bf26a2b5..2d3bb59078f 100644 --- a/Orange/tests/test_url_reader.py +++ b/Orange/tests/test_url_reader.py @@ -25,6 +25,19 @@ def test_special_characters(self): "vestnik-clanki/detektiranje-utrdb-v-šahu-.txt" self.assertRaises(OSError, UrlReader(path).read) + def test_base_url_with_query(self): + data = UrlReader("https://datasets.biolab.si/core/grades.xlsx?a=1&b=2").read() + self.assertEqual(16, len(data)) + + def test_url_with_fragment(self): + data = UrlReader("https://datasets.biolab.si/core/grades.xlsx#tab=1").read() + self.assertEqual(16, len(data)) + + def test_special_characters_with_query_and_fragment(self): + path = "http://file.biolab.si/text-semantics/data/elektrotehniski-" \ + "vestnik-clanki/detektiranje-utrdb-v-šahu-.txt?a=1&b=2#c=3" + self.assertRaises(OSError, UrlReader(path).read) + if __name__ == "__main__": unittest.main() diff --git a/Orange/tests/test_util.py b/Orange/tests/test_util.py index 7ea7482e904..ae0c9d3362c 100644 --- a/Orange/tests/test_util.py +++ b/Orange/tests/test_util.py @@ -1,17 +1,19 @@ +from itertools import count +import time import os import unittest +from unittest.mock import Mock, patch import warnings import numpy as np import scipy.sparse as sp from Orange.util import export_globals, flatten, deprecated, try_, deepgetattr, \ - OrangeDeprecationWarning -from Orange.data import Table + OrangeDeprecationWarning, nan_eq, nan_hash_stand, Registry, namegen from Orange.data.util import vstack, hstack, array_equal from Orange.statistics.util import stats from Orange.tests.test_statistics import dense_sparse -from Orange.util import wrap_callback, get_entry_point +from Orange.util import wrap_callback, get_entry_point, allot SOMETHING = 0xf00babe @@ -25,7 +27,7 @@ def test_get_entry_point(self): def test_export_globals(self): self.assertEqual(sorted(export_globals(globals(), __name__)), - ['SOMETHING', 'TestUtil']) + ['SOMETHING', 'TestAllot', 'TestUtil']) def test_flatten(self): self.assertEqual(list(flatten([[1, 2], [3]])), [1, 2, 3]) @@ -43,10 +45,11 @@ def identity(x): def test_try_(self): self.assertTrue(try_(lambda: np.ones(3).any())) - self.assertFalse(try_(lambda: np.whatever())) + self.assertFalse(try_(lambda: 1 / 0)) self.assertEqual(try_(len, default=SOMETHING), SOMETHING) def test_reprable(self): + # pylint: disable=import-outside-toplevel from Orange.data import ContinuousVariable from Orange.preprocess.impute import ReplaceUnknownsRandom from Orange.statistics.distribution import Continuous @@ -66,12 +69,27 @@ def test_reprable(self): self.assertEqual(repr(logit), 'LogisticRegressionLearner()') def test_deepgetattr(self): - class a: + class a: # pylint: disable=invalid-name l = [] self.assertTrue(deepgetattr(a, 'l.__len__.__call__'), a.l.__len__.__call__) self.assertTrue(deepgetattr(a, 'l.__nx__.__x__', 42), 42) self.assertRaises(AttributeError, lambda: deepgetattr(a, 'l.__nx__.__x__')) + def test_nan_eq(self): + self.assertTrue(nan_eq(float("nan"), float("nan"))) + self.assertTrue(nan_eq(1, 1.0)) + self.assertFalse(nan_eq(float("nan"), 1)) + self.assertFalse(nan_eq(1, float("nan"))) + self.assertFalse(nan_eq(float("inf"), float("nan"))) + self.assertFalse(nan_eq(float("nan"), float("inf"))) + self.assertFalse(nan_eq(1, 2)) + self.assertFalse(nan_eq(None, 2)) + self.assertFalse(nan_eq(2, None)) + + def test_nan_hash_stand(self): + self.assertEqual(hash(nan_hash_stand(float("nan"))), + hash(nan_hash_stand(float("nan")))) + def test_vstack(self): numpy = np.array([[1., 2.], [3., 4.]]) csr = sp.csr_matrix(numpy) @@ -119,10 +137,8 @@ def test_raise_deprecations(self): warnings.warn('foo', OrangeDeprecationWarning) def test_stats_sparse(self): - """ - Stats should not fail when trying to calculate mean on sparse data. - GH-2357 - """ + # pylint: disable=import-outside-toplevel + from Orange.data import Table data = Table("iris") sparse_x = sp.csr_matrix(data.X) self.assertTrue(stats(data.X).all() == stats(sparse_x).all()) @@ -183,6 +199,204 @@ def func(i): self.assertEqual(f(0.1), 0.17) self.assertEqual(f(1), 0.8) + def test_registry(self): + # pylint: disable=invalid-name, unused-variable + class A(metaclass=Registry): + pass + + class BBB(A): + pass + + class CC(A): + pass + + class DDDD(BBB): + pass + + self.assertEqual(set(A), {"BBB", "CC", "DDDD"}) + self.assertEqual(str(A), "A({BBB, CC, DDDD})") + + class D(metaclass=Registry): + pass + + self.assertEqual(set(A), {"BBB", "CC", "DDDD"}) + self.assertEqual(str(A), "A({BBB, CC, DDDD})") + self.assertEqual(set(D), set()) + self.assertEqual(str(D), "D({})") + + class E(D): + pass + + self.assertEqual(set(A), {"BBB", "CC", "DDDD"}) + self.assertEqual(str(A), "A({BBB, CC, DDDD})") + self.assertEqual(set(D), set("E")) + self.assertEqual(str(D), "D({E})") + + def test_namegen(self): + self.assertEqual([name for name, _ in zip(namegen(), range(3))], + ["_0", "_1", "_2"]) + + self.assertEqual([name for name, _ in zip(namegen("foo "), range(3))], + ["foo 0", "foo 1", "foo 2"]) + + self.assertEqual([name for name, _ in zip(namegen("foo ", 2, spec_count=count), range(3))], + ["foo 2", "foo 3", "foo 4"]) + + +class TestAllot(unittest.TestCase): + # names of functions within tests don't matter, pylint: disable=invalid-name + + def setUp(self): + # patch the object to user perf_counter, which will include the time + # when tests `sleep` + patcher = patch.object(allot, "_allot__timer", new=time.perf_counter) + patcher.start() + self.addCleanup(patcher.stop) + + def test_duration(self): + @allot + def f(x, y): + self.assertEqual(x, 5) + self.assertEqual(y, 6) + time.sleep(0.2) + + f(5, y=6) + self.assertGreaterEqual(f.last_call_duration, 0.2) + + class A: + def __init__(self): + self.x = self.y = 0 + + @allot + def f(self, x, y): + self.x = x + self.y = y + time.sleep(0.2) + + a = A() + a.f(5, y=6) + self.assertEqual(a.x, 5) + self.assertEqual(a.y, 6) + self.assertGreaterEqual(f.last_call_duration, 0.2) + + def test_skipping(self): + uf = Mock() + + @allot(0.5) + def f(x, y): + uf() + self.assertEqual(x, 5) + self.assertEqual(y, 6) + time.sleep(0.2) + + f(5, y=6) + uf.assert_called_once() + uf.reset_mock() + self.assertGreaterEqual(f.last_call_duration, 0.2) + f(5, y=6) + uf.assert_not_called() + self.assertGreaterEqual(f.last_call_duration, 0.2) + time.sleep(0.35) + f(5, y=6) + uf.assert_called_once() + self.assertGreaterEqual(f.last_call_duration, 0.2) + + class A: + def __init__(self): + self.x = self.y = 0 + + @allot(0.5) + def f(self, x, y): + self.x = x + self.y = y + time.sleep(0.2) + + a = A() + a2 = A() + a.f(5, y=6) + self.assertEqual(a.x, 5) + self.assertEqual(a.y, 6) + self.assertGreaterEqual(f.last_call_duration, 0.2) + + a.f(7, y=8) + self.assertEqual(a.x, 5) # no call, `a` is unchanged + self.assertGreaterEqual(f.last_call_duration, 0.2) + + time.sleep(0.35) + a.f(9, y=10) + a2.f(11, y=12) + self.assertEqual(a.x, 9) + self.assertGreaterEqual(f.last_call_duration, 0.2) + # a2.f is not being skipped because of a.f - bound methods for different + # instance are separate + self.assertEqual(a2.x, 11) + + # forced calls work + a.f.call(11, 12) + self.assertEqual(a.x, 11) + + # forced calls block later non-forced calls + a = A() + a.f.call(13, y=14) + self.assertEqual(a.x, 13) + a.f(15, y=16) + self.assertEqual(a.x, 13) + + def test_overflow(self): + uf = Mock() + of = Mock(return_value=13) + + @allot(0.1, overflow=of) + def f(*_, **__): + uf() + time.sleep(0.1) + return 42 + + self.assertEqual(f(5, y=6), 42) + uf.assert_called() + uf.reset_mock() + of.assert_not_called() + + self.assertEqual(f(7, y=8), 13) + uf.assert_not_called() + of.assert_called_with(7, y=8) + + def test_assert_no_result(self): + # pylint: disable=function-redefined + @allot(0.1) + def f(): + return 42 + + self.assertRaises(AssertionError, f) + + @allot(0.05, overflow=lambda x: 3 * x) + def f(x): + time.sleep(0.6) + return 2 * x + + self.assertEqual(f(3), 6) + self.assertEqual(f(3), 9) + + @allot + def f(): + return 42 + + self.assertEqual(f(), 42) + + def test_assertion(self): + self.assertRaises(AssertionError, allot, "foo") + self.assertRaises(AssertionError, allot, 0.0) + self.assertRaises(AssertionError, allot, -1.0) + + def test_getter(self): + class A: + @allot + def f(self): + pass + + # getter for class must not do anything with the method + self.assertIs(A.f, A.__dict__["f"]) + if __name__ == "__main__": unittest.main() diff --git a/Orange/tests/test_value.py b/Orange/tests/test_value.py index 30a68c4d086..ddf30cbab97 100644 --- a/Orange/tests/test_value.py +++ b/Orange/tests/test_value.py @@ -64,3 +64,13 @@ def test_hash(self): self.assertTrue(val == v and hash(val) == hash(v)) val = Value(DiscreteVariable("var", ["red", "green", "blue"]), 1) self.assertRaises(TypeError, hash, val) + + def test_as_values(self): + x = ContinuousVariable("x") + values = Value._as_values(x, [0., 1., 2.]) # pylint: disable=protected-access + self.assertIsInstance(values[0], Value) + self.assertEqual(values[0], 0) + s = StringVariable("s") + values = Value._as_values(s, ["a", "b", ""]) # pylint: disable=protected-access + self.assertIsInstance(values[0], Value) + self.assertEqual(values[0], "a") diff --git a/Orange/tests/test_xlsx_reader.py b/Orange/tests/test_xlsx_reader.py index 46f89ef36d9..7c3def587dc 100644 --- a/Orange/tests/test_xlsx_reader.py +++ b/Orange/tests/test_xlsx_reader.py @@ -4,6 +4,7 @@ import unittest import os from functools import wraps +from tempfile import mkstemp from typing import Callable import numpy as np @@ -46,6 +47,35 @@ def test_read_round_floats(self): self.assertIsInstance(domain[1], ContinuousVariable) self.assertEqual(domain[2].values, ("1", "2")) + def test_write_file(self): + fd, filename = mkstemp(suffix=".xlsx") + os.close(fd) + + data = Table("zoo") + io.ExcelReader.write_file(filename, data, with_annotations=True) + + reader = io.ExcelReader(filename) + read_data = reader.read() + + domain1 = data.domain + domain2 = read_data.domain + self.assertEqual(len(domain1.attributes), len(domain2.attributes)) + self.assertEqual(len(domain1.class_vars), len(domain2.class_vars)) + self.assertEqual(len(domain1.metas), len(domain2.metas)) + for var1, var2 in zip(domain1.variables + domain1.metas, + domain2.variables + domain2.metas): + self.assertEqual(type(var1), type(var2)) + self.assertEqual(var1.name, var2.name) + if var1.is_discrete: + self.assertEqual(var1.values, var2.values) + + np.testing.assert_array_equal(data.X, read_data.X) + np.testing.assert_array_equal(data.Y, read_data.Y) + np.testing.assert_array_equal(data.metas, read_data.metas) + np.testing.assert_array_equal(data.W, read_data.W) + + os.unlink(filename) + class TestExcelHeader0(unittest.TestCase): @test_xlsx_xls @@ -113,6 +143,25 @@ def test_no_flags(self, reader: Callable[[str], io.FileFormat]): [0, 0, np.nan, 0]])) np.testing.assert_equal(table.Y, np.array([]).reshape(3, 0)) + def test_hash(self): + table = Table.from_file(get_dataset("header_1_hash.xlsx")) + domain = table.domain + self.assertEqual(len(domain.metas), 0) + self.assertEqual(len(domain.attributes), 3) + self.assertEqual(len(domain.class_vars), 1) + self.assertIsInstance(domain[0], DiscreteVariable) + self.assertIsInstance(domain[1], ContinuousVariable) + self.assertIsInstance(domain[2], ContinuousVariable) + self.assertIsInstance(domain[3], DiscreteVariable) + self.assertEqual([v.name for v in domain.variables], + ["#", "b#", "d", "Feature 1"]) + self.assertEqual(domain[0].values, ("green", "red")) + np.testing.assert_almost_equal(table.X, np.array([[1, 0.5, 21], + [1, 0.1, 123], + [0, 0, 0]])) + np.testing.assert_equal(table.Y, [0, 0, np.nan]) + + @test_xlsx_xls def test_flags(self, reader: Callable[[str], io.FileFormat]): table = read_file(reader, "header_1_flags") @@ -185,7 +234,7 @@ class TestMissingValues(unittest.TestCase): @test_xlsx_xls def test_read_errors(self, reader: Callable[[str], io.FileFormat]): table = read_file(reader, "missing") - values = table.get_column_view("C")[0] + values = table.get_column("C") self.assertTrue(np.isnan(values).all()) diff --git a/Orange/tests/xlsx_files/distances.xlsx b/Orange/tests/xlsx_files/distances.xlsx new file mode 100644 index 00000000000..d66f5ea0cf7 Binary files /dev/null and b/Orange/tests/xlsx_files/distances.xlsx differ diff --git a/Orange/tests/xlsx_files/distances_nonsquare.xlsx b/Orange/tests/xlsx_files/distances_nonsquare.xlsx new file mode 100644 index 00000000000..78c5d8ff650 Binary files /dev/null and b/Orange/tests/xlsx_files/distances_nonsquare.xlsx differ diff --git a/Orange/tests/xlsx_files/distances_with_nans.xlsx b/Orange/tests/xlsx_files/distances_with_nans.xlsx new file mode 100644 index 00000000000..3690084892c Binary files /dev/null and b/Orange/tests/xlsx_files/distances_with_nans.xlsx differ diff --git a/Orange/tests/xlsx_files/header_1_hash.xlsx b/Orange/tests/xlsx_files/header_1_hash.xlsx new file mode 100644 index 00000000000..d5cb8b5c7d1 Binary files /dev/null and b/Orange/tests/xlsx_files/header_1_hash.xlsx differ diff --git a/Orange/tree.py b/Orange/tree.py index 44bb8d14488..8a76ce43aa4 100644 --- a/Orange/tree.py +++ b/Orange/tree.py @@ -12,7 +12,7 @@ class Node: """Tree node base class; instances of this class are also used as leaves Attributes: - attr (Odange.data.Variable): The attribute used for splitting + attr (Orange.data.Variable): The attribute used for splitting attr_idx (int): The index of the attribute used for splitting value (object): value used for prediction (e.g. class distribution) children (list of Node): child branches @@ -376,3 +376,6 @@ def _compute_subtree(node): conditions = OrderedDict() self.root.parent = None _compute_subtree(self.root) + + def predict_proba(self, data): + return self(data, ret=TreeModelInterface.Probs) diff --git a/Orange/util.py b/Orange/util.py index dcece9ebbbb..192ea07c059 100644 --- a/Orange/util.py +++ b/Orange/util.py @@ -1,25 +1,35 @@ """Various small utilities that might be useful everywhere""" import logging import os +import time import inspect import datetime +import math +import functools +import importlib.resources from contextlib import contextmanager - -import pkg_resources +from importlib.metadata import distribution +from typing import TYPE_CHECKING, Callable, Union, Optional, TypeVar +from weakref import WeakKeyDictionary from enum import Enum as _Enum from functools import wraps, partial from operator import attrgetter from itertools import chain, count, repeat -from collections import OrderedDict, namedtuple +from collections import namedtuple import warnings # Exposed here for convenience. Prefer patching to try-finally blocks from unittest.mock import patch # pylint: disable=unused-import +import numpy as np + # Backwards-compat from Orange.data.util import scale # pylint: disable=unused-import +if TYPE_CHECKING: + from numpy.typing import DTypeLike + log = logging.getLogger(__name__) @@ -105,20 +115,18 @@ def resource_filename(path): """ Return the resource filename path relative to the Orange package. """ - return pkg_resources.resource_filename("Orange", path) + path = importlib.resources.files("Orange").joinpath(path) + return str(path) def get_entry_point(dist, group, name): """ Load and return the entry point from the distribution. - - Unlike `pkg_resources.load_entry_point`, this function does not check - for requirements. Calling this function is preferred because of developers - who experiment with different versions and have inconsistent configurations. """ - dist = pkg_resources.get_distribution(dist) - ep = dist.get_entry_info(group, name) - return ep.resolve() + dist = distribution(dist) + eps = dist.entry_points.select(group=group, name=name) + ep = next(iter(eps)) + return ep.load() def deprecated(obj): @@ -154,18 +162,18 @@ def deprecated(obj): ... return 'new behavior' >>> C().old() # doctest: +SKIP /... OrangeDeprecationWarning: Call to deprecated ... C.old ... - Instead, use C.new() ... + use use C.new() instead ... 'old behavior' """ - alternative = ('; Instead, use ' + obj) if isinstance(obj, str) else '' + alternative = f'; use {obj} instead' if isinstance(obj, str) else '' def decorator(func): @wraps(func) def wrapper(*args, **kwargs): - name = '{}.{}'.format( - func.__self__.__class__, - func.__name__) if hasattr(func, '__self__') else func - warnings.warn('Call to deprecated {}{}'.format(name, alternative), + name = func.__name__ + if hasattr(func, "__self__"): + name = f'{func.__self__.__class__}.{name}' + warnings.warn(f'Call to deprecated {name}{alternative}', OrangeDeprecationWarning, stacklevel=2) return func(*args, **kwargs) return wrapper @@ -173,8 +181,143 @@ def wrapper(*args, **kwargs): return decorator if alternative else decorator(obj) +# This should look like decorator, not a class, pylint: disable=invalid-name +class allot: + """ + Decorator that allows a function only a specified portion of time per call. + + Usage: + + ``` + @allot(0.2, overflow=of) + def f(x): + ... + ``` + + The above function is allotted 0.2 second per second. If it runs for 0.2 s, + all subsequent calls in the next second (after the start of the call) are + ignored. If it runs for 0.1 s, subsequent calls in the next 0.5 s are + ignored. If it runs for a second, subsequent calls are ignored for 5 s. + + An optional overflow function can be given as a keyword argument + `overflow`. This function must have the same signature as the wrapped + function and is called instead of the original when the call is blocked. + + If the overflow function is not given, the wrapped function must not return + result. This is because without the overflow function, the wrapper has no + value to return when the call is skipped. + + The decorator adds a method `call` to force the call, e.g. by calling + f.call(5), in the above case. The used up time still counts for the + following (non-forced) calls. + + The decorator also adds two attributes: + + - f.last_call_duration is the duration of the last call (in seconds) + - f.no_call_before contains the time stamp when the next call will be made. + + The decorator can be used for functions and for methods. + + A non-parametrized decorator doesn't block any calls and only adds + last_call_duration, so that it can be used for timing. + """ + + try: + __timer = time.thread_time + except AttributeError: + # thread_time is not available on macOS + __timer = time.process_time + + def __new__(cls: type, arg: Union[None, float, Callable], *, + overflow: Optional[Callable] = None, + _bound_methods: Optional[WeakKeyDictionary] = None): + self = super().__new__(cls) + + if arg is None or isinstance(arg, float): + # Parametrized decorator + if arg is not None: + assert arg > 0 + + def set_func(func): + self.__init__(func, + overflow=overflow, + _bound_methods=_bound_methods) + self.allotted_time = arg + return self + + return set_func + + else: + # Non-parametrized decorator + self.allotted_time = None + return self + + def __init__(self, + func: Callable, *, + overflow: Optional[Callable] = None, + _bound_methods: Optional[WeakKeyDictionary] = None): + assert callable(func) + self.func = func + self.overflow = overflow + functools.update_wrapper(self, func) + + self.no_call_before = 0 + self.last_call_duration = None + + # Used by __get__; see a comment there + if _bound_methods is None: + self.__bound_methods = WeakKeyDictionary() + else: + self.__bound_methods = _bound_methods + + # If we are wrapping a method, __get__ is called to bind it. + # Create a wrapper for each instance and store it, so that each instance's + # method gets its share of time. + def __get__(self, inst, cls): + if inst is None: + return self + + if inst not in self.__bound_methods: + # __bound_methods caches bound methods per instance. This is not + # done for perfoamnce. Bound methods can be rebound, even to + # different instances or even classes, e.g. + # >>> x = f.__get__(a, A) + # >>> y = x.__get__(b, B) + # >>> z = x.__get__(a, A) + # After this, we want `x is z`, there shared caching. This looks + # bizarre, but let's keep it safe. At least binding to the same + # instance, f.__get__(a, A),__get__(a, A), sounds reasonably + # possible. + cls = type(self) + bound_overflow = self.overflow and self.overflow.__get__(inst, cls) + decorator = cls( + self.allotted_time, + overflow=bound_overflow, + _bound_methods=self.__bound_methods) + self.__bound_methods[inst] = decorator(self.func.__get__(inst, cls)) + + return self.__bound_methods[inst] + + def __call__(self, *args, **kwargs): + if self.__timer() < self.no_call_before: + if self.overflow is None: + return None + return self.overflow(*args, **kwargs) + return self.call(*args, **kwargs) + + def call(self, *args, **kwargs): + start = self.__timer() + result = self.func(*args, **kwargs) + self.last_call_duration = self.__timer() - start + if self.allotted_time is not None: + if self.overflow is None: + assert result is None, "skippable function cannot return a result" + self.no_call_before = start + self.last_call_duration / self.allotted_time + return result + + def literal_eval(literal): - import ast + import ast # pylint: disable=import-outside-toplevel # ast.literal_eval does not parse empty set ¯\_(ツ)_/¯ if literal == "set()": @@ -218,11 +361,11 @@ def requirementsSatisfied(required_state, local_state, req_type=None): for req_string in required_state: # parse requirement req = None - for op_str in op_map: + for op_str, op in op_map.items(): split = req_string.split(op_str) # if operation is not in req_string, continue if len(split) == 2: - req = _Requirement(split[0], op_map[op_str], split[1]) + req = _Requirement(split[0], op, split[1]) break if req is None: @@ -252,6 +395,27 @@ def try_(func, default=None): return default +A = TypeVar("A") +B = TypeVar("B") + + +def ftry( + func: Callable[..., A], + error: type[BaseException] | tuple[type[BaseException]], + default: B +) -> Callable[..., A | B]: + """ + Wrap a `func` such that if `errors` occur `default` is returned instead. + """ + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except error: + return default + return wrapper + + def flatten(lst): """Flatten iterable a single level.""" return chain.from_iterable(lst) @@ -262,7 +426,7 @@ class Registry(type): def __new__(mcs, name, bases, attrs): cls = type.__new__(mcs, name, bases, attrs) if not hasattr(cls, 'registry'): - cls.registry = OrderedDict() + cls.registry = {} else: cls.registry[name] = cls return cls @@ -273,11 +437,14 @@ def __iter__(cls): def __str__(cls): if cls in cls.registry.values(): return cls.__name__ - return '{}({{{}}})'.format(cls.__name__, ', '.join(cls.registry)) + return f'{cls.__name__}({{{", ".join(cls.registry)}}})' +# it is what it is, we keep for compatibility: +# pylint: disable=keyword-arg-before-vararg def namegen(prefix='_', *args, spec_count=count, **kwargs): """Continually generate names with `prefix`, e.g. '_1', '_2', ...""" + # pylint: disable=stop-iteration-return spec_count = iter(spec_count(*args, **kwargs)) while True: yield prefix + str(next(spec_count)) @@ -318,6 +485,7 @@ def deepgetattr(obj, attr, default=_NOTSET): def color_to_hex(color): + # pylint: disable=consider-using-f-string return "#{:02X}{:02X}{:02X}".format(*color) @@ -330,9 +498,9 @@ def inherit_docstrings(cls): for method in cls.__dict__.values(): if inspect.isfunction(method) and method.__doc__ is None: for parent in cls.__mro__[1:]: - __doc__ = getattr(parent, method.__name__, None).__doc__ - if __doc__: - method.__doc__ = __doc__ + doc = getattr(parent, method.__name__, None).__doc__ + if doc: + method.__doc__ = doc break return cls @@ -372,7 +540,7 @@ def interleave(seq1, seq2): def Reprable_repr_pretty(name, itemsiter, printer, cycle): # type: (str, Iterable[Tuple[str, Any]], Ipython.lib.pretty.PrettyPrinter, bool) -> None if cycle: - printer.text("{0}(...)".format("name")) + printer.text(f"{name}(...)") else: def printitem(field, value): printer.text(field + "=") @@ -385,9 +553,10 @@ def printsep(): itemsiter = (partial(printitem, *item) for item in itemsiter) sepiter = repeat(printsep) - with printer.group(len(name) + 1, "{0}(".format(name), ")"): + with printer.group(len(name) + 1, f"{name}(", ")"): for part in interleave(itemsiter, sepiter): part() + part() class _Undef: @@ -451,6 +620,7 @@ def _reprable_fields(self): param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD): yield param.name, param.default + # pylint: disable=unused-argument def _reprable_omit_param(self, name, default, value): if default is value: return True @@ -496,9 +666,9 @@ def __repr__(self): nameparts = (([str(module)] if module else []) + [self.__class__.__name__]) name = ".".join(nameparts) - return "{}({})".format( - name, ", ".join("{}={!r}".format(f, v) for f, _, v in self._reprable_items()) - ) + items = ", ".join(f"{f}={repr(v)}" + for f, _, v in self._reprable_items()) + return f"{name}({items})" def wrap_callback(progress_callback, start=0, end=1): @@ -531,6 +701,78 @@ def utc_from_timestamp(timestamp) -> datetime.datetime: datetime.timedelta(seconds=float(timestamp)) +def frompyfunc(func: Callable, nin: int, nout: int, dtype: 'DTypeLike'): + """ + Wrap an `func` callable into an ufunc-like function with `out`, `dtype`, + `where`, ... parameters. The `dtype` is used as the default. + + Unlike numpy.frompyfunc this function always returns output array of + the specified `dtype`. Note that the conversion is space efficient. + """ + func_ = np.frompyfunc(func, nin, nout) + + @wraps(func) + def funcv(*args, out=None, dtype=dtype, casting="unsafe", **kwargs): + if not args: + raise TypeError + args = [np.asanyarray(a) for a in args] + args = np.broadcast_arrays(*args) + shape = args[0].shape + have_out = out is not None + if out is None and dtype is not None: + out = np.empty(shape, dtype) + + res = func_(*args, out, dtype=dtype, casting=casting, **kwargs) + if res.shape == () and not have_out: + return res.item() + else: + return res + + return funcv + + +_isnan = math.isnan + + +def nan_eq(a, b) -> bool: + """ + Same as `a == b` except where both `a` and `b` are NaN values in which + case `True` is returned. + + .. seealso:: nan_hash_stand + """ + try: + both_nan = _isnan(a) and _isnan(b) + except TypeError: + return a == b + else: + return both_nan or a == b + + +def nan_hash_stand(value): + """ + If `value` is a NaN then return a singular global *standin* NaN instance, + otherwise return `value` unchanged. + + Use this where a hash of `value` is needed and `value` might be a NaN + to account for distinct hashes of NaN instances. + + E.g. the folowing `__eq__` and `__hash__` pairs would be ill-defined for + `A(float("nan"))` instances if `nan_hash_stand` and `nan_eq` were not + used. + >>> class A: + ... def __init__(self, v): self.v = v + ... def __hash__(self): return hash(nan_hash_stand(self.v)) + ... def __eq__(self, other): return nan_eq(self.v, other.v) + """ + try: + if _isnan(value): + return math.nan + except TypeError: + pass + return value + + # For best result, keep this at the bottom __all__ = export_globals(globals(), __name__) diff --git a/Orange/utils/__init__.py b/Orange/utils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/utils/tree/__init__.py b/Orange/utils/tree/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/visualize/utils/tree/rules.py b/Orange/utils/tree/rules.py similarity index 98% rename from Orange/widgets/visualize/utils/tree/rules.py rename to Orange/utils/tree/rules.py index f151a56cef0..d16dc1639d9 100644 --- a/Orange/widgets/visualize/utils/tree/rules.py +++ b/Orange/utils/tree/rules.py @@ -166,8 +166,8 @@ class IntervalRule(Rule): Examples -------- >>> print(IntervalRule('Rule', - >>> ContinuousRule('Rule', True, 1, inclusive=True), - >>> ContinuousRule('Rule', False, 3))) + ... ContinuousRule('Rule', True, 1, inclusive=True), + ... ContinuousRule('Rule', False, 3))) Rule ∈ [1.000, 3.000) Notes diff --git a/Orange/widgets/visualize/utils/tree/skltreeadapter.py b/Orange/utils/tree/skltreeadapter.py similarity index 98% rename from Orange/widgets/visualize/utils/tree/skltreeadapter.py rename to Orange/utils/tree/skltreeadapter.py index 756d5d72da3..4ae7fc97fd4 100644 --- a/Orange/widgets/visualize/utils/tree/skltreeadapter.py +++ b/Orange/utils/tree/skltreeadapter.py @@ -3,11 +3,11 @@ import random import numpy as np -from Orange.widgets.visualize.utils.tree.treeadapter import BaseTreeAdapter from Orange.misc.cache import memoize_method from Orange.preprocess.transformation import Indicator -from Orange.widgets.visualize.utils.tree.rules import ( +from Orange.utils.tree.treeadapter import BaseTreeAdapter +from Orange.utils.tree.rules import ( DiscreteRule, ContinuousRule ) diff --git a/Orange/utils/tree/tests/__init__.py b/Orange/utils/tree/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/visualize/utils/tree/tests/test_rules.py b/Orange/utils/tree/tests/test_rules.py similarity index 99% rename from Orange/widgets/visualize/utils/tree/tests/test_rules.py rename to Orange/utils/tree/tests/test_rules.py index 562d926eae4..107bf3c2383 100644 --- a/Orange/widgets/visualize/utils/tree/tests/test_rules.py +++ b/Orange/utils/tree/tests/test_rules.py @@ -1,7 +1,7 @@ """Test rules for classification and regression trees.""" import unittest -from Orange.widgets.visualize.utils.tree.rules import ( +from Orange.utils.tree.rules import ( ContinuousRule, IntervalRule, ) diff --git a/Orange/widgets/visualize/utils/tree/tests/test_treeadapter.py b/Orange/utils/tree/tests/test_treeadapter.py similarity index 96% rename from Orange/widgets/visualize/utils/tree/tests/test_treeadapter.py rename to Orange/utils/tree/tests/test_treeadapter.py index 79f1a16b3c4..85a7d6cd0e7 100644 --- a/Orange/widgets/visualize/utils/tree/tests/test_treeadapter.py +++ b/Orange/utils/tree/tests/test_treeadapter.py @@ -8,7 +8,7 @@ from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable from Orange.classification.tree import \ TreeModel, Node, DiscreteNode, MappedDiscreteNode, NumericNode -from Orange.widgets.visualize.utils.tree.treeadapter import TreeAdapter +from Orange.utils.tree.treeadapter import TreeAdapter class TestTreeAdapter(unittest.TestCase): @@ -70,7 +70,7 @@ def test_adapter(self): [16, 17, 18], [20, 21, 22], [32, 33, 34], - [36, 37, 38]], dtype=np.float)) + [36, 37, 38]], dtype=float)) self.assertEqual(adapt.max_depth, 2) self.assertEqual(adapt.num_nodes, 7) self.assertIs(adapt.root, self.root) diff --git a/Orange/widgets/visualize/utils/tree/treeadapter.py b/Orange/utils/tree/treeadapter.py similarity index 96% rename from Orange/widgets/visualize/utils/tree/treeadapter.py rename to Orange/utils/tree/treeadapter.py index 4c5403e4bf1..9109f72ee50 100644 --- a/Orange/widgets/visualize/utils/tree/treeadapter.py +++ b/Orange/utils/tree/treeadapter.py @@ -21,8 +21,11 @@ class BaseTreeAdapter(metaclass=ABCMeta): def __init__(self, model): self.model = model self.domain = model.domain - self.instances = model.instances - self.instances_transformed = self.instances.transform(self.domain) + if model.instances is None: + self.instances = self.instances_transformed = None + else: + self.instances = model.instances + self.instances_transformed = self.instances.transform(self.domain) @abstractmethod def weight(self, node): diff --git a/Orange/widgets/__init__.py b/Orange/widgets/__init__.py index 640e85afe67..901d4660107 100644 --- a/Orange/widgets/__init__.py +++ b/Orange/widgets/__init__.py @@ -3,15 +3,20 @@ """ import os import sysconfig +from typing import TYPE_CHECKING -import pkg_resources - -import Orange - +if TYPE_CHECKING: + import orangewidget.workflow.discovery # Entry point for main Orange categories/widgets discovery def widget_discovery(discovery): - dist = pkg_resources.get_distribution("Orange3") + # type: (orangewidget.workflow.discovery.WidgetDiscovery) -> None + from orangecanvas.registry import CategoryDescription + from orangecanvas.registry.utils import category_from_package_globals + from orangecanvas.utils.pkgmeta import get_distribution + + + dist = get_distribution("Orange3") pkgs = [ "Orange.widgets.data", "Orange.widgets.visualize", @@ -19,8 +24,28 @@ def widget_discovery(discovery): "Orange.widgets.evaluate", "Orange.widgets.unsupervised", ] + for pkg in pkgs: + discovery.handle_category(category_from_package_globals(pkg)) + # manually described category (without 'package' definition) + discovery.handle_category( + CategoryDescription( + name="Transform", + priority=1, + background="#FF9D5E", + icon="data/icons/Transform.svg", + package=__package__, + ) + ) + discovery.handle_category( + CategoryDescription( + name="Orange Obsolete", + package=__package__, + hidden=True, + ) + ) for pkg in pkgs: discovery.process_category_package(pkg, distribution=dist) + discovery.process_widget_module("Orange.widgets.obsolete.owtable") WIDGET_HELP_PATH = ( diff --git a/Orange/widgets/data/__init__.py b/Orange/widgets/data/__init__.py index 26a3816dc4c..28831a02940 100644 --- a/Orange/widgets/data/__init__.py +++ b/Orange/widgets/data/__init__.py @@ -1,14 +1,17 @@ -NAME = "Data" +""" +==== +Data +==== -ID = "orange.widgets.data" +Data manipulation. + +""" -DESCRIPTION = """Widgets for data manipulation.""" +NAME = "Data" -LONG_DESRIPTION = """ -This category contains widgets for data manipulation. This includes -loading, importing, saving, preprocessing, selection, etc. +ID = "orange.widgets.data" -""" +DESCRIPTION = """Data manipulation""" ICON = "icons/Category-Data.svg" diff --git a/Orange/widgets/data/icons/AggregateColumns.svg b/Orange/widgets/data/icons/AggregateColumns.svg index a4a7ddc66f8..0911520ccd3 100644 --- a/Orange/widgets/data/icons/AggregateColumns.svg +++ b/Orange/widgets/data/icons/AggregateColumns.svg @@ -1 +1,16 @@ -AggregateColumns \ No newline at end of file + + + + + + + + + + + + \ No newline at end of file diff --git a/Orange/widgets/data/icons/CSVFile.svg b/Orange/widgets/data/icons/CSVFile.svg index f5150dc3698..98ddd69b7cd 100644 --- a/Orange/widgets/data/icons/CSVFile.svg +++ b/Orange/widgets/data/icons/CSVFile.svg @@ -1,132 +1,32 @@ - - - -image/svg+xml \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/Category-Data.svg b/Orange/widgets/data/icons/Category-Data.svg index a0f390ad279..cd5d88f60c1 100644 --- a/Orange/widgets/data/icons/Category-Data.svg +++ b/Orange/widgets/data/icons/Category-Data.svg @@ -1,19 +1,27 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/Colors.svg b/Orange/widgets/data/icons/Colors.svg index c684a48eefd..18205729c76 100644 --- a/Orange/widgets/data/icons/Colors.svg +++ b/Orange/widgets/data/icons/Colors.svg @@ -3,21 +3,32 @@ - +* { color: #333; } +.ColorScheme-Text { color: #333; } +.ColorScheme-Text-Disabled { color: #666; } +.ColorScheme-Background { color: #fff; } + + - - - - - + + + + + + + + + + - - - + - + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + diff --git a/Orange/widgets/data/icons/Continuize.svg b/Orange/widgets/data/icons/Continuize.svg index 1b894fe4194..e405a016fa1 100644 --- a/Orange/widgets/data/icons/Continuize.svg +++ b/Orange/widgets/data/icons/Continuize.svg @@ -3,21 +3,31 @@ + + + - + - - - - - - + + diff --git a/Orange/widgets/data/icons/Correlations.svg b/Orange/widgets/data/icons/Correlations.svg index 92ec8ac9174..05948376e94 100644 --- a/Orange/widgets/data/icons/Correlations.svg +++ b/Orange/widgets/data/icons/Correlations.svg @@ -2,42 +2,34 @@ - + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/CreateInstance.svg b/Orange/widgets/data/icons/CreateInstance.svg index 2a7b039df6d..8f99f228a7a 100644 --- a/Orange/widgets/data/icons/CreateInstance.svg +++ b/Orange/widgets/data/icons/CreateInstance.svg @@ -3,26 +3,41 @@ + + + - - + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - diff --git a/Orange/widgets/data/icons/DataInfo.svg b/Orange/widgets/data/icons/DataInfo.svg index e883321055c..81937b23b57 100644 --- a/Orange/widgets/data/icons/DataInfo.svg +++ b/Orange/widgets/data/icons/DataInfo.svg @@ -1,17 +1,23 @@ - - - - - + + + + + - - + + - diff --git a/Orange/widgets/data/icons/DataSampler.svg b/Orange/widgets/data/icons/DataSampler.svg index 740c83a99e8..748def18cc4 100644 --- a/Orange/widgets/data/icons/DataSampler.svg +++ b/Orange/widgets/data/icons/DataSampler.svg @@ -1,24 +1,32 @@ - + + + - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - + + + + + + + diff --git a/Orange/widgets/data/icons/DataSets.svg b/Orange/widgets/data/icons/DataSets.svg index 508d456cf98..521a3456a93 100644 --- a/Orange/widgets/data/icons/DataSets.svg +++ b/Orange/widgets/data/icons/DataSets.svg @@ -3,36 +3,17 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/Orange/widgets/data/icons/Discretize.svg b/Orange/widgets/data/icons/Discretize.svg index 8aa37b6f606..5b107980e76 100644 --- a/Orange/widgets/data/icons/Discretize.svg +++ b/Orange/widgets/data/icons/Discretize.svg @@ -3,11 +3,20 @@ - - - - - - + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/EditDomain.svg b/Orange/widgets/data/icons/EditDomain.svg index 4edf444a8ac..82bda0a11ef 100644 --- a/Orange/widgets/data/icons/EditDomain.svg +++ b/Orange/widgets/data/icons/EditDomain.svg @@ -1,31 +1,27 @@ - - + + + - - - - - - - - - - - + + + - - - - diff --git a/Orange/widgets/data/icons/FeatureConstructor.svg b/Orange/widgets/data/icons/FeatureConstructor.svg index a271576314b..c9cf5f97a45 100644 --- a/Orange/widgets/data/icons/FeatureConstructor.svg +++ b/Orange/widgets/data/icons/FeatureConstructor.svg @@ -3,30 +3,43 @@ + + + - - - - - - - - - - - - - - - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/Orange/widgets/data/icons/FeatureStatistics.svg b/Orange/widgets/data/icons/FeatureStatistics.svg index c624ace31a8..0b60b8e9e8a 100644 --- a/Orange/widgets/data/icons/FeatureStatistics.svg +++ b/Orange/widgets/data/icons/FeatureStatistics.svg @@ -1,18 +1,22 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/File.svg b/Orange/widgets/data/icons/File.svg index 617d2324b02..1eac91ce2fb 100644 --- a/Orange/widgets/data/icons/File.svg +++ b/Orange/widgets/data/icons/File.svg @@ -3,6 +3,11 @@ - - + + + diff --git a/Orange/widgets/data/icons/GroupBy.svg b/Orange/widgets/data/icons/GroupBy.svg new file mode 100644 index 00000000000..160a310ce39 --- /dev/null +++ b/Orange/widgets/data/icons/GroupBy.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/Impute.svg b/Orange/widgets/data/icons/Impute.svg index ebfde00446e..c2787528315 100644 --- a/Orange/widgets/data/icons/Impute.svg +++ b/Orange/widgets/data/icons/Impute.svg @@ -3,21 +3,34 @@ + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/Melt.svg b/Orange/widgets/data/icons/Melt.svg index f445d7c0f36..9cf7b1775da 100644 --- a/Orange/widgets/data/icons/Melt.svg +++ b/Orange/widgets/data/icons/Melt.svg @@ -1,27 +1,92 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/MergeData.svg b/Orange/widgets/data/icons/MergeData.svg index 47bf80facce..5066c31ef6e 100644 --- a/Orange/widgets/data/icons/MergeData.svg +++ b/Orange/widgets/data/icons/MergeData.svg @@ -2,27 +2,38 @@ + + + - - - - - - - + + + + + + - - - - - - - + + + + + + + + diff --git a/Orange/widgets/data/icons/Neighbors.svg b/Orange/widgets/data/icons/Neighbors.svg index e4a9e8f8ad4..4fb2df746ba 100644 --- a/Orange/widgets/data/icons/Neighbors.svg +++ b/Orange/widgets/data/icons/Neighbors.svg @@ -15,9 +15,15 @@ .st9{fill:none;stroke:#C6C6C6;stroke-width:3;stroke-miterlimit:10;} .st10{fill:none;stroke:#333333;stroke-width:2;stroke-linejoin:round;stroke-miterlimit:10;} - - - + + + + diff --git a/Orange/widgets/data/icons/Outliers.svg b/Orange/widgets/data/icons/Outliers.svg index cc4697948c0..14602d56159 100644 --- a/Orange/widgets/data/icons/Outliers.svg +++ b/Orange/widgets/data/icons/Outliers.svg @@ -3,31 +3,21 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + diff --git a/Orange/widgets/data/icons/PaintData.svg b/Orange/widgets/data/icons/PaintData.svg index d55e4f3ec31..ee8ca1e626a 100644 --- a/Orange/widgets/data/icons/PaintData.svg +++ b/Orange/widgets/data/icons/PaintData.svg @@ -1,36 +1,41 @@ - - - - + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + - - + + + + + + + + - - - - - - diff --git a/Orange/widgets/data/icons/SQLTable.svg b/Orange/widgets/data/icons/SQLTable.svg index 3c2f1d962ae..4937fcf932a 100644 --- a/Orange/widgets/data/icons/SQLTable.svg +++ b/Orange/widgets/data/icons/SQLTable.svg @@ -3,15 +3,27 @@ + + + - - + + - - diff --git a/Orange/widgets/data/icons/Save.svg b/Orange/widgets/data/icons/Save.svg index 3389676ddf2..557428ed335 100644 --- a/Orange/widgets/data/icons/Save.svg +++ b/Orange/widgets/data/icons/Save.svg @@ -3,10 +3,17 @@ + + + - - - - + + + + diff --git a/Orange/widgets/data/icons/SelectByDataIndex.svg b/Orange/widgets/data/icons/SelectByDataIndex.svg index cd6e10ade5f..9a088825936 100644 --- a/Orange/widgets/data/icons/SelectByDataIndex.svg +++ b/Orange/widgets/data/icons/SelectByDataIndex.svg @@ -2,23 +2,32 @@ + + + - - - - - - - - - + + + + + + + + - + diff --git a/Orange/widgets/data/icons/SelectColumns.svg b/Orange/widgets/data/icons/SelectColumns.svg index 76e493c6f69..f7ec832ac9c 100644 --- a/Orange/widgets/data/icons/SelectColumns.svg +++ b/Orange/widgets/data/icons/SelectColumns.svg @@ -3,28 +3,39 @@ - + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - + + + - - + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/Table.svg b/Orange/widgets/data/icons/Table.svg index 40dec0ee052..98b753e4319 100644 --- a/Orange/widgets/data/icons/Table.svg +++ b/Orange/widgets/data/icons/Table.svg @@ -1,23 +1,32 @@ - - - - - +* { color: #333; } +.ColorScheme-Text { color: #333; } +.ColorScheme-ViewBackground { color: #fff; } + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/Orange/widgets/data/icons/Transform.svg b/Orange/widgets/data/icons/Transform.svg index 6af2b5350e3..63496e8509a 100644 --- a/Orange/widgets/data/icons/Transform.svg +++ b/Orange/widgets/data/icons/Transform.svg @@ -2,29 +2,23 @@ - diff --git a/Orange/widgets/data/icons/Transpose.svg b/Orange/widgets/data/icons/Transpose.svg index 349428a5b24..88ec751f806 100644 --- a/Orange/widgets/data/icons/Transpose.svg +++ b/Orange/widgets/data/icons/Transpose.svg @@ -2,49 +2,37 @@ - - + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - - - - - - + + + + + + diff --git a/Orange/widgets/data/icons/Unique.svg b/Orange/widgets/data/icons/Unique.svg index 995d86a55b2..fdea5be1d11 100644 --- a/Orange/widgets/data/icons/Unique.svg +++ b/Orange/widgets/data/icons/Unique.svg @@ -1,76 +1,16 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - + + + + + + + + + + + diff --git a/Orange/widgets/data/owaggregatecolumns.py b/Orange/widgets/data/owaggregatecolumns.py index 8f7cd5d9599..ab37911c152 100644 --- a/Orange/widgets/data/owaggregatecolumns.py +++ b/Orange/widgets/data/owaggregatecolumns.py @@ -1,77 +1,123 @@ -from typing import List +from itertools import chain +from typing import List, NamedTuple, Callable import numpy as np -from AnyQt.QtWidgets import QSizePolicy +from AnyQt.QtWidgets import QSizePolicy, QStyle, \ + QButtonGroup, QRadioButton, QComboBox from AnyQt.QtCore import Qt + from Orange.data import Variable, Table, ContinuousVariable, TimeVariable from Orange.data.util import get_unique_names from Orange.widgets import gui, widget from Orange.widgets.settings import ( ContextSetting, Setting, DomainContextHandler ) +from Orange.widgets.utils.signals import AttributeList from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output from Orange.widgets.utils.itemmodels import DomainModel +class OpDesc(NamedTuple): + name: str + func: Callable[[np.ndarray], np.ndarray] + time_preserving: bool = False + + +def nancount_nonzero(a, axis=None): + return np.count_nonzero(np.nan_to_num(a), axis=axis) + + class OWAggregateColumns(widget.OWWidget): name = "Aggregate Columns" description = "Compute a sum, max, min ... of selected columns." + category = "Transform" icon = "icons/AggregateColumns.svg" - priority = 100 - keywords = ["aggregate", "sum", "product", "max", "min", "mean", - "median", "variance"] + priority = 1200 + keywords = "aggregate columns, aggregate, sum, product, max, min, mean, median, variance" class Inputs: data = Input("Data", Table, default=True) + features = Input("Features", AttributeList) class Outputs: data = Output("Data", Table) + class Warning(widget.OWWidget.Warning): + discrete_features = widget.Msg("Some input features are categorical:\n{}") + missing_features = widget.Msg("Some input features are missing:\n{}") + want_main_area = False + Operations = {"Sum": OpDesc("Sum", np.nansum), + "Product": OpDesc("Product", np.nanprod), + "Min": OpDesc("Minimal value", np.nanmin, True), + "Max": OpDesc("Maximal value", np.nanmax, True), + "Mean": OpDesc("Mean value", np.nanmean, True), + "Variance": OpDesc("Variance", np.nanvar), + "Median": OpDesc("Median", np.nanmedian, True), + "Count non-zero": OpDesc("Count non-zero", nancount_nonzero)} + KeyFromDesc = {op.name: key for key, op in Operations.items()} + + SelectAll, SelectAllAndMeta, InputFeatures, SelectManually = range(4) + settingsHandler = DomainContextHandler() variables: List[Variable] = ContextSetting([]) - operation = Setting("Sum") - var_name = Setting("agg") + selection_method: int = Setting(SelectManually, schema_only=True) + operation = ContextSetting("Sum") + var_name = Setting("agg", schema_only=True) auto_apply = Setting(True) - Operations = {"Sum": np.nansum, "Product": np.nanprod, - "Min": np.nanmin, "Max": np.nanmax, - "Mean": np.nanmean, "Variance": np.nanvar, - "Median": np.nanmedian} - TimePreserving = ("Min", "Max", "Mean", "Median") - def __init__(self): super().__init__() self.data = None + self.features = None - box = gui.vBox(self.controlArea, box=True) + self.selection_box = gui.vBox(self.controlArea, "Variable selection") + self.selection_group = QButtonGroup(self.selection_box) + for i, label in enumerate(("All", + "All, including meta attributes", + "Features from separate input signal", + "Selected variables")): + button = QRadioButton(label) + if i == self.selection_method: + button.setChecked(True) + self.selection_group.addButton(button, id=i) + self.selection_box.layout().addWidget(button) + self.selection_group.idClicked.connect(self._on_sel_method_changed) self.variable_model = DomainModel( - order=DomainModel.MIXED, valid_types=(ContinuousVariable, )) + order=(DomainModel.ATTRIBUTES, DomainModel.METAS), + valid_types=ContinuousVariable) + pixm: QStyle = self.style().pixelMetric + ind_width = pixm(QStyle.PM_ExclusiveIndicatorWidth) + \ + pixm(QStyle.PM_RadioButtonLabelSpacing) var_list = gui.listView( - box, self, "variables", model=self.variable_model, - callback=lambda: self.commit() # pylint: disable=W0108 + gui.indentedBox(self.selection_box, ind_width), self, "variables", + model=self.variable_model, + callback=self.commit.deferred ) var_list.setSelectionMode(var_list.ExtendedSelection) - combo = gui.comboBox( - box, self, "operation", - label="Operator: ", orientation=Qt.Horizontal, - items=list(self.Operations), sendSelectedValue=True, - callback=lambda: self.commit() # pylint: disable=W0108 - ) - combo.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + box = gui.vBox(self.controlArea, box="Operation") + combo = self.operation_combo = QComboBox() + combo.addItems([op.name for op in self.Operations.values()]) + combo.textActivated[str].connect(self._on_operation_changed) + combo.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) + combo.setCurrentText(self.Operations[self.operation].name) + box.layout().addWidget(combo) gui.lineEdit( box, self, "var_name", - label="Variable name: ", orientation=Qt.Horizontal, - callback=lambda: self.commit() # pylint: disable=W0108 + label="Output variable name: ", orientation=Qt.Horizontal, + callback=self.commit.deferred ) - gui.auto_apply(self.controlArea, self) + gui.auto_apply(self.buttonsArea, self) + + self._update_selection_buttons() + @Inputs.data def set_data(self, data: Table = None): @@ -81,55 +127,138 @@ def set_data(self, data: Table = None): if self.data: self.variable_model.set_domain(data.domain) self.openContext(data) + self.operation_combo.setCurrentText(self.Operations[self.operation].name) else: self.variable_model.set_domain(None) - self.unconditional_commit() + @Inputs.features + def set_features(self, features): + if features is None: + self.features = None + missing = [] + else: + self.features = [attr for attr in features if attr.is_continuous] + missing = self._missing(features, self.features) + self.Warning.discrete_features(missing, shown=bool(missing)) + + def _update_selection_buttons(self): + if self.features is not None: + for i, button in enumerate(self.selection_group.buttons()): + button.setChecked(i == self.InputFeatures) + button.setEnabled(i == self.InputFeatures) + self.controls.variables.setEnabled(False) + else: + for i, button in enumerate(self.selection_group.buttons()): + button.setChecked(i == self.selection_method) + button.setEnabled(i != self.InputFeatures) + self.controls.variables.setEnabled( + self.selection_method == self.SelectManually) + + def handleNewSignals(self): + self._update_selection_buttons() + self.commit.now() + + def _on_sel_method_changed(self, i): + self.selection_method = i + self._update_selection_buttons() + self.commit.deferred() + + def _on_operation_changed(self, oper): + self.operation = self.KeyFromDesc[oper] + self.commit.deferred() + + @gui.deferred def commit(self): augmented = self._compute_data() self.Outputs.data.send(augmented) def _compute_data(self): - if not self.data or not self.variables: + self.Warning.missing_features.clear() + if not self.data: + return self.data + + variables = self._variables() + if not self.data or not variables: return self.data - new_col = self._compute_column() - new_var = self._new_var() + new_col = self._compute_column(variables) + new_var = self._new_var(variables) return self.data.add_column(new_var, new_col) - def _compute_column(self): - arr = np.empty((len(self.data), len(self.variables))) - for i, var in enumerate(self.variables): - arr[:, i] = self.data.get_column_view(var)[0].astype(float) - func = self.Operations[self.operation] + def _variables(self): + self.Warning.missing_features.clear() + if self.features is not None: + selected = [attr for attr in self.features + if attr in self.data.domain] + missing = self._missing(self.features, selected) + self.Warning.missing_features(missing, shown=bool(missing)) + return selected + + assert self.data + + domain = self.data.domain + if self.selection_method == self.SelectAll: + return [attr for attr in domain.attributes + if attr.is_continuous] + if self.selection_method == self.SelectAllAndMeta: + # skip separators + return [attr for attr in chain(domain.attributes, domain.metas) + if attr.is_continuous] + + assert self.selection_method == self.SelectManually + return self.variables + + def _compute_column(self, variables): + arr = np.empty((len(self.data), len(variables))) + for i, var in enumerate(variables): + arr[:, i] = self.data.get_column(var) + func = self.Operations[self.operation].func return func(arr, axis=1) def _new_var_name(self): return get_unique_names(self.data.domain, self.var_name) - def _new_var(self): + def _new_var(self, variables): name = self._new_var_name() - if self.operation in self.TimePreserving \ - and all(isinstance(var, TimeVariable) for var in self.variables): + if self.Operations[self.operation].time_preserving \ + and all(isinstance(var, TimeVariable) for var in variables): return TimeVariable(name) return ContinuousVariable(name) def send_report(self): - # fp for self.variables, pylint: disable=unsubscriptable-object - if not self.data or not self.variables: + if not self.data: return - var_list = ", ".join(f"'{var.name}'" - for var in self.variables[:31][:-1]) - if len(self.variables) > 30: - var_list += f" and {len(self.variables) - 30} others" - else: - var_list += f" and '{self.variables[-1].name}'" + variables = self._variables() + if not variables: + return + var_list = self._and_others(variables, 30) self.report_items(( ("Output:", f"'{self._new_var_name()}' as {self.operation.lower()} of {var_list}" ), )) + @staticmethod + def _and_others(variables, limit): + if len(variables) == 1: + return f"'{variables[0].name}'" + var_list = ", ".join(f"'{var.name}'" + for var in variables[:limit + 1][:-1]) + if len(variables) > limit: + var_list += f" and {len(variables) - limit} more" + else: + var_list += f" and '{variables[-1].name}'" + return var_list + + @classmethod + def _missing(cls, given, used): + if len(given) == len(used): + return "" + used = set(used) + # Don't use set difference because it loses order + missing = [attr for attr in given if attr not in used] + return cls._and_others(missing, 5) + if __name__ == "__main__": # pragma: no cover brown = Table("brown-selected") diff --git a/Orange/widgets/data/owcolor.py b/Orange/widgets/data/owcolor.py index 2ff3c5bbb4f..3f9a3b108ba 100644 --- a/Orange/widgets/data/owcolor.py +++ b/Orange/widgets/data/owcolor.py @@ -18,6 +18,7 @@ from Orange.widgets import widget, settings, gui from Orange.widgets.gui import HorizontalGridDelegate from Orange.widgets.utils import itemmodels, colorpalettes +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.report import colored_square as square from Orange.widgets.widget import Input, Output @@ -41,7 +42,9 @@ class AttrDesc: new_name (str or `None`): a changed name or `None` """ def __init__(self, var): - self.var = var + # these objects are stored within context settings; + # avoid storing compute_values + self.var = var.copy(compute_value=None) self.new_name = None def reset(self): @@ -117,9 +120,9 @@ def set_value(self, i, value): self.new_values = list(self.var.values) self.new_values[i] = value - def create_variable(self): - new_var = self.var.copy(name=self.name, values=self.values, - compute_value=Identity(self.var)) + def create_variable(self, base_var): + new_var = base_var.copy(name=self.name, values=self.values, + compute_value=Identity(base_var)) new_var.colors = np.asarray(self.colors) return new_var @@ -208,9 +211,9 @@ def palette_name(self): def palette_name(self, palette_name): self.new_palette_name = palette_name - def create_variable(self): - new_var = self.var.copy(name=self.name, - compute_value=Identity(self.var)) + def create_variable(self, base_var): + new_var = base_var.copy(name=self.name, + compute_value=Identity(base_var)) new_var.attributes["palette"] = self.palette_name return new_var @@ -435,7 +438,7 @@ def paint(self, painter, option, index): strip = index.data(StripRole) rect = option.rect painter.drawPixmap( - rect.x() + 13, rect.y() + (rect.height() - strip.height()) / 2, + rect.x() + 13, int(rect.y() + (rect.height() - strip.height()) / 2), strip) super().paint(painter, option, index) @@ -537,6 +540,7 @@ class OWColor(widget.OWWidget): name = "Color" description = "Set color legend for variables." icon = "icons/Colors.svg" + keywords = "palette, legend" class Inputs: data = Input("Data", Orange.data.Table) @@ -603,15 +607,17 @@ def set_data(self, data): self.openContext(data) self.disc_view.resizeColumnsToContents() self.cont_view.resizeColumnsToContents() - self.unconditional_commit() + self.commit.now() def _on_data_changed(self): - self.commit() + self.commit.deferred() def reset(self): self.disc_model.reset() self.cont_model.reset() - self.commit() + # Reset button is in the same box as Load, which has commit.now, + # and Apply, hence let Reset commit now, too. + self.commit.now() def save(self): fname, _ = QFileDialog.getSaveFileName( @@ -645,16 +651,15 @@ def load(self): return try: - f = open(fname) + with open(fname) as f: + js = json.load(f) #: dict + self._parse_var_defs(js) except IOError: QMessageBox.critical(self, "File error", "File cannot be opened.") return - - try: - js = json.load(f) #: dict - self._parse_var_defs(js) except (json.JSONDecodeError, InvalidFileFormat): QMessageBox.critical(self, "File error", "Invalid file format.") + return def _parse_var_defs(self, js): if not isinstance(js, dict) or set(js) != {"categorical", "numeric"}: @@ -688,6 +693,7 @@ def _parse_var_defs(self, js): # First, construct all descriptions; assign later, after we know # there won't be exceptions due to invalid file format + unused_vars = [] both_descs = [] warnings = [] for old_desc, repo, desc_type in ( @@ -698,11 +704,26 @@ def _parse_var_defs(self, js): for var_name, var_data in js[repo].items(): var = var_by_name.get(var_name) if var is None: + unused_vars.append(var_name) continue # This can throw InvalidFileFormat new_descs[var_name], warn = desc_type.from_dict(var, var_data) warnings += warn both_descs.append(new_descs) + if unused_vars: + names = [f"'{name}'" for name in unused_vars] + if len(unused_vars) == 1: + warn = f'Definition for variable {names[0]}, which does not ' \ + f'appear in the data, was ignored.\n' + else: + if len(unused_vars) <= 5: + warn = 'Definitions for variables ' \ + f'{", ".join(names[:-1])} and {names[-1]}' + else: + warn = f'Definitions for {", ".join(names[:4])} ' \ + f'and {len(names) - 4} other variables' + warn += ", which do not appear in the data, were ignored.\n" + warnings.insert(0, warn) self.disc_descs = [both_descs[0].get(desc.var.name, desc) for desc in self.disc_descs] @@ -714,20 +735,21 @@ def _parse_var_defs(self, js): self.disc_model.set_data(self.disc_descs) self.cont_model.set_data(self.cont_descs) - self.unconditional_commit() + self.commit.now() def _start_dir(self): return self.workflowEnv().get("basedir") \ or QSettings().value("colorwidget/last-location") \ or os.path.expanduser(f"~{os.sep}") + @gui.deferred def commit(self): def make(variables): new_vars = [] for var in variables: source = disc_dict if var.is_discrete else cont_dict desc = source.get(var.name) - new_vars.append(desc.create_variable() if desc else var) + new_vars.append(desc.create_variable(var) if desc else var) return new_vars if self.data is None: @@ -784,7 +806,7 @@ def was(n, o): (name, _report_variables(variables)) for name, variables in ( ("Features", dom.attributes), - ("Outcome" + "s" * (len(dom.class_vars) > 1), dom.class_vars), + (f'{pl(len(dom.class_vars), "Outcome")}', dom.class_vars), ("Meta attributes", dom.metas))) table = "".join(f"
{name}
{rows}" for name, rows in sections if rows) diff --git a/Orange/widgets/data/owconcatenate.py b/Orange/widgets/data/owconcatenate.py index 3ecc73681ba..f4731a9b663 100644 --- a/Orange/widgets/data/owconcatenate.py +++ b/Orange/widgets/data/owconcatenate.py @@ -5,11 +5,10 @@ Concatenate (append) two or more datasets. """ - -from collections import OrderedDict, namedtuple, defaultdict +from collections import OrderedDict, namedtuple, defaultdict, Counter from functools import reduce -from itertools import chain, count -from typing import List +from itertools import chain, count, zip_longest +from typing import List, Optional, Sequence import numpy as np from AnyQt.QtWidgets import QFormLayout @@ -21,39 +20,45 @@ from Orange.widgets import widget, gui, settings from Orange.widgets.settings import Setting from Orange.widgets.utils.annotated_data import add_columns -from Orange.widgets.utils.sql import check_sql_input +from Orange.widgets.utils.sql import check_sql_input, check_sql_input_sequence from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import Input, Output, Msg +from Orange.widgets.widget import Input, MultiInput, Output, Msg class OWConcatenate(widget.OWWidget): name = "Concatenate" description = "Concatenate (append) two or more datasets." + category = "Transform" priority = 1111 icon = "icons/Concatenate.svg" - keywords = ["append", "join", "extend"] + keywords = "concatenate, append, join, extend" class Inputs: primary_data = Input("Primary Data", Orange.data.Table) - additional_data = Input("Additional Data", - Orange.data.Table, - multiple=True, - default=True) + additional_data = MultiInput( + "Additional Data", Orange.data.Table, default=True + ) class Outputs: data = Output("Data", Orange.data.Table) class Error(widget.OWWidget.Error): bow_concatenation = Msg("Inputs must be of the same type.") + incompatible_domains = \ + Msg("Ignoring column names requires matching column types") class Warning(widget.OWWidget.Warning): renamed_variables = Msg( "Variables with duplicated names have been renamed.") + unmergeable_attributes = Msg( + "Some variables may not be concatenated correctly due " + "to attributes difference ({}).") merge_type: int append_source_column: bool source_column_role: int source_attr_name: str + ignore_names: bool #: Domain merging operations MergeUnion, MergeIntersection = 0, 1 @@ -69,6 +74,8 @@ class Warning(widget.OWWidget.Warning): source_column_role = settings.Setting(0) #: User specified name for the "Source ID" attr source_attr_name = settings.Setting("Source ID") + #: Use names from the primary table, ignore others + ignore_names = settings.Setting(False) ignore_compute_value = settings.Setting(False) @@ -86,34 +93,36 @@ def __init__(self): super().__init__() self.primary_data = None - self.more_data = OrderedDict() - - self.mergebox = gui.vBox(self.controlArea, "Variable Merging") - box = gui.radioButtons( - self.mergebox, self, "merge_type", - callback=self._merge_type_changed) + self._more_data_input: List[Optional[Orange.data.Table]] = [] + self.mergebox = gui.vBox(self.controlArea, "Variable Sets Merging") gui.widgetLabel( - box, self.tr("When there is no primary table, " + - "the output should contain:")) - - for opts in self.domain_opts: - gui.appendRadioButton(box, self.tr(opts)) + self.mergebox, self.tr("When there is no primary table, " + + "the output should contain")) - gui.separator(box) + gui.radioButtons( + gui.indentedBox(self.mergebox, 10), self, "merge_type", self.domain_opts, + callback=self._merge_type_changed) label = gui.widgetLabel( - box, + self.mergebox, self.tr("The resulting table will have a class only if there " + "is no conflict between input classes.")) label.setWordWrap(True) - gui.separator(box) + box = gui.vBox(self.controlArea, "Variable matching") + gui.checkBox( + box, self, "ignore_names", + "Use column names from the primary table,\n" + "and ignore names in other tables.", + callback=self.ignore_names_changed, stateWhenDisabled=False) + + gui.separator(self.controlArea) gui.checkBox( box, self, "ignore_compute_value", "Treat variables with the same name as the same variable,\n" "even if they are computed using different formulae.", - callback=self.apply, stateWhenDisabled=False) + callback=self.commit.deferred, stateWhenDisabled=False) ### box = gui.vBox( self.controlArea, self.tr("Source Identification"), @@ -150,7 +159,7 @@ def __init__(self): cb.disables.append(ibox) cb.makeConsistent() - gui.auto_apply(self.buttonsArea, self, "auto_commit", commit=self.apply) + gui.auto_apply(self.buttonsArea, self, "auto_commit") @Inputs.primary_data @check_sql_input @@ -158,43 +167,63 @@ def set_primary_data(self, data): self.primary_data = data @Inputs.additional_data - @check_sql_input - def set_more_data(self, data=None, sig_id=None): - if data is not None: - self.more_data[sig_id] = data - elif sig_id in self.more_data: - del self.more_data[sig_id] + @check_sql_input_sequence + def set_more_data(self, index, data): + self._more_data_input[index] = data + + @Inputs.additional_data.insert + @check_sql_input_sequence + def insert_more_data(self, index, data): + self._more_data_input.insert(index, data) + + @Inputs.additional_data.remove + def remove_more_data(self, index): + self._more_data_input.pop(index) + + @property + def more_data(self) -> Sequence[Orange.data.Table]: + return [t for t in self._more_data_input if t is not None] def handleNewSignals(self): self.mergebox.setDisabled(self.primary_data is not None) + self.controls.ignore_names.setEnabled(self.primary_data is not None) + self.controls.ignore_compute_value.setDisabled( + self.primary_data is not None and self.ignore_names) if self.incompatible_types(): self.Error.bow_concatenation() else: self.Error.bow_concatenation.clear() - self.unconditional_apply() + self.commit.now() + + def ignore_names_changed(self): + self.controls.ignore_compute_value.setDisabled(self.ignore_names) + self.commit.deferred() def incompatible_types(self): types_ = set() if self.primary_data is not None: types_.add(type(self.primary_data)) - for key in self.more_data: - types_.add(type(self.more_data[key])) + for table in self.more_data: + types_.add(type(table)) if len(types_) > 1: return True return False - def apply(self): + @gui.deferred + def commit(self): self.Warning.renamed_variables.clear() + self.Warning.unmergeable_attributes.clear() + self.Error.incompatible_domains.clear() tables, domain, source_var = [], None, None if self.primary_data is not None: - tables = [self.primary_data] + list(self.more_data.values()) + tables = [self.primary_data] + list(self.more_data) domain = self.primary_data.domain elif self.more_data: if self.ignore_compute_value: tables = self._dumb_tables() else: - tables = self.more_data.values() + tables = self.more_data domains = [table.domain for table in tables] domain = self.merge_domains(domains) @@ -208,20 +237,46 @@ def apply(self): get_unique_names(domain, self.source_attr_name), values=names ) - places = ["class_vars", "attributes", "metas"] - domain = add_columns( - domain, - **{places[self.source_column_role]: (source_var,)}) + source_ids = np.array(list(flatten( + [i] * len(table) for i, table in enumerate(tables)))).reshape((-1, 1)) - tables = [table.transform(domain) for table in tables] - if tables: - data = type(tables[0]).concatenate(tables) - if source_var: - source_ids = np.array(list(flatten( - [i] * len(table) for i, table in enumerate(tables)))).reshape((-1, 1)) - data[:, source_var] = source_ids - else: + if not tables: data = None + elif self.primary_data is not None and self.ignore_names: + if any(type(pv) is not type(mv) or + pv.is_discrete and pv.values != mv.values + for table in self.more_data + for pv, mv in chain(zip_longest(domain.attributes, table.domain.attributes), + zip_longest(domain.class_vars, table.domain.class_vars), + zip_longest(domain.metas, table.domain.metas) + ) + ): + self.Error.incompatible_domains() + data = None + else: + data = type(tables[0]).concatenate(tables, ignore_domains=True) + if source_var is not None: + if self.source_column_role == self.ClassRole: + sdata = data.Table.from_numpy( + data.Domain([], source_var), + np.zeros(len(source_ids, 0)), source_ids) + data = type(tables[0].concatenate(sdata, axis=1)) + else: + data = data.add_column( + source_var, source_ids.flatten(), + to_metas=self.source_column_role == self.MetaRole) + else: + if source_var is not None: + places = ["class_vars", "attributes", "metas"] + domain = add_columns( + domain, + **{places[self.source_column_role]: (source_var,)}) + tables = [table.transform(domain) for table in tables] + data = type(tables[0]).concatenate(tables) + if source_var is not None: + parts = [data.Y, data.X, data.metas] + with data.unlocked(parts[self.source_column_role]): + data[:, source_var] = source_ids self.Outputs.data.send(data) @@ -230,7 +285,7 @@ def enumerated_parts(domain): return enumerate((domain.attributes, domain.class_vars, domain.metas)) compute_value_groups = defaultdict(set) - for table in self.more_data.values(): + for table in self.more_data: for part, part_vars in enumerated_parts(table.domain): for var in part_vars: desc = (var.name, type(var), part) @@ -240,7 +295,7 @@ def enumerated_parts(domain): if len(compute_values) > 1} dumb_tables = [] - for table in self.more_data.values(): + for table in self.more_data: dumb_domain = Orange.data.Domain( *[[var.copy(compute_value=None) if (var.name, type(var), part) in to_dumbify @@ -251,6 +306,7 @@ def enumerated_parts(domain): dumb_domain, table.X, table.Y, table.metas, table.W, table.attributes, table.ids) + dumb_table.name = table.name dumb_tables.append(dumb_table) return dumb_tables @@ -260,10 +316,10 @@ def _merge_type_changed(self, ): else: self.Error.bow_concatenation.clear() if self.primary_data is None and self.more_data: - self.apply() + self.commit.deferred() def _source_changed(self): - self.apply() + self.commit.deferred() def send_report(self): items = OrderedDict() @@ -278,12 +334,42 @@ def send_report(self): self.report_items(items) def merge_domains(self, domains): + variables = set(chain.from_iterable( + [d.variables + d.metas for d in domains])) + + feature_attrs = defaultdict(list) + for var in variables: + for domain in domains: + if var not in domain: + continue + for key, value in domain[var].attributes.items(): + feature_attrs[var].append((key, value)) + def fix_names(part): for i, attr, name in zip(count(), part, name_iter): if attr.name != name: part[i] = attr.renamed(name) self.Warning.renamed_variables() + def fix_attrs(part): + fixed = [] + for attr in part: + attrs = feature_attrs[attr] + if len(attrs) > 0: + attr = attr.copy() + attr.attributes = dict(attrs) + # find duplicated keys with different values - can't use + # set because values are not necessarily hashable + counter = Counter(k for k, _ in attrs) + for duplicate in [k for k, v in counter.items() if v > 1]: + values = [v for k, v in feature_attrs[attr] + if k == duplicate] + if len(values) > 1: + if any(values[0] != v for v in values[1:]): + self.Warning.unmergeable_attributes(attr.name) + fixed.append(attr) + return fixed + oper = set.union if self.merge_type == OWConcatenate.MergeUnion \ else set.intersection parts = [self._get_part(domains, oper, part) @@ -292,6 +378,7 @@ def fix_names(part): name_iter = iter(get_unique_names_duplicates(all_names)) for part in parts: fix_names(part) + parts = [fix_attrs(part) for part in parts] domain = Orange.data.Domain(*parts) return domain @@ -352,5 +439,5 @@ def _unique_vars(seq: List[Orange.data.Variable]): if __name__ == "__main__": # pragma: no cover WidgetPreview(OWConcatenate).run( - set_more_data=[(Orange.data.Table("iris"), 0), - (Orange.data.Table("zoo"), 1)]) + insert_more_data=[(0, Orange.data.Table("iris")), + (1, Orange.data.Table("zoo"))]) diff --git a/Orange/widgets/data/owcontinuize.py b/Orange/widgets/data/owcontinuize.py index 7c944319cb8..bddd1bb82c8 100644 --- a/Orange/widgets/data/owcontinuize.py +++ b/Orange/widgets/data/owcontinuize.py @@ -1,161 +1,704 @@ -from functools import reduce +from functools import partial from types import SimpleNamespace +from typing import NamedTuple, Dict, List -from AnyQt.QtWidgets import QGridLayout +import numpy as np +import scipy.sparse as sp -import Orange.data -from Orange.util import Reprable -from Orange.statistics import distribution -from Orange.preprocess import Continuize +from AnyQt.QtCore import Qt, QSize, QAbstractListModel, QObject, \ + QItemSelectionModel +from AnyQt.QtGui import QColor +from AnyQt.QtWidgets import QButtonGroup, QRadioButton, QListView + +from orangewidget.utils import listview +from orangewidget.utils.itemmodels import SeparatedListDelegate, \ + LabelledSeparator + +from Orange.data import DiscreteVariable, ContinuousVariable, Domain, Table +from Orange.preprocess import Continuize as Continuizer from Orange.preprocess.transformation import Identity, Indicator, Normalizer -from Orange.data.table import Table from Orange.widgets import gui, widget from Orange.widgets.settings import Setting +from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output +class MethodDesc(NamedTuple): + id_: int + label: str # Label used for radio button + short_desc: str # Short description for list views + tooltip: str # Tooltip for radio button + supports_sparse: bool = True + + +DefaultKey = "" +DefaultId = 99 +BackCompatClass = object() + +Continuize = SimpleNamespace( + Default=DefaultId, + **{v.name: v.value for v in Continuizer.MultinomialTreatment}) + +DiscreteOptions: Dict[int, MethodDesc] = { + method.id_: method + for method in ( + MethodDesc( + Continuize.Default, "Use general preset", "preset", + "Treat the variable as defined in general preset"), + MethodDesc( + Continuize.Leave, "Keep categorical", "keep as is", + "Keep the variable discrete"), + MethodDesc( + Continuize.FirstAsBase, "First value as base", "first as base", + "One indicator variable for each value except the first"), + MethodDesc( + Continuize.FrequentAsBase, "Most frequent as base", "frequent as base", + "One indicator variable for each value except the most frequent", + False), + MethodDesc( + Continuize.Indicators, "One-hot encoding", "one-hot", + "One indicator variable for each value", + False), + MethodDesc( + Continuize.RemoveMultinomial, "Remove if more than 2 values", "remove if >2", + "Remove variables with more than two values; indicator otherwise"), + MethodDesc( + Continuize.Remove, "Remove", "remove", + "Remove variable"), + MethodDesc( + Continuize.AsOrdinal, "Treat as ordinal", "as ordinal", + "Each value gets a consecutive number from 0 to number of values - 1"), + MethodDesc( + Continuize.AsNormalizedOrdinal, "Treat as normalized ordinal", "as norm. ordinal", + "Same as above, but scaled to [0, 1]") + )} + +ContinuizationDefault = Continuize.FirstAsBase + + +Normalize = SimpleNamespace(Default=DefaultId, + Leave=0, Standardize=1, Center=2, Scale=3, + Normalize11=4, Normalize01=5) + +ContinuousOptions: Dict[int, MethodDesc] = { + method.id_: method + for method in ( + MethodDesc( + Normalize.Default, "Use general preset", "preset", + "Treat the variable as defined in general preset"), + MethodDesc( + Normalize.Leave, "Keep as it is", "no change", + "Keep the variable as it is"), + MethodDesc( + Normalize.Standardize, "Standardize to μ=0, σ²=1", "standardize", + "Subtract the mean and divide by standard deviation", + False), + MethodDesc( + Normalize.Center, "Center to μ=0", "center", + "Subtract the mean", + False), + MethodDesc( + Normalize.Scale, "Scale to σ²=1", "scale", + "Divide by standard deviation"), + MethodDesc( + Normalize.Normalize11, "Normalize to interval [-1, 1]", "to [-1, 1]", + "Linear transformation into interval [-1, 1]", + False), + MethodDesc( + Normalize.Normalize01, "Normalize to interval [0, 1]", "to [0, 1]", + "Linear transformation into interval [0, 1]", + False), + )} + +NormalizationDefault = Normalize.Leave + + +class ContDomainModel(DomainModel): + HintRole = next(gui.OrangeUserRole) + FilterRole = next(gui.OrangeUserRole) + """Domain model that adds description of chosen methods""" + def __init__(self, valid_type): + super().__init__( + order=(DomainModel.ATTRIBUTES, + LabelledSeparator("Meta attributes"), DomainModel.METAS, + LabelledSeparator("Targets"), DomainModel.CLASSES), + valid_types=(valid_type, ), strict_type=True) + + def data(self, index, role=Qt.DisplayRole): + if role == Qt.ToolTipRole: + return None + if role == self.FilterRole: + name = super().data(index, Qt.DisplayRole) + if not isinstance(name, str): + return None + hint = index.data(self.HintRole) + if hint is None: + return name + return f"{name} {hint[0]}" + value = super().data(index, role) + if role == Qt.DisplayRole: + if isinstance(value, LabelledSeparator): + return None + return value, *(index.data(self.HintRole) or ("", False)) + return value + + +class DefaultContModel(QAbstractListModel): + """A model used for showing "Default settings" above the list view""" + icon = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if DefaultContModel.icon is None: + DefaultContModel.icon = gui.createAttributePixmap( + "★", QColor(0, 0, 0, 0), Qt.black) + self.method = "" + + @staticmethod + def rowCount(parent): + return 0 if parent.isValid() else 1 + + @staticmethod + def columnCount(parent): + return 0 if parent.isValid() else 1 + + def data(self, _, role=Qt.DisplayRole): + if role == Qt.DisplayRole: + return f"General preset: {self.method}" + elif role == Qt.DecorationRole: + return self.icon + elif role == Qt.ToolTipRole: + return "Default for variables without specific settings" + return None + + def setMethod(self, method): + self.method = method + self.dataChanged.emit(self.index(0, 0), self.index(0, 0)) + + +class ListViewSearch(listview.ListViewSearch): + """ + A list view with two components shown above it: + - a listview containing a single item representing default settings + - a filter for search + + The class is based on listview.ListViewSearch and needs to have the same + name in order to override its private method __layout. + + Inherited __init__ calls __layout, so `default_view` must be constructed + there. Construction before calling super().__init__ doesn't work because + PyQt does not allow it. + """ + class Delegate(SeparatedListDelegate): + """ + A delegate that shows items (variables) with specific settings in bold + """ + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + hint = index.data(ContDomainModel.HintRole) + option.font.setBold(hint is not None and hint[1]) + + def displayText(self, value, _): + if value is None: + return None + name, hint, _ = value + return f"{name}: {hint}" + + def __init__(self, *args, **kwargs): + self.default_view = None + super().__init__(preferred_size=QSize(350, -1), *args, **kwargs) + self.setItemDelegate(self.Delegate(self)) + self.force_hints = False + + def select_default(self): + """Select the item representing default settings""" + self.default_view.selectionModel().select( + self.default_view.model().index(0), + QItemSelectionModel.Select) + + def set_default_method(self, method): + self.default_view.model().setMethod(method) + + # pylint: disable=unused-private-member + def __layout(self): + if self.default_view is None: # __layout was called from __init__ + view = self.default_view = QListView(self) + view.setModel(DefaultContModel()) + self.filterProxyModel().setFilterRole(ContDomainModel.FilterRole) + view.verticalScrollBar().setDisabled(True) + view.horizontalScrollBar().setDisabled(True) + view.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + view.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + font = view.font() + font.setBold(True) + view.setFont(font) + else: + view = self.default_view + + # Put the list view with default on top + margins = self.viewportMargins() + def_height = view.sizeHintForRow(0) + 2 * view.spacing() + 2 + view.setGeometry(0, 0, self.geometry().width(), def_height) + view.setFixedHeight(def_height) + + # Then search + search = self.__search + src_height = search.sizeHint().height() + size = self.size() + search.setGeometry(0, def_height + 2, size.width(), src_height) + + # Then the real list view + margins.setTop(def_height + 2 + src_height) + self.setViewportMargins(margins) + + class OWContinuize(widget.OWWidget): + # Many false positives for `hints`; pylint ignores type annotations + # pylint: disable=unsubscriptable-object,unsupported-assignment-operation + # pylint: disable=unsupported-membership-test, unsupported-delete-operation name = "Continuize" description = ("Transform categorical attributes into numeric and, " + - "optionally, normalize numeric values.") + "optionally, scale numeric values.") icon = "icons/Continuize.svg" - category = "Data" - keywords = ["encode", "dummy", "numeric", "one-hot", "binary", - "treatment", "contrast"] + category = "Transform" + keywords = "continuize, encode, dummy, numeric, one-hot, binary, treatment, contrast" + priority = 2120 class Inputs: - data = Input("Data", Orange.data.Table) + data = Input("Data", Table) class Outputs: - data = Output("Data", Orange.data.Table) + data = Output("Data", Table) - want_main_area = False - resizing_enabled = False + class Error(widget.OWWidget.Error): + unsupported_sparse = \ + widget.Msg("Some chosen methods do not support sparse data: {}") - Normalize = SimpleNamespace(Leave=0, Standardize=1, Center=2, Scale=3, - Normalize11=4, Normalize01=5) + want_control_area = False - settings_version = 2 - multinomial_treatment = Setting(0) - continuous_treatment = Setting(Normalize.Leave) - class_treatment = Setting(0) + settings_version = 3 + disc_var_hints: Dict[str, int] = Setting( + {DefaultKey: ContinuizationDefault}, schema_only=True) + cont_var_hints: Dict[str, int] = Setting( + {DefaultKey: NormalizationDefault}, schema_only=True) autosend = Setting(True) - multinomial_treats = ( - ("First value as base", Continuize.FirstAsBase), - ("Most frequent value as base", Continuize.FrequentAsBase), - ("One attribute per value", Continuize.Indicators), - ("Ignore multinomial attributes", Continuize.RemoveMultinomial), - ("Remove categorical attributes", Continuize.Remove), - ("Treat as ordinal", Continuize.AsOrdinal), - ("Divide by number of values", Continuize.AsNormalizedOrdinal)) - - continuous_treats = ( - ("Leave them as they are", True), - ("Standardize to μ=0, σ²=1", False), - ("Center to μ=0", False), - ("Scale to σ²=1", True), - ("Normalize to interval [-1, 1]", False), - ("Normalize to interval [0, 1]", False) - ) - - class_treats = ( - ("Leave it as it is", Continuize.Leave), - ("Treat as ordinal", Continuize.AsOrdinal), - ("Divide by number of values", Continuize.AsNormalizedOrdinal), - ("One class per value", Continuize.Indicators), - ) - def __init__(self): super().__init__() - - layout = QGridLayout() - gui.widgetBox(self.controlArea, orientation=layout) - - box = gui.radioButtonsInBox( - None, self, "multinomial_treatment", box="Categorical Features", - btnLabels=[x[0] for x in self.multinomial_treats], - callback=self.settings_changed) - gui.rubber(box) - layout.addWidget(box, 0, 0, 2, 1) - - box = gui.radioButtonsInBox( - None, self, "continuous_treatment", box = "Numeric Features", - btnLabels=[x[0] for x in self.continuous_treats], - callback=self.settings_changed) - gui.rubber(box) - layout.addWidget(box, 0, 1, 2, 1) - - box = gui.radioButtonsInBox( - None, self, "class_treatment", box="Categorical Outcome(s)", - btnLabels=[t[0] for t in self.class_treats], - callback=self.settings_changed) - gui.rubber(box) - layout.addWidget(box, 0, 2, 2, 1) - - gui.auto_apply(self.buttonsArea, self, "autosend") - self.data = None - - def settings_changed(self): - self.commit() + self._var_cache = {} + + def create(title, vartype, methods): + hbox = gui.hBox(box, title) + view = ListViewSearch( + selectionMode=ListViewSearch.ExtendedSelection, + uniformItemSizes=True) + view.setModel(ContDomainModel(vartype)) + view.selectionModel().selectionChanged.connect( + lambda: self._on_var_selection_changed(view)) + view.default_view.selectionModel().selectionChanged.connect( + lambda selected: self._on_default_selected(view, selected)) + hbox.layout().addWidget(view) + + bbox = gui.vBox(hbox) + bgroup = QButtonGroup(self) + bgroup.idClicked.connect(self._on_radio_clicked) + for desc in methods.values(): + button = QRadioButton(desc.label) + button.setToolTip(desc.tooltip) + bgroup.addButton(button, desc.id_) + bbox.layout().addWidget(button) + bbox.layout().addStretch(1) + return hbox, view, bbox, bgroup + + box = gui.vBox(self.mainArea, True, spacing=8) + self.disc_box, self.disc_view, self.disc_radios, self.disc_group = \ + create("Categorical Variables", DiscreteVariable, DiscreteOptions) + self.disc_view.set_default_method( + DiscreteOptions[self.disc_var_hints[DefaultKey]].short_desc) + self.disc_view.select_default() + + self.cont_box, self.cont_view, self.cont_radios, self.cont_group = \ + create("Numeric Variables", ContinuousVariable, ContinuousOptions) + self.cont_view.set_default_method( + ContinuousOptions[self.cont_var_hints[DefaultKey]].short_desc) + self.cont_view.select_default() + + boxes = (self.disc_radios, self.cont_radios) + width = max(box.sizeHint().width() for box in boxes) + for box in boxes: + box.setFixedWidth(width) + + box = gui.hBox(self.mainArea) + gui.button( + box, self, "Reset All", callback=self._on_reset_hints, + autoDefault=False) + gui.rubber(box) + gui.auto_apply(box, self, "autosend") + + def _on_var_selection_changed(self, view): + if not view.selectionModel().selectedIndexes(): + # Prevent infinite recursion (with _on_default_selected) + return + view.default_view.selectionModel().clearSelection() + self._update_radios(view) + + def _on_default_selected(self, view, selected): + if not selected: + # Prevent infinite recursion (with _var_selection_selected) + return + view.selectionModel().clearSelection() + self._update_radios(view) + + def selected_vars(self, view) -> List[str]: + """ + Return selected variables + + If 'Default settings' are selected, this returns DefaultKey + """ + model = view.model() + return [model[index.row()] + for index in view.selectionModel().selectedRows()] + + def _update_radios(self, view): + if view is self.disc_view: + group, hints = self.disc_group, self.disc_var_hints + else: + group, hints = self.cont_group, self.cont_var_hints + + selvars = self.selected_vars(view) + if not selvars: + self._check_button(group, hints[DefaultKey], True) + self._set_radio_enabled(group, DefaultId, False) + return + + self._set_radio_enabled(group, DefaultId, True) + options = {hints.get(var.name, self.default_for_var(var)) + for var in selvars} + if len(options) == 1: + self._check_button(group, options.pop(), True) + else: + self._uncheck_all_buttons(group) + + @staticmethod + def _uncheck_all_buttons(group: QButtonGroup): + button = group.checkedButton() + if button is not None: + group.setExclusive(False) + button.setChecked(False) + group.setExclusive(True) + + def _set_radio_enabled( + self, group: QButtonGroup, method_id: int, value: bool): + if group.button(method_id).isChecked() and not value: + self._uncheck_all_buttons(group) + group.button(method_id).setEnabled(value) + + @staticmethod + def _check_button(group: QButtonGroup, method_id: int, checked: bool): + group.button(method_id).setChecked(checked) + + def _on_radio_clicked(self, method_id: int): + if QObject().sender() is self.disc_group: + view, hints, methods = \ + self.disc_view, self.disc_var_hints, DiscreteOptions + leave_id = Continuize.Leave + else: + view, hints, methods = \ + self.cont_view, self.cont_var_hints, ContinuousOptions + leave_id = Normalize.Leave + selvars = self.selected_vars(view) + if not selvars: + hints[DefaultKey] = method_id + view.set_default_method(methods[method_id].short_desc) + else: + keys = [var.name for var in selvars] + indexes = view.selectionModel().selectedIndexes() + model = view.model() + # These two keys may delete values from dict, hence we must loop + if method_id in (DefaultId, leave_id): + for key in keys: + # Attributes do not store the hint if it equals Default; + # metas and targets do not store it if it is Leave + if method_id == (DefaultId if self.is_attr(key) else leave_id): + if key in hints: + del hints[key] + else: + hints[key] = method_id + else: + hints.update(dict.fromkeys(keys, method_id)) + desc = methods[method_id].short_desc + for index, var in zip(indexes, selvars): + show = method_id != self.default_for_var(var) + model.setData(index, (desc, show), model.HintRole) + self.commit.deferred() @Inputs.data @check_sql_input - def setData(self, data): + def set_data(self, data): self.data = data - self.enable_normalization() - if data is None: - self.Outputs.data.send(None) + self._var_cache.clear() + domain = data.domain if data else None + self.disc_view.model().set_domain(domain) + self.cont_view.model().set_domain(domain) + if data: + # Clean up hints only when receiving new data, not on disconnection + self._set_hints() + self.commit.now() + + def _set_hints(self): + assert self.data + + # Backward compatibility for settings < 3 + class_treatment = self.disc_var_hints.get(BackCompatClass, None) + if class_treatment is not None \ + and self.data.domain.class_var is not None: + self.disc_var_hints[self.data.domain.class_var.name] \ + = class_treatment + + for hints, model, options in ( + (self.cont_var_hints, self.cont_view.model(), ContinuousOptions), + (self.disc_var_hints, self.disc_view.model(), DiscreteOptions)): + filtered = {DefaultKey: hints[DefaultKey]} + for i, var in enumerate(model): + if isinstance(var, LabelledSeparator): + continue + default = self.default_for_var(var) + method_id = hints.get(var.name, default) + nondefault = method_id != default + if nondefault: + filtered[var.name] = method_id + model.setData( + model.index(i, 0), + (options[method_id].short_desc, nondefault), + model.HintRole) + hints.clear() + hints.update(filtered) + + def _on_reset_hints(self): + if not self.data: + return + self.cont_var_hints.clear() + self.disc_var_hints.clear() + self.disc_var_hints[DefaultKey] = ContinuizationDefault + self.cont_var_hints[DefaultKey] = NormalizationDefault + self._set_hints() + self.cont_view.set_default_method( + ContinuousOptions[ContinuizationDefault].short_desc) + self.disc_view.set_default_method( + ContinuousOptions[NormalizationDefault].short_desc) + + @gui.deferred + def commit(self): + self.Outputs.data.send(self._prepare_output()) + + def _prepare_output(self): + self.Error.unsupported_sparse.clear() + if not self.data: + return None + if unsupp_sparse := self._unsupported_sparse(): + if len(unsupp_sparse) == 1: + self.Error.unsupported_sparse(unsupp_sparse[0]) + else: + self.Error.unsupported_sparse("\n" + ", ".join(unsupp_sparse)) + return None + + domain = self.data.domain + attrs = self._create_vars(domain.attributes) + class_vars = self._create_vars(domain.class_vars) + metas = self._create_vars(domain.metas) + return self.data.transform(Domain(attrs, class_vars, metas)) + + def _unsupported_sparse(self): + # time is not continuous, pylint: disable=unidiomatic-typecheck + domain = self.data.domain + disc = set() + cont = set() + # At the time of writing, self.data.Y cannot be sparse (setter for + # `Y` converts it to dense, as done in + # https://github.com/biolab/orange3/commit/a18f38059caf37f3b329d6ad688189561959bb24) + # Including it here doesn't hurt, though. + for part, attrs in ((self.data.X, domain.attributes), + (self.data.Y, domain.class_vars), + (self.data.metas, domain.metas)): + if sp.issparse(part): + disc |= {self._hint_for_var(var) + for var in attrs + if var.is_discrete} + cont |= {self._hint_for_var(var) + for var in attrs + if type(var) is ContinuousVariable} + disc &= {method.id_ + for method in DiscreteOptions.values() + if not method.supports_sparse} + cont &= {method.id_ + for method in ContinuousOptions.values() + if not method.supports_sparse} + + # Retrieve them from DiscreteOptions/ContinuousOptions to keep the order + return [method.label + for methods, problems in ((DiscreteOptions, disc), + (ContinuousOptions, cont)) + for method in methods.values() if method.id_ in problems] + + def _create_vars(self, part): + # time is not continuous, pylint: disable=unidiomatic-typecheck + return sum( + (self._continuized_vars(var) if var.is_discrete + else self._scaled_vars(var) if type(var) is ContinuousVariable + else [var] + for var in part), + start=[]) + + def _get(self, var, stat): + def most_frequent(col): + col = col[np.isfinite(col)].astype(int) + counts = np.bincount(col, minlength=len(var.values)) + return np.argmax(counts) + + funcs = {"min": np.nanmin, "max": np.nanmax, + "mean": np.nanmean, "std": np.nanstd, + "major": most_frequent} + name = var.name + cache = self._var_cache.setdefault(name, {}) + if stat not in cache: + cache[stat] = funcs[stat](self.data.get_column(var)) + return cache[stat] + + def is_attr(self, var): + domain = self.data.domain + return 0 <= domain.index(var) < len(domain.attributes) + + def default_for_var(self, var): + if self.is_attr(var): + return DefaultId + return Continuize.Leave if var.is_discrete else Normalize.Leave + + def _hint_for_var(self, var): + if var.is_discrete: + hints, leave_id = self.disc_var_hints, Continuize.Leave else: - self.unconditional_commit() + hints, leave_id = self.cont_var_hints, Normalize.Leave + + # Default for attributes is given by "default" + if self.is_attr(var): + return hints.get(var.name, hints[DefaultKey]) + + # For metas and targets, default is Leave + # If user changes it to "Default", default is used + hint = hints.get(var.name, leave_id) + if hint == DefaultId: + hint = hints[DefaultKey] + return hint + + def _scaled_vars(self, var): + hint = self._hint_for_var(var) + if hint == Normalize.Leave: + return [var] - def enable_normalization(self): - buttons = self.controls.continuous_treatment.buttons - if self.data is not None and self.data.is_sparse(): - if self.continuous_treatment == self.Normalize.Standardize: - self.continuous_treatment = self.Normalize.Scale - else: - self.continuous_treatment = self.Normalize.Leave - for button, (_, supports_sparse) \ - in zip(buttons, self.continuous_treats): - button.setEnabled(supports_sparse) + get = partial(self._get, var) + if hint == Normalize.Standardize: + off, scale = get("mean"), 1 / (get("std") or 1) + elif hint == Normalize.Center: + off, scale = get("mean"), 1 + elif hint == Normalize.Scale: + off, scale = 0, 1 / (get("std") or 1) else: - for button in buttons: - button.setEnabled(True) + assert hint in (Normalize.Normalize11, Normalize.Normalize01), f"hint={hint}?!" + min_, max_ = get("min"), get("max") + span = (max_ - min_) or 1 + if hint == Normalize.Normalize11: + off, scale = (min_ + max_) / 2, 2 / span + else: + off, scale = min_, 1 / span - def constructContinuizer(self): - conzer = DomainContinuizer( - multinomial_treatment=self.multinomial_treats[self.multinomial_treatment][1], - continuous_treatment=self.continuous_treatment, - class_treatment=self.class_treats[self.class_treatment][1] - ) - return conzer + return [ContinuousVariable( + var.name, + compute_value=Normalizer(var, off, scale))] - def commit(self): - continuizer = self.constructContinuizer() - if self.data: - domain = continuizer(self.data) - data = self.data.transform(domain) - self.Outputs.data.send(data) + def _continuized_vars(self, var, hint=None): + if hint is None: + hint = self._hint_for_var(var) + + # Single variable + if hint == Continuize.Leave: + return [var] + if hint == Continuize.Remove: + return [] + if hint == Continuize.RemoveMultinomial and len(var.values) <= 2 or \ + hint == Continuize.AsOrdinal: + return [ContinuousVariable(var.name, + compute_value=Identity(var))] + if hint == Continuize.RemoveMultinomial: + assert len(var.values) > 2 + return [] + if hint == Continuize.AsOrdinal: + return [ContinuousVariable(var.name, + compute_value=Identity(var))] + if hint == Continuize.AsNormalizedOrdinal: + scale = 1 / (len(var.values) - 1 or 1) + return [ContinuousVariable(var.name, + compute_value=Normalizer(var, 0, scale))] + + # Multiple dummy variables + if hint == Continuize.FirstAsBase: + base = 0 + elif hint == Continuize.FrequentAsBase: + base = self._get(var, "major") + elif hint == Continuize.Indicators: + base = None else: - self.Outputs.data.send(self.data) # None or empty data + assert False, f"hint={hint}?!" + return [ + ContinuousVariable(f"{var.name}={value}", + compute_value=Indicator(var, value=i)) + for i, value in enumerate(var.values) + if i != base + ] def send_report(self): - self.report_items( - "Settings", - [("Categorical features", - self.multinomial_treats[self.multinomial_treatment][0]), - ("Numeric features", - self.continuous_treats[self.continuous_treatment][0]), - ("Class", self.class_treats[self.class_treatment][0])]) + if not self.data: + return + single_disc = len(self.disc_view.model()) > 0 \ + and len(self.disc_var_hints) == 1 \ + and DiscreteOptions[self.disc_var_hints[DefaultKey]].label.lower() + single_cont = len(self.cont_view.model()) > 0 \ + and len(self.cont_var_hints) == 1 \ + and ContinuousOptions[self.cont_var_hints[DefaultKey]].label.lower() + if single_disc and single_cont: + self.report_items( + (("Categorical variables", single_disc), + ("Numeric variables", single_cont)) + ) + else: + if single_disc: + self.report_paragraph("Categorical variables", single_disc) + elif len(self.disc_view.model()) > 0: + self.report_items( + "Categorical variables", + [("General preset" if name == DefaultKey else name, + DiscreteOptions[id_].label.lower()) + for name, id_ in self.disc_var_hints.items()]) + if single_cont: + self.report_paragraph("Numeric variables", single_cont) + elif len(self.cont_view.model()) > 0: + self.report_items( + "Numeric variables", + [("General preset" if name == DefaultKey else name, + ContinuousOptions[id_].label.lower()) + for name, id_ in self.cont_var_hints.items()]) + self.report_paragraph("Unlisted", + "Any unlisted attributes default to general preset, and " + "unlisted meta attributes and target variables are kept " + "as they are") @classmethod def migrate_settings(cls, settings, version): if version < 2: - Normalize = cls.Normalize cont_treat = settings.pop("continuous_treatment", 0) zero_based = settings.pop("zero_based", True) if cont_treat == 1: @@ -165,229 +708,33 @@ def migrate_settings(cls, settings, version): settings["continuous_treatment"] = Normalize.Normalize11 elif cont_treat == 2: settings["continuous_treatment"] = Normalize.Standardize + if version < 3: + settings["cont_var_hints"] = \ + {DefaultKey: + settings.pop("continuous_treatment", Normalize.Leave)} + # DISC OPS: Default=99, Indicators=1, FirstAsBase=2, FrequentAsBase=3, Remove=4, + # RemoveMultinomial=5, ReportError=6, AsOrdinal=7, AsNormalizedOrdinal=8, Leave=9 -class WeightedIndicator(Indicator): - def __init__(self, variable, value, weight=1.0): - super().__init__(variable, value) - self.weight = weight - - def transform(self, c): - t = super().transform(c) * self.weight - if self.weight != 1.0: - t *= self.weight - return t - - def __eq__(self, other): - return super().__eq__(other) and self.weight == other.weight - - def __hash__(self): - return hash((type(self), self.variable, self.value, self.weight)) - - -def make_indicator_var(source, value_ind, weight=None): - if weight is None: - indicator = Indicator(source, value=value_ind) - else: - indicator = WeightedIndicator(source, value=value_ind, weight=weight) - return Orange.data.ContinuousVariable( - "{}={}".format(source.name, source.values[value_ind]), - compute_value=indicator - ) - - -def dummy_coding(var, base_value=0): - N = len(var.values) - return [make_indicator_var(var, i) - for i in range(N) if i != base_value] - - -def one_hot_coding(var): - N = len(var.values) - return [make_indicator_var(var, i) for i in range(N)] - - -def continuize_domain(data, - multinomial_treatment=Continuize.Indicators, - continuous_treatment=OWContinuize.Normalize.Leave, - class_treatment=Continuize.Leave): - domain = data.domain - def needs_dist(var, mtreat, ctreat): - "Does the `var` need a distribution given specified flags" - if var.is_discrete: - return mtreat == Continuize.FrequentAsBase - elif var.is_continuous: - return ctreat != OWContinuize.Normalize.Leave - else: - raise ValueError - - # Compute the column indices which need a distribution. - attr_needs_dist = [needs_dist(var, multinomial_treatment, - continuous_treatment) - for var in domain.attributes] - cls_needs_dist = [needs_dist(var, class_treatment, OWContinuize.Normalize.Leave) - for var in domain.class_vars] - - columns = [i for i, needs in enumerate(attr_needs_dist + cls_needs_dist) - if needs] - - if columns: - if data is None: - raise TypeError("continuizer requires data") - dist = distribution.get_distributions_for_columns(data, columns) - else: - dist = [] - - dist_iter = iter(dist) - - newattrs = [continuize_var(var, next(dist_iter) if needs_dist else None, - multinomial_treatment, continuous_treatment) - for var, needs_dist in zip(domain.attributes, attr_needs_dist)] - newclass = [continuize_var(var, - next(dist_iter) if needs_dist else None, - class_treatment, OWContinuize.Normalize.Leave) - for var, needs_dist in zip(domain.class_vars, cls_needs_dist)] - - newattrs = reduce(list.__iadd__, newattrs, []) - newclass = reduce(list.__iadd__, newclass, []) - return Orange.data.Domain(newattrs, newclass, domain.metas) - - -def continuize_var(var, - data_or_dist=None, - multinomial_treatment=Continuize.Indicators, - continuous_treatment=OWContinuize.Normalize.Leave): - def continuize_continuous(): - dist = _ensure_dist(var, data_or_dist) if continuous_treatment != OWContinuize.Normalize.Leave else None - treatments = [lambda var, _: var, - normalize_by_sd, center_to_mean, divide_by_sd, - normalize_to_11, normalize_to_01] - if dist is not None and dist.shape[1] == 0: - return [var] - new_var = treatments[continuous_treatment](var, dist) - return [new_var] - - def continuize_discrete(): - if len(var.values) > 2 and \ - multinomial_treatment == Continuize.ReportError: - raise ValueError("{0.name} is a multinomial variable".format(var)) - if len(var.values) < 2 or \ - multinomial_treatment == Continuize.Remove or \ - (multinomial_treatment == Continuize.RemoveMultinomial - and len(var.values) > 2): - return [] - elif multinomial_treatment == Continuize.AsOrdinal: - return [ordinal_to_continuous(var)] - elif multinomial_treatment == Continuize.AsNormalizedOrdinal: - return [ordinal_to_norm_continuous(var)] - elif multinomial_treatment == Continuize.Indicators: - return one_hot_coding(var) - elif multinomial_treatment in ( - Continuize.FirstAsBase, Continuize.RemoveMultinomial): - return dummy_coding(var) - elif multinomial_treatment == Continuize.FrequentAsBase: - dist = _ensure_dist(var, data_or_dist) - modus = dist.modus() - return dummy_coding(var, base_value=modus) - elif multinomial_treatment == Continuize.Leave: - return [var] - raise ValueError("Invalid value of `multinomial_treatment`") - - if var.is_continuous: - return continuize_continuous() - elif var.is_discrete: - return continuize_discrete() - raise TypeError("Non-primitive variables cannot be continuized") - - -def _ensure_dist(var, data_or_dist): - if isinstance(data_or_dist, distribution.Discrete): - if not var.is_discrete: - raise TypeError - return data_or_dist - elif isinstance(data_or_dist, distribution.Continuous): - if not var.is_continuous: - raise TypeError - return data_or_dist - elif isinstance(data_or_dist, Orange.data.Storage): - return distribution.get_distribution(data_or_dist, var) - else: - raise ValueError("Need a distribution or data.") - - -def normalized_var(var, translate, scale): - return Orange.data.ContinuousVariable(var.name, - compute_value=Normalizer(var, translate, scale)) - - -def ordinal_to_continuous(var): - return Orange.data.ContinuousVariable(var.name, - compute_value=Identity(var)) - - -def ordinal_to_norm_continuous(var): - n_values = len(var.values) - return normalized_var(var, 0, 1 / (n_values - 1)) - - -def normalize_by_sd(var, dist): - mean, sd = dist.mean(), dist.standard_deviation() - sd = sd if sd > 1e-10 else 1 - return normalized_var(var, mean, 1 / sd) - - -def center_to_mean(var, dist): - return normalized_var(var, dist.mean(), 1) - - -def divide_by_sd(var, dist): - sd = dist.standard_deviation() - sd = sd if sd > 1e-10 else 1 - return normalized_var(var, 0, 1 / sd) - - -def normalize_to_11(var, dist): - return normalize_by_span(var, dist, False) - - -def normalize_to_01(var, dist): - return normalize_by_span(var, dist, True) - + # OLD ORDER: [FirstAsBase, FrequentAsBase, Indicators, RemoveMultinomial, Remove, + # AsOrdinal, AsNormalizedOrdinal] + old_to_new = [2, 3, 1, 5, 4, 7, 8] -def normalize_by_span(var, dist, zero_based=True): - v_max, v_min = dist.max(), dist.min() - span = (v_max - v_min) - if span < 1e-15: - span = 1 - if zero_based: - return normalized_var(var, v_min, 1 / span) - else: - return normalized_var(var, (v_min + v_max) / 2, 2 / span) + settings["disc_var_hints"] = \ + {DefaultKey: + old_to_new[settings.pop("multinomial_treatment", 0)]} + # OLD ORDER: [Leave, AsOrdinal, AsNormalizedOrdinal, Indicators] + old_to_new = [9, 7, 8, 1] -class DomainContinuizer(Reprable): - def __init__(self, - multinomial_treatment=Continuize.Indicators, - continuous_treatment=OWContinuize.Normalize.Leave, - class_treatment=Continuize.Leave): - self.multinomial_treatment = multinomial_treatment - self.continuous_treatment = continuous_treatment - self.class_treatment = class_treatment + class_treatment = old_to_new[settings.pop("class_treatment", 0)] + if class_treatment != Continuize.Leave: + settings["disc_var_hints"][BackCompatClass] = class_treatment - def __call__(self, data): - treat = self.multinomial_treatment - domain = data.domain - if (treat == Continuize.ReportError and - any(var.is_discrete and len(var.values) > 2 for var in domain)): - raise ValueError("Domain has multinomial attributes") - newdomain = continuize_domain( - data, - self.multinomial_treatment, - self.continuous_treatment, - self.class_treatment) - return newdomain +# Backward compatibility for unpickling settings +OWContinuize.Normalize = Normalize if __name__ == "__main__": # pragma: no cover - WidgetPreview(OWContinuize).run(Table("iris")) + WidgetPreview(OWContinuize).run(Table("heart_disease")) diff --git a/Orange/widgets/data/owcorrelations.py b/Orange/widgets/data/owcorrelations.py index 98f7840427e..e178881ff32 100644 --- a/Orange/widgets/data/owcorrelations.py +++ b/Orange/widgets/data/owcorrelations.py @@ -1,6 +1,7 @@ """ Correlations widget """ +import warnings from enum import IntEnum from operator import attrgetter from types import SimpleNamespace @@ -64,18 +65,33 @@ def __init__(self, data): def get_clusters_of_attributes(self): """ - Generates groupes of attribute IDs, grouped by cluster. Clusters are + Generates groups of attribute IDs, grouped by cluster. Clusters are obtained by KMeans algorithm. :return: generator of attributes grouped by cluster """ data = Normalize()(self.data).X.T - kmeans = KMeans(n_clusters=self.n_clusters, random_state=0).fit(data) + if data.base is not None: + data = data.copy() + self._impute_means(data) + + kmeans = KMeans(n_clusters=self.n_clusters, random_state=0, n_init=1).fit(data) labels_attrs = sorted([(l, i) for i, l in enumerate(kmeans.labels_)]) return [Cluster(instances=list(pair[1] for pair in group), centroid=kmeans.cluster_centers_[l]) for l, group in groupby(labels_attrs, key=lambda x: x[0])] + @staticmethod + def _impute_means(arr): + nans = np.isnan(arr) + if np.any(nans): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + means = np.nanmean(arr, axis=1) + means = np.nan_to_num(means) + inds = np.where(nans) + arr[inds] = means[inds[0]] + def get_states(self, initial_state): """ Generates states (attribute pairs) - the most promising first, i.e. @@ -118,6 +134,7 @@ class CorrelationRank(VizRankDialogAttrPair): """ threadStopped = Signal() PValRole = next(gui.OrangeUserRole) + CorrRole = next(gui.OrangeUserRole) def __init__(self, *args): super().__init__(*args) @@ -127,7 +144,7 @@ def __init__(self, *args): def initialize(self): super().initialize() - data = self.master.cont_data + data = self.master.actual_data self.attrs = data and data.domain.attributes self.model_proxy.setFilterKeyColumn(-1) self.heuristic = None @@ -145,30 +162,43 @@ def initialize(self): def compute_score(self, state): (attr1, attr2), corr_type = state, self.master.correlation_type - data = self.master.cont_data.X + data = self.master.actual_data.X + col1, col2 = data[:, attr1], data[:, attr2] + mask = ~np.isnan(col1) & ~np.isnan(col2) + if np.sum(mask) < 2: + return np.inf, np.nan, np.nan # no valid data + col1, col2 = col1[mask], col2[mask] corr = pearsonr if corr_type == CorrelationType.PEARSON else spearmanr - r, p_value = corr(data[:, attr1], data[:, attr2]) - return -abs(r) if not np.isnan(r) else NAN, r, p_value + r, p_value = corr(col1, col2) + return -abs(r) if not np.isnan(r) else np.inf, r, p_value def row_for_state(self, score, state): attrs = sorted((self.attrs[x] for x in state), key=attrgetter("name")) attr_items = [] - for attr in attrs: + for attr, halign in zip(attrs, (Qt.AlignRight, Qt.AlignLeft)): item = QStandardItem(attr.name) item.setData(attrs, self._AttrRole) - item.setData(Qt.AlignLeft + Qt.AlignTop, Qt.TextAlignmentRole) + item.setData(halign + Qt.AlignVCenter, Qt.TextAlignmentRole) item.setToolTip(attr.name) attr_items.append(item) - correlation_item = QStandardItem("{:+.3f}".format(score[1])) + if halign is Qt.AlignRight: + colon = QStandardItem(":") + colon.setData(Qt.AlignCenter, Qt.TextAlignmentRole) + attr_items.append(colon) + if np.isnan(score[1]): + correlation_item = QStandardItem("N/A") + else: + correlation_item = QStandardItem(f"{score[1]:+.3f}") + correlation_item.setData( + self.NEGATIVE_COLOR if score[1] < 0 else self.POSITIVE_COLOR, + gui.TableBarItem.BarColorRole) + correlation_item.setData(score[1], self.CorrRole) correlation_item.setData(score[2], self.PValRole) correlation_item.setData(attrs, self._AttrRole) - correlation_item.setData( - self.NEGATIVE_COLOR if score[1] < 0 else self.POSITIVE_COLOR, - gui.TableBarItem.BarColorRole) return [correlation_item] + attr_items def check_preconditions(self): - return self.master.cont_data is not None + return self.master.actual_data is not None def iterate_states(self, initial_state): if self.sel_feature_index is not None: @@ -195,6 +225,7 @@ def stopped(self): self.threadStopped.emit() header = self.rank_table.horizontalHeader() header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) def start(self, task, *args, **kwargs): self._set_empty_status() @@ -237,6 +268,8 @@ class OWCorrelations(OWWidget): description = "Compute all pairwise attribute correlations." icon = "icons/Correlations.svg" priority = 1106 + category = "Unsupervised" + keywords = "pearson, spearman" class Inputs: data = Input("Data", Table) @@ -244,7 +277,7 @@ class Inputs: class Outputs: data = Output("Data", Table) features = Output("Features", AttributeList) - correlations = Output("Correlations", Table) + correlations = Output("Correlations", Table, dynamic=False) want_main_area = False want_control_area = True @@ -256,11 +289,12 @@ class Outputs: selection = ContextSetting([]) feature = ContextSetting(None) correlation_type = Setting(0) + impute_missing = Setting(True) class Information(OWWidget.Information): removed_cons_feat = Msg("Constant features have been removed.") - class Warning(OWWidget.Warning): + class Error(OWWidget.Error): not_enough_vars = Msg("At least two numeric features are needed.") not_enough_inst = Msg("At least two instances are needed.") @@ -268,6 +302,7 @@ def __init__(self): super().__init__() self.data = None # type: Table self.cont_data = None # type: Table + self.actual_data = None # type: Table # GUI box = gui.vBox(self.controlArea) @@ -281,7 +316,15 @@ def __init__(self): placeholder="(All combinations)", valid_types=ContinuousVariable) gui.comboBox( box, self, "feature", callback=self._feature_combo_changed, - model=self.feature_model + model=self.feature_model, searchable=True + ) + + gui.checkBox( + box, self, "impute_missing", "Impute missing values", + toolTip="Replace missing values with means;\n" + "if disabled, rows with missing values for the corre" + "sponding variables are ignored", + callback=self._impute_missing_changed ) self.vizrank, _ = CorrelationRank.add_vizrank( @@ -306,6 +349,10 @@ def _correlation_combo_changed(self): def _feature_combo_changed(self): self.apply() + def _impute_missing_changed(self): + self.set_actual_data() + self.apply() + def _vizrank_selection_changed(self, *args): self.selection = list(args) self.commit() @@ -345,10 +392,11 @@ def set_data(self, data): self.clear_messages() self.data = data self.cont_data = None + self.actual_data = None self.selection = [] if data is not None: if len(data) < 2: - self.Warning.not_enough_inst() + self.Error.not_enough_inst() else: domain = data.domain cont_vars = [a for a in domain.class_vars + domain.metas + @@ -359,26 +407,43 @@ def set_data(self, data): if remover.attr_results["removed"]: self.Information.removed_cons_feat() if len(cont_data.domain.attributes) < 2: - self.Warning.not_enough_vars() + self.Error.not_enough_vars() else: - self.cont_data = SklImpute()(cont_data) - self.set_feature_model() - self.openContext(self.cont_data) + self.cont_data = cont_data + self.set_actual_data() + + if self.actual_data and data.domain.has_continuous_class: + self.feature = self.actual_data.domain[data.domain.class_var.name] + else: + self.feature = None + self.openContext(self.actual_data) self.apply() - self.vizrank.button.setEnabled(self.cont_data is not None) - - def set_feature_model(self): - self.feature_model.set_domain( - self.cont_data.domain if self.cont_data else None) - data = self.data - if self.cont_data and data.domain.has_continuous_class: - self.feature = self.cont_data.domain[data.domain.class_var.name] + self.vizrank.button.setEnabled(self.actual_data is not None) + + def set_actual_data(self): + if self.cont_data is None: + self.actual_data = None + self.feature_model.set_domain(None) + self.feature = None + self.vizrank.setEnabled(False) + return + + if self.impute_missing and self.cont_data.has_missing_attribute(): + imputer = SklImpute(strategy="mean") + self.actual_data = imputer(self.cont_data) + else: + self.actual_data = self.cont_data + + feature_name = self.feature and self.feature.name + self.feature_model.set_domain(self.actual_data.domain) + if feature_name and feature_name in self.actual_data.domain: + self.feature = self.actual_data.domain[feature_name] else: self.feature = None def apply(self): self.vizrank.initialize() - if self.cont_data is not None: + if self.actual_data is not None: # this triggers self.commit() by changing vizrank selection self.vizrank.toggle() else: @@ -392,18 +457,24 @@ def commit(self): self.Outputs.correlations.send(None) return - attrs = [ContinuousVariable("Correlation"), ContinuousVariable("FDR")] + attrs = [ContinuousVariable("Correlation"), + ContinuousVariable("uncorrected p"), + ContinuousVariable("FDR")] metas = [StringVariable("Feature 1"), StringVariable("Feature 2")] domain = Domain(attrs, metas=metas) model = self.vizrank.rank_model - x = np.array([[float(model.data(model.index(row, 0), role)) - for role in (Qt.DisplayRole, CorrelationRank.PValRole)] - for row in range(model.rowCount())]) - x[:, 1] = FDR(list(x[:, 1])) + count = model.rowCount() + index = model.index + corr_p = np.array([ + [d(CorrelationRank.CorrRole), d(CorrelationRank.PValRole)] + for d in (index(row, 0).data for row in range(count)) + ]) + fdr = FDR(corr_p[:, 1]) + x = np.hstack((corr_p, fdr[:, np.newaxis])) # pylint: disable=protected-access - m = np.array([[a.name for a in model.data(model.index(row, 0), - CorrelationRank._AttrRole)] - for row in range(model.rowCount())], dtype=object) + m = np.array([[a.name + for a in index(row, 0).data(CorrelationRank._AttrRole)] + for row in range(count)], dtype=object) corr_table = Table(domain, x, metas=m) corr_table.name = "Correlations" @@ -428,5 +499,29 @@ def migrate_context(cls, context, version): for name, vtype in sel], -3) +def mock_data(): + # pylint: disable=import-outside-toplevel + from Orange.data import DiscreteVariable + domain = Domain([DiscreteVariable("a", values="abc")] + + [ContinuousVariable(x) for x in "defghij"]) + n = np.nan + s = 1 / 2 + return Table.from_numpy( + domain, + np.array([[0, 0, 0, 0, 1, 0], # a + [1, 0, 0, 1, 0, 0], # d 0 + [0, 1, 1, 0, 1, 1], # e 1 + [1, 0, 0, 1, 0, 0], # f 2 + [1, 0, s, 1, 0, s], # g 3 + [1, 0, n, 1, 0, n], # h 4 + [n, 0, n, 1, 0, n], # i 5 + [0, n, n, n, n, 1]] # j 6 + ).T + ) + + if __name__ == "__main__": # pragma: no cover - WidgetPreview(OWCorrelations).run(Table("iris")) + WidgetPreview(OWCorrelations).run( + Table("iris") + # mock_data() + ) diff --git a/Orange/widgets/data/owcreateclass.py b/Orange/widgets/data/owcreateclass.py index d05579c1611..aec2535fa8d 100644 --- a/Orange/widgets/data/owcreateclass.py +++ b/Orange/widgets/data/owcreateclass.py @@ -1,56 +1,91 @@ """Widget for creating classes from non-numeric attribute by substrings""" import re from itertools import count +from typing import Optional, Sequence import numpy as np -from AnyQt.QtWidgets import QGridLayout, QLabel, QLineEdit, QSizePolicy, QWidget -from AnyQt.QtCore import QSize, Qt +from AnyQt.QtWidgets import QLayout, QFrame, QGridLayout, QLabel, QLineEdit, \ + QSizePolicy, QWidget, QScrollArea +from AnyQt.QtCore import Qt, QTimer from Orange.data import StringVariable, DiscreteVariable, Domain from Orange.data.table import Table from Orange.statistics.util import bincount from Orange.preprocess.transformation import Transformation, Lookup from Orange.widgets import gui, widget -from Orange.widgets.settings import DomainContextHandler, ContextSetting +from Orange.widgets.settings import DomainContextHandler, ContextSetting, Setting from Orange.widgets.utils.itemmodels import DomainModel +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Msg, Input, Output -def map_by_substring(a, patterns, case_sensitive, match_beginning, - map_values=None): +def map_by_substring( + a: np.ndarray, + patterns: list[str], + case_sensitive: bool, match_beginning: bool, regular_expressions: bool, + map_values: Optional[Sequence[int]] = None) -> np.ndarray: """ Map values in a using a list of patterns. The patterns are considered in order of appearance. + Flags `match_beginning` and `regular_expressions` are incompatible. + Args: a (np.array): input array of `dtype` `str` patterns (list of str): list of strings case_sensitive (bool): case sensitive match match_beginning (bool): match only at the beginning of the string - map_values (list of int): list of len(pattens); - contains return values for each pattern + map_values (list of int, optional): + list of len(patterns); return values for each pattern + regular_expressions (bool): use regular expressions Returns: np.array of floats representing indices of matched patterns """ + assert not (regular_expressions and match_beginning) if map_values is None: map_values = np.arange(len(patterns)) else: map_values = np.array(map_values, dtype=int) res = np.full(len(a), np.nan) - if not case_sensitive: + if not case_sensitive and not regular_expressions: a = np.char.lower(a) patterns = (pattern.lower() for pattern in patterns) for val_idx, pattern in reversed(list(enumerate(patterns))): - indices = np.char.find(a, pattern) - matches = indices == 0 if match_beginning else indices != -1 + # Note that similar code repeats in update_counts. Any changes here + # should be reflected there. + if regular_expressions: + re_pattern = re.compile(pattern, + re.IGNORECASE if not case_sensitive else 0) + matches = np.array([bool(re_pattern.search(s)) for s in a], + dtype=bool) + else: + indices = np.char.find(a, pattern) + matches = indices == 0 if match_beginning else indices != -1 res[matches] = map_values[val_idx] return res -class ValueFromStringSubstring(Transformation): +class _EqHashMixin: + def __eq__(self, other): + return super().__eq__(other) \ + and self.patterns == other.patterns \ + and self.case_sensitive == other.case_sensitive \ + and self.match_beginning == other.match_beginning \ + and self.regular_expressions == other.regular_expressions \ + and np.all(self.map_values == other.map_values) + + def __hash__(self): + return hash((type(self), self.variable, + tuple(self.patterns), + self.case_sensitive, self.match_beginning, + self.regular_expressions, + None if self.map_values is None else tuple(self.map_values) + )) + +class ValueFromStringSubstring(_EqHashMixin, Transformation): """ Transformation that computes a discrete variable from a string variable by pattern matching. @@ -66,15 +101,28 @@ class ValueFromStringSubstring(Transformation): sensitive match_beginning (bool, optional): if set to `True`, the pattern must appear at the beginning of the string + map_values (list of int, optional): return values for each pattern + regular_expressions (bool, optional): if set to `True`, the patterns are """ - def __init__(self, variable, patterns, - case_sensitive=False, match_beginning=False, map_values=None): + # regular_expressions was added later and at the end (instead of with other + # flags) for compatibility with older existing pickles + def __init__( + self, + variable: StringVariable, + patterns: list[str], + case_sensitive: bool = False, + match_beginning: bool = False, + map_values: Optional[Sequence[int]] = None, + regular_expressions: bool = False): super().__init__(variable) self.patterns = patterns self.case_sensitive = case_sensitive self.match_beginning = match_beginning + self.regular_expressions = regular_expressions self.map_values = map_values + InheritEq = True + def transform(self, c): """ Transform the given data. @@ -89,26 +137,14 @@ def transform(self, c): c = c.astype(str) c[nans] = "" res = map_by_substring( - c, self.patterns, self.case_sensitive, self.match_beginning, + c, self.patterns, + self.case_sensitive, self.match_beginning, self.regular_expressions, self.map_values) res[nans] = np.nan return res - def __eq__(self, other): - return super().__eq__(other) \ - and self.patterns == other.patterns \ - and self.case_sensitive == other.case_sensitive \ - and self.match_beginning == other.match_beginning \ - and self.map_values == other.map_values - def __hash__(self): - return hash((type(self), self.variable, - tuple(self.patterns), - self.case_sensitive, self.match_beginning, - self.map_values)) - - -class ValueFromDiscreteSubstring(Lookup): +class ValueFromDiscreteSubstring(_EqHashMixin, Lookup): """ Transformation that computes a discrete variable from discrete variable by pattern matching. @@ -125,16 +161,29 @@ class ValueFromDiscreteSubstring(Lookup): sensitive match_beginning (bool, optional): if set to `True`, the pattern must appear at the beginning of the string + map_values (list of int, optional): return values for each pattern + regular_expressions (bool, optional): if set to `True`, the patterns are + """ - def __init__(self, variable, patterns, - case_sensitive=False, match_beginning=False, - map_values=None): + # regular_expressions was added later and at the end (instead of with other + # flags) for compatibility with older existing pickles + def __init__( + self, + variable: DiscreteVariable, + patterns: list[str], + case_sensitive: bool = False, + match_beginning: bool = False, + map_values: Optional[Sequence[int]] = None, + regular_expressions: bool = False): super().__init__(variable, []) self.case_sensitive = case_sensitive self.match_beginning = match_beginning self.map_values = map_values + self.regular_expressions = regular_expressions self.patterns = patterns # Finally triggers computation of the lookup + InheritEq = True + def __setattr__(self, key, value): """__setattr__ is overloaded to recompute the lookup table when the patterns, the original attribute or the flags change.""" @@ -144,10 +193,10 @@ def __setattr__(self, key, value): "variable", "map_values"): self.lookup_table = map_by_substring( self.variable.values, self.patterns, - self.case_sensitive, self.match_beginning, self.map_values) + self.case_sensitive, self.match_beginning, + self.regular_expressions, self.map_values) - -def unique_in_order_mapping(a): +def unique_in_order_mapping(a: Sequence[str]) -> tuple[list[str], list[int]]: """ Return - unique elements of the input list (in the order of appearance) - indices of the input list onto the returned uniques @@ -167,8 +216,9 @@ class OWCreateClass(widget.OWWidget): name = "Create Class" description = "Create class attribute from a string attribute" icon = "icons/CreateClass.svg" - category = "Data" - keywords = [] + category = "Transform" + keywords = "create class" + priority = 2300 class Inputs: data = Input("Data", Table) @@ -178,13 +228,19 @@ class Outputs: want_main_area = False buttons_area_orientation = Qt.Vertical + #: Max pixel height of the rules area before it scrolls instead of + #: growing the widget further + MAX_RULES_AREA_HEIGHT = 360 settingsHandler = DomainContextHandler() - attribute = ContextSetting(None) - class_name = ContextSetting("class") - rules = ContextSetting({}) - match_beginning = ContextSetting(False) - case_sensitive = ContextSetting(False) + attribute = ContextSetting(None, schema_only=True) + class_name = Setting("class", schema_only=True) + rules = Setting({}, schema_only=True) + match_beginning = Setting(False, schema_only=True) + case_sensitive = Setting(False, schema_only=True) + regular_expressions = Setting(False, schema_only=True) + + settings_version = 2 TRANSFORMERS = {StringVariable: ValueFromStringSubstring, DiscreteVariable: ValueFromDiscreteSubstring} @@ -200,6 +256,7 @@ class Warning(widget.OWWidget.Warning): class Error(widget.OWWidget.Error): class_name_duplicated = Msg("Class name duplicated.") class_name_empty = Msg("Class name should not be empty.") + invalid_regular_expression = Msg("Invalid regular expression: {}") def __init__(self): super().__init__() @@ -218,16 +275,19 @@ def __init__(self): self.remove_buttons = [] #: list of list of QLabel: pairs of labels with counts self.counts = [] + #: bool: set by add_row, tells _refit_rules_area to scroll down + # once the new row's height has been applied + self._scroll_to_bottom_pending = False - gui.lineEdit( + le = gui.lineEdit( self.controlArea, self, "class_name", orientation=Qt.Horizontal, box="New Class Name") + le.setStyleSheet("QLineEdit { padding-left: 4px; }") - variable_select_box = gui.vBox(self.controlArea, "Match by Substring") + variable_select_box = gui.vBox(self.controlArea, box="Source column and patterns") combo = gui.comboBox( - variable_select_box, self, "attribute", label="From column:", - orientation=Qt.Horizontal, searchable=True, + variable_select_box, self, "attribute", searchable=True, callback=self.update_rules, model=DomainModel(valid_types=(StringVariable, DiscreteVariable))) # Don't use setSizePolicy keyword argument here: it applies to box, @@ -235,11 +295,12 @@ def __init__(self): combo.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) patternbox = gui.vBox(variable_select_box) + patternbox.layout().setSpacing(0) #: QWidget: the box that contains the remove buttons, line edits and # count labels. The lines are added and removed dynamically. self.rules_box = rules_box = QGridLayout() rules_box.setSpacing(4) - rules_box.setContentsMargins(4, 4, 4, 4) + rules_box.setContentsMargins(4, 4, 4, 0) self.rules_box.setColumnMinimumWidth(1, 70) self.rules_box.setColumnMinimumWidth(0, 10) self.rules_box.setColumnStretch(0, 1) @@ -250,9 +311,15 @@ def __init__(self): rules_box.addWidget(QLabel("Count"), 0, 3, 1, 2) self.update_rules() - widget = QWidget(patternbox) - widget.setLayout(rules_box) - patternbox.layout().addWidget(widget) + self._rules_widget = widg = QWidget() + widg.setLayout(rules_box) + + self._rules_scroll = scroll = QScrollArea() + scroll.setWidget(widg) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + patternbox.layout().addWidget(scroll) box = gui.hBox(patternbox) gui.rubber(box) @@ -262,8 +329,12 @@ def __init__(self): QSizePolicy.Maximum)) optionsbox = gui.vBox(self.controlArea, "Options") + gui.checkBox( + optionsbox, self, "regular_expressions", "Use regular expressions", + callback=self.options_changed) gui.checkBox( optionsbox, self, "match_beginning", "Match only at the beginning", + stateWhenDisabled=False, callback=self.options_changed) gui.checkBox( optionsbox, self, "case_sensitive", "Case sensitive", @@ -273,8 +344,8 @@ def __init__(self): gui.button(self.buttonsArea, self, "Apply", callback=self.apply) - # TODO: Resizing upon changing the number of rules does not work self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) + self.layout().setSizeConstraint(QLayout.SetFixedSize) @property def active_rules(self): @@ -296,7 +367,6 @@ def rules_to_edits(self): def set_data(self, data): """Input data signal handler.""" self.closeContext() - self.rules = {} self.data = data model = self.controls.attribute.model() model.set_domain(data.domain if data is not None else None) @@ -320,6 +390,7 @@ def update_rules(self): # TODO: Indicator that changes need to be applied def options_changed(self): + self.controls.match_beginning.setEnabled(not self.regular_expressions) self.update_counts() def adjust_n_rule_rows(self): @@ -342,8 +413,8 @@ def _add_line(): self.rules_box.addWidget(button, n_lines, 0) self.counts.append([]) for coli, kwargs in enumerate( - (dict(), - dict(styleSheet="color: gray"))): + ({}, + {"styleSheet": "color: gray"})): label = QLabel(alignment=Qt.AlignCenter, **kwargs) self.counts[-1].append(label) self.rules_box.addWidget(label, n_lines, 3 + coli) @@ -371,9 +442,27 @@ def _fix_tab_order(): _remove_line() _fix_tab_order() + QTimer.singleShot(0, self._refit_rules_area) + + def _refit_rules_area(self): + content_height = self._rules_widget.sizeHint().height() + self._rules_scroll.setFixedHeight( + min(content_height, self.MAX_RULES_AREA_HEIGHT)) + self.adjustSize() + + if self._scroll_to_bottom_pending: + self._scroll_to_bottom_pending = False + QTimer.singleShot(0, self._scroll_rules_to_bottom) + + def _scroll_rules_to_bottom(self): + """Scroll the rules area down so a newly added row is visible.""" + bar = self._rules_scroll.verticalScrollBar() + bar.setValue(bar.maximum()) + def add_row(self): """Append a new row at the end.""" self.active_rules.append(["", ""]) + self._scroll_to_bottom_pending = True self.adjust_n_rule_rows() self.update_counts() @@ -399,23 +488,48 @@ def class_labels(self): if re.match("^C\\d+", label)), default=0) class_count = count(largest_c + 1) - return [label_edit.text() or "C{}".format(next(class_count)) + return [label_edit.text() or f"C{next(class_count)}" for label_edit, _ in self.line_edits] + def invalid_patterns(self): + if not self.regular_expressions: + return None + for _, pattern in self.active_rules: + try: + re.compile(pattern) + except re.error: + return pattern + return None + def update_counts(self): """Recompute and update the counts of matches.""" - def _matcher(strings, pattern): - """Return indices of strings into patterns; consider case - sensitivity and matching at the beginning. The given strings are - assumed to be in lower case if match is case insensitive. Patterns - are fixed on the fly.""" - if not self.case_sensitive: - pattern = pattern.lower() - indices = np.char.find(strings, pattern.strip()) - return indices == 0 if self.match_beginning else indices != -1 - - def _lower_if_needed(strings): - return strings if self.case_sensitive else np.char.lower(strings) + if self.regular_expressions: + def _matcher(strings, pattern): + # Note that similar code repeats in map_by_substring. + # Any changes here should be reflected there. + re_pattern = re.compile( + pattern, + re.IGNORECASE if not self.case_sensitive else 0) + return np.array([bool(re_pattern.search(s)) for s in strings], + dtype=bool) + + def _lower_if_needed(strings): + return strings + else: + def _matcher(strings, pattern): + """Return indices of strings into patterns; consider case + sensitivity and matching at the beginning. The given strings are + assumed to be in lower case if match is case insensitive. Patterns + are fixed on the fly.""" + # Note that similar code repeats in map_by_substring. + # Any changes here should be reflected there. + if not self.case_sensitive: + pattern = pattern.lower() + indices = np.char.find(strings, pattern.strip()) + return indices == 0 if self.match_beginning else indices != -1 + + def _lower_if_needed(strings): + return strings if self.case_sensitive else np.char.lower(strings) def _string_counts(): """ @@ -467,12 +581,13 @@ def _set_labels(): for (n_matched, n_total), (lab_matched, lab_total), (lab, patt) in \ zip(self.match_counts, self.counts, self.active_rules): n_before = n_total - n_matched - lab_matched.setText("{}".format(n_matched)) + lab_matched.setText(f"{n_matched}") if n_before and (lab or patt): - lab_total.setText("+ {}".format(n_before)) + lab_total.setText(f"+ {n_before}") if n_matched: - tip = "{} of the {} matching instances are already " \ - "covered above".format(n_before, n_total) + tip = f"{n_before} o" \ + f"f {n_total} matching {pl(n_total, 'instance')} " \ + f"{pl(n_before, 'is|are')} already covered above." else: tip = "All matching instances are already covered above" lab_total.setToolTip(tip) @@ -493,12 +608,17 @@ def _set_placeholders(): lab_edit.setPlaceholderText(label) _clear_labels() + if (invalid := self.invalid_patterns()) is not None: + self.Error.invalid_regular_expression(invalid) + return + self.Error.invalid_regular_expression.clear() + attr = self.attribute if attr is None: return counters = {StringVariable: _string_counts, DiscreteVariable: _discrete_counts} - data = self.data.get_column_view(attr)[0] + data = self.data.get_column(attr) self.match_counts = [[int(np.sum(x)) for x in matches] for matches in counters[type(attr)]()] _set_labels() @@ -507,6 +627,11 @@ def _set_placeholders(): def apply(self): """Output the transformed data.""" self.Error.clear() + if (invalid := self.invalid_patterns()) is not None: + self.Error.invalid_regular_expression(invalid) + self.Outputs.data.send(None) + return + self.class_name = self.class_name.strip() if not self.attribute: self.Outputs.data.send(None) @@ -538,19 +663,21 @@ def _create_variable(self): if valid) transformer = self.TRANSFORMERS[type(self.attribute)] - # join patters with the same names + # join patterns with the same names names, map_values = unique_in_order_mapping(names) names = tuple(str(a) for a in names) map_values = tuple(map_values) var_key = (self.attribute, self.class_name, names, - patterns, self.case_sensitive, self.match_beginning, map_values) + patterns, self.case_sensitive, self.match_beginning, + self.regular_expressions, map_values) if var_key in self.cached_variables: return self.cached_variables[var_key] compute_value = transformer( - self.attribute, patterns, self.case_sensitive, self.match_beginning, - map_values) + self.attribute, patterns, self.case_sensitive, + self.match_beginning and not self.regular_expressions, + map_values, self.regular_expressions) new_var = DiscreteVariable( self.class_name, names, compute_value=compute_value) self.cached_variables[var_key] = new_var @@ -561,23 +688,30 @@ def send_report(self): # within the loop # pylint: disable=undefined-loop-variable def _cond_part(): - rule = "
{} ".format(class_name) + rule = f"{class_name} " if patt: - rule += "if {} contains {}".format( - self.attribute.name, patt) + rule += f"if {self.attribute.name} contains {patt}" else: rule += "otherwise" return rule def _count_part(): + aca = "already covered above" if not n_matched: - return "all {} matching instances are already covered " \ - "above".format(n_total) - elif n_matched < n_total and patt: - return "{} matching instances (+ {} that are already " \ - "covered above".format(n_matched, n_total - n_matched) + if n_total == 1: + return f"the single matching instance is {aca}" + elif n_total == 2: + return f"both matching instances are {aca}" + else: + return f"all {n_total} matching instances are {aca}" + elif not patt: + return f"{n_matched} {pl(n_matched, 'instance')}" else: - return "{} matching instances".format(n_matched) + m = f"{n_matched} matching {pl(n_matched, 'instance')}" + if n_matched < n_total: + n_already = n_total - n_matched + m += f" (+{n_already} that {pl(n_already, 'is|are')} {aca})" + return m if not self.attribute: return @@ -587,10 +721,23 @@ def _count_part(): for (n_matched, n_total), class_name, (lab, patt) in \ zip(self.match_counts, names, self.active_rules): if lab or patt or n_total: - output += "
  • {}; {}
  • ".format(_cond_part(), _count_part()) + output += f"
  • {_cond_part()}; {_count_part()}
  • " if output: self.report_items("Output", [("Class name", self.class_name)]) - self.report_raw("
      {}
    ".format(output)) + self.report_raw(f"
      {output}
    ") + + @classmethod + def migrate_settings(cls, settings, version): + if version < 2: + contexts = settings.pop("context_settings", []) + if contexts: + context = contexts[0] + settings.update( + {name: context.values.pop(name)[0] + for name in ("class_name", "rules", "match_beginning", + "case_sensitive", "regular_expressions")}) + context.values["__version__"] = 2 + settings["context_settings"] = [context] # selected attribute if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/data/owcreateinstance.py b/Orange/widgets/data/owcreateinstance.py index f9f5292c426..300705a58cd 100644 --- a/Orange/widgets/data/owcreateinstance.py +++ b/Orange/widgets/data/owcreateinstance.py @@ -1,4 +1,4 @@ -from typing import Optional, Callable, List, Union, Dict +from typing import Optional, Callable, List, Union, Dict, Tuple from collections import namedtuple from functools import singledispatch @@ -7,13 +7,16 @@ from AnyQt.QtCore import Qt, QSortFilterProxyModel, QSize, QDateTime, \ QModelIndex, Signal, QPoint, QRect, QEvent from AnyQt.QtGui import QStandardItemModel, QStandardItem, QIcon, QPainter, \ - QColor + QColor, QValidator from AnyQt.QtWidgets import QLineEdit, QTableView, QSlider, \ QComboBox, QStyledItemDelegate, QWidget, QDateTimeEdit, QHBoxLayout, \ QDoubleSpinBox, QSizePolicy, QStyleOptionViewItem, QLabel, QMenu, QAction +from orangewidget.gui import Slider + from Orange.data import DiscreteVariable, ContinuousVariable, \ TimeVariable, Table, StringVariable, Variable, Domain +from Orange.data.util import get_unique_names from Orange.widgets import gui from Orange.widgets.utils.itemmodels import TableModel from Orange.widgets.settings import Setting @@ -49,28 +52,36 @@ def sizeHint(self): class DiscreteVariableEditor(VariableEditor): - valueChanged = Signal(int) - - def __init__(self, parent: QWidget, items: List[str], callback: Callable): + def __init__(self, parent: QWidget, items: Tuple[str], callback: Callable): super().__init__(parent, callback) self._combo = QComboBox( parent, maximumWidth=180, sizePolicy=QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) ) - self._combo.addItems(items) - self._combo.currentIndexChanged.connect(self.valueChanged) + self._combo.addItems(items + ("?",)) + self._combo.currentIndexChanged.connect(self.__on_index_changed) self.layout().addWidget(self._combo) @property - def value(self) -> int: - return self._combo.currentIndex() + def value(self) -> Union[int, float]: + return self._map_to_var_values() @value.setter def value(self, value: float): + if np.isnan(value): + value = self._combo.model().rowCount() - 1 assert value == int(value) self._combo.setCurrentIndex(int(value)) + def __on_index_changed(self): + self.valueChanged.emit(self._map_to_var_values()) + + def _map_to_var_values(self) -> Union[int, float]: + n_values = self._combo.model().rowCount() - 1 + current = self._combo.currentIndex() + return current if current < n_values else np.nan + class ContinuousVariableEditor(VariableEditor): MAX_FLOAT = 2147483647 @@ -104,6 +115,17 @@ def sizeHint(self) -> QSize: size: QSize = super().sizeHint() return QSize(size.width(), size.height() + 2) + def validate(self, text: str, pos: int) -> Tuple[int, str, int]: + state, text, pos = super().validate(text, pos) + if text == "": + state = QValidator.Acceptable + return state, text, pos + + def textFromValue(self, value): + if not np.isfinite(value): + return "?" + return super().textFromValue(value) + self._spin = DoubleSpinBox( parent, value=self._min_value, @@ -114,7 +136,7 @@ def sizeHint(self) -> QSize: minimumWidth=70, sizePolicy=sp_spin, ) - self._slider = QSlider( + self._slider = Slider( parent, minimum=self.__map_to_slider(self._min_value), maximum=self.__map_to_slider(self._max_value), @@ -186,7 +208,8 @@ def _apply_slider_value(self): self.value = self.__map_from_slider(self._slider.value()) def _apply_spin_value(self): - self.value = self._spin.value() + value = self._spin.value() + self.value = value if np.isfinite(value) else np.nan def __round_value(self, value): return round(value, self._n_decimals) @@ -362,7 +385,9 @@ def _(variable: TimeVariable, _: np.ndarray, return TimeVariableEditor(parent, variable, callback) -def majority(values: np.ndarray) -> int: +def majority(values: np.ndarray) -> Union[int, float]: + if all(np.isnan(values)): + return np.nan return np.bincount(values[~np.isnan(values)].astype(int)).argmax() @@ -385,7 +410,7 @@ def set_data(self, data: Table, saved_values={}): [(TableModel.Meta, m) for m in domain.metas] for place, variable in variables: if variable.is_primitive(): - values = data.get_column_view(variable)[0].astype(float) + values = data.get_column(variable) if all(np.isnan(values)): self.dataHasNanColumn.emit() continue @@ -449,9 +474,9 @@ class OWCreateInstance(OWWidget): name = "Create Instance" description = "Interactively create a data instance from sample dataset." icon = "icons/CreateInstance.svg" - category = "Data" - keywords = ["simulator"] - priority = 4000 + category = "Transform" + keywords = "create instance, simulator" + priority = 2310 class Inputs: data = Input("Data", Table) @@ -465,6 +490,7 @@ class Information(OWWidget.Information): "removed from the list.") want_main_area = False + BUTTONS = ["Median", "Mean", "Random", "Input"] ACTIONS = ["median", "mean", "random", "input"] HEADER = [["name", "Variable"], ["variable", "Value"]] @@ -510,20 +536,19 @@ def __init__(self): box = gui.hBox(vbox, objectName="buttonBox") gui.rubber(box) - for name in self.ACTIONS: + for name, action in zip(self.BUTTONS, self.ACTIONS): gui.button( - box, self, name.capitalize(), - lambda *args, fun=name: self._initialize_values(fun), + box, self, name, + lambda *args, fun=action: self._initialize_values(fun), autoDefault=False ) gui.rubber(box) - # pylint: disable=unnecessary-lambda - append = gui.checkBox(self.buttonsArea, self, "append_to_data", - "Append this instance to input data", - callback=lambda: self.commit()) + gui.checkBox(self.buttonsArea, self, "append_to_data", + "Append this instance to input data", + callback=self.commit.deferred) gui.rubber(self.buttonsArea) - box = gui.auto_apply(self.buttonsArea, self, "auto_commit") + gui.auto_apply(self.buttonsArea, self, "auto_commit") self.settingsAboutToBePacked.connect(self.pack_settings) @@ -531,7 +556,7 @@ def __filter_edit_changed(self): self.proxy_model.setFilterFixedString(self.filter_edit.text().strip()) def __table_data_changed(self): - self.commit() + self.commit.deferred() def __menu_requested(self, point: QPoint): index = self.view.indexAt(point) @@ -575,11 +600,7 @@ def _initialize_values(self, fun: str, indices: List[QModelIndex] = None): if fun == "input": if variable not in self.reference.domain: continue - values = self.reference.get_column_view(variable)[0] - if variable.is_primitive(): - values = values.astype(float) - if all(np.isnan(values)): - continue + values = self.reference.get_column(variable) else: values = self.model.data(index, ValuesRole) @@ -595,13 +616,13 @@ def _initialize_values(self, fun: str, indices: List[QModelIndex] = None): self.model.setData(index, value, ValueRole) self.model.dataChanged.connect(self.__table_data_changed) - self.commit() + self.commit.deferred() @Inputs.data def set_data(self, data: Table): self.data = data self._set_model_data() - self.unconditional_commit() + self.commit.now() def _set_model_data(self): self.Information.nans_removed.clear() @@ -620,6 +641,7 @@ def _set_model_data(self): def set_reference(self, data: Table): self.reference = data + @gui.deferred def commit(self): output_data = None if self.data: @@ -630,28 +652,45 @@ def commit(self): def _create_data_from_values(self) -> Table: data = Table.from_domain(self.data.domain, 1) - data.name = "created" - data.X[:] = np.nan - data.Y[:] = np.nan - for i, m in enumerate(self.data.domain.metas): - data.metas[:, i] = "" if m.is_string else np.nan - - values = self._get_values() - for var_name, value in values.items(): - data[:, var_name] = value + with data.unlocked(): + data.name = "created" + if data.X.size: + data.X[:] = np.nan + if data.Y.size: + data.Y[:] = np.nan + for i, m in enumerate(self.data.domain.metas): + data.metas[:, i] = "" if m.is_string else np.nan + + values = self._get_values() + for var_name, value in values.items(): + data[:, var_name] = value return data - def _append_to_data(self, data: Table) -> Table: + def _append_to_data(self, instance: Table) -> Table: assert self.data - assert len(data) == 1 - - var = DiscreteVariable("Source ID", values=(self.data.name, data.name)) - data = Table.concatenate([self.data, data], axis=0) - domain = Domain(data.domain.attributes, data.domain.class_vars, - data.domain.metas + (var,)) + assert len(instance) == 1 + source_label = "__source_widget" + + data = Table.concatenate([self.data, instance], axis=0) + domain = self.data.domain + with data.unlocked(): + for attrs, part in ((domain.attributes, data.X), + (domain.class_vars, data.Y.reshape(len(data), -1)), + (domain.metas, data.metas)): + for idx, var in enumerate(attrs): + if var.attributes.get(source_label) == OWCreateInstance: + part[-1, idx] = 1 + return data + + name = get_unique_names(self.data.domain, "Source ID") + var = DiscreteVariable(name, values=(self.data.name, instance.name)) + var.attributes[source_label] = OWCreateInstance + domain = Domain(domain.attributes, domain.class_vars, + domain.metas + (var,)) data = data.transform(domain) - data.metas[: len(self.data), -1] = 0 - data.metas[len(self.data):, -1] = 1 + with data.unlocked(data.metas): + data.metas[: len(self.data), -1] = 0 + data.metas[len(self.data):, -1] = 1 return data def _get_values(self) -> Dict[str, Union[str, float]]: diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 5891a05a1b4..9725eaa3ed6 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -4,6 +4,7 @@ ---------------------- """ +from __future__ import annotations import sys import types import os @@ -32,7 +33,8 @@ ) from AnyQt.QtCore import ( - Qt, QFileInfo, QTimer, QSettings, QObject, QSize, QMimeDatabase, QMimeType + Qt, QFileInfo, QTimer, QSettings, QObject, QSize, QMimeDatabase, QMimeType, + QUrl ) from AnyQt.QtGui import ( QStandardItem, QStandardItemModel, QPalette, QColor, QIcon @@ -47,13 +49,18 @@ import numpy as np import pandas.errors import pandas as pd - +from pandas import CategoricalDtype from pandas.api import types as pdtypes +from orangecanvas.utils import assocf +from orangewidget.utils import enum_as_int + import Orange.data from Orange.misc.collections import natural_sorted from Orange.widgets import widget, gui, settings +from Orange.widgets.utils.filedialogs import OWUrlDropBase +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.concurrent import PyOwned from Orange.widgets.utils import ( textimport, concurrent as qconcurrent, unique_everseen, enum_get, qname @@ -61,6 +68,7 @@ from Orange.widgets.utils.combobox import ItemStyledComboBox from Orange.widgets.utils.pathutils import ( PathItem, VarPath, AbsPath, samepath, prettyfypath, isprefixed, + infer_prefix, ) from Orange.widgets.utils.overlay import OverlayWidget from Orange.widgets.utils.settings import ( @@ -607,24 +615,27 @@ def default_options_for_mime_type( return Options(dialect=dialect, encoding=encoding, rowspec=rowspec) -class OWCSVFileImport(widget.OWWidget): +class OWCSVFileImport(OWUrlDropBase): name = "CSV File Import" description = "Import a data table from a CSV formatted file." icon = "icons/CSVFile.svg" priority = 11 category = "Data" - keywords = ["file", "load", "read", "open", "csv"] + keywords = "csv file import, file, load, read, open, csv" class Outputs: data = widget.Output( name="Data", type=Orange.data.Table, - doc="Loaded data set.") + doc="Loaded data set.", + dynamic=False, + ) data_frame = widget.Output( name="Data Frame", type=pd.DataFrame, doc="", - auto_summary=False + auto_summary=False, + dynamic=False, ) class Error(widget.OWWidget.Error): @@ -753,7 +764,7 @@ def update_buttons(cbindex): self.import_options_button, QDialogButtonBox.ActionRole ) button_box.setStyleSheet( - "button-layout: {:d};".format(QDialogButtonBox.MacLayout) + "button-layout: {:d};".format(enum_as_int(QDialogButtonBox.MacLayout)) ) self.controlArea.layout().addWidget(button_box) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Maximum) @@ -964,6 +975,25 @@ def _activate_import_dialog(self): """Activate the Import Options dialog for the current item.""" item = self.current_item() assert item is not None + path = item.path() + options = item.options() + + def onfinished(result: int): + if result != QDialog.Accepted: + return + newoptions = dlg.options() + item.setData(newoptions, ImportItem.OptionsRole) + # update local recent paths list + self._note_recent(path, newoptions) + if newoptions != options: + self._invalidate() + + dlg = self._activate_import_dialog_for_item(item) + dlg.finished.connect(onfinished) + + def _activate_import_dialog_for_item( + self, item: ImportItem, + ) -> CSVImportDialog: dlg = CSVImportDialog( self, windowTitle="Import Options", sizeGripEnabled=True, ) @@ -981,19 +1011,12 @@ def _activate_import_dialog(self): if isinstance(options, Options): dlg.setOptions(options) - def update(): - newoptions = dlg.options() - item.setData(newoptions, ImportItem.OptionsRole) - # update local recent paths list - self._note_recent(path, newoptions) - if newoptions != options: - self._invalidate() - dlg.accepted.connect(update) - - def store_size(): + def onfinished(): settings.setValue("size", dlg.size()) - dlg.finished.connect(store_size) + + dlg.finished.connect(onfinished) dlg.show() + return dlg def set_selected_file(self, filename, options=None): """ @@ -1032,7 +1055,6 @@ def _add_recent(self, filename, options=None): else: item = ImportItem.fromPath(filename) - # item.setData(VarPath(filename), ImportItem.VarPathRole) item.setData(True, ImportItem.IsSessionItemRole) model.insertRow(0, item) @@ -1124,6 +1146,7 @@ def cancel(self): """ Cancel current pending or executing task. """ + self.__committimer.stop() if self.__watcher is not None: self.__cancel_task() self.__clear_running_state() @@ -1222,18 +1245,11 @@ def _update_status_messages(self, data): if data is None: return - def pluralize(seq): - return "s" if len(seq) != 1 else "" - - summary = ("{n_instances} row{plural_1}, " - "{n_features} feature{plural_2}, " - "{n_meta} meta{plural_3}").format( - n_instances=len(data), plural_1=pluralize(data), - n_features=len(data.domain.attributes), - plural_2=pluralize(data.domain.attributes), - n_meta=len(data.domain.metas), - plural_3=pluralize(data.domain.metas)) - self.summary_text.setText(summary) + n_instances = len(data) + n_features, n_meta = len(data.domain.attributes), len(data.domain.metas) + self.summary_text.setText(f"{n_instances} {pl(n_instances, 'row')}, " + f"{n_features} {pl(n_features, 'feature')}, " + f"{n_meta} {pl(n_meta, 'meta')}") def itemsFromSettings(self): # type: () -> List[Tuple[str, Options]] @@ -1267,9 +1283,17 @@ def _replacements(self) -> Mapping[str, str]: def _saveState(self): session_items = [] model = self.import_items_model + env = list(self._replacements().items()) for item in map(model.item, range(model.rowCount())): if isinstance(item, ImportItem) and item.data(ImportItem.IsSessionItemRole): vp = item.data(VarPathItem.VarPathRole) + # If the file lives inside a known prefix (e.g. the workflow's + # basedir), persist it as a VarPath so that moving the + # workflow together with its data resolves automatically. + if isinstance(vp, AbsPath) and env: + inferred = infer_prefix(vp.path, env) + if inferred is not None: + vp = inferred session_items.append((vp.as_dict(), item.options().as_dict())) self._session_items_v2 = session_items @@ -1315,6 +1339,38 @@ def _restoreState(self): idx = -1 self.recent_combo.setCurrentIndex(idx) + def canDropUrl(self, url: QUrl) -> bool: + if url.isLocalFile(): + return _mime_type_for_path(url.toLocalFile()).inherits("text/plain") + else: + return False + + def handleDroppedUrl(self, url: QUrl) -> None: + # search recent items for path + path = url.toLocalFile() + hist = self.itemsFromSettings() + res = assocf(hist, lambda p: samepath(p, path)) + if res is not None: + _, options = res + else: + mt = _mime_type_for_path(path) + options = default_options_for_mime_type(path, mt.name()) + self.activate_import_for_file(path, options) + + def activate_import_for_file(self, path: str, options: Options | None = None): + self.cancel() # Cancel current task if any + item = ImportItem() # dummy temp item + item.setPath(path) + item.setOptions(options) + + def finished(result: int): + if result != QDialog.Accepted: + return + self.set_selected_file(path, dlg.options()) + + dlg = self._activate_import_dialog_for_item(item) + dlg.finished.connect(finished) + @classmethod def migrate_settings(cls, settings, version): if not version or version < 2: @@ -1327,7 +1383,7 @@ def migrate_settings(cls, settings, version): @singledispatch -def sniff_csv(file, samplesize=2 ** 20, delimiters=None): +def sniff_csv(file, samplesize=4 * 2 ** 10, delimiters=None): sniffer = csv.Sniffer() sample = file.read(samplesize) dialect = sniffer.sniff(sample, delimiters=delimiters) @@ -1353,7 +1409,9 @@ def sniff(self, *_args, **_kwargs): # pylint: disable=signature-differs @sniff_csv.register(str) @sniff_csv.register(bytes) -def sniff_csv_with_path(path, encoding="utf-8", samplesize=2 ** 20, delimiters=None): +def sniff_csv_with_path( + path, encoding="utf-8", samplesize=4 * 2 ** 10, delimiters=None +): with _open(path, "rt", encoding=encoding) as f: return sniff_csv(f, samplesize, delimiters) @@ -1534,11 +1592,6 @@ def expand(ranges): numbers_format_kwds["thousands"] = opts.group_separator if numbers_format_kwds: - # float_precision = "round_trip" cannot handle non c-locale decimal and - # thousands sep (https://github.com/pandas-dev/pandas/issues/35365). - # Fallback to 'high'. - numbers_format_kwds["float_precision"] = "high" - else: numbers_format_kwds["float_precision"] = "round_trip" with ExitStack() as stack: @@ -1557,10 +1610,18 @@ def expand(ranges): file, sep=opts.dialect.delimiter, dialect=opts.dialect, skipinitialspace=opts.dialect.skipinitialspace, header=header, skiprows=skiprows, - dtype=dtypes, parse_dates=parse_dates, prefix=prefix, + dtype=dtypes, parse_dates=parse_dates, na_values=na_values, keep_default_na=False, **numbers_format_kwds ) + if parse_dates: + for date_col in parse_dates: + if df.dtypes[date_col] == "object": + df[df.columns[date_col]] = pd.to_datetime( + df.iloc[:, date_col], errors="coerce", utc=True, + ).dt.tz_localize(None) + if prefix: + df.columns = [f"{prefix}{column}" for column in df.columns] # for older workflows avoid guessing type guessing if not compatibility_mode: @@ -1627,19 +1688,6 @@ def guess_data_type(col: pd.Series) -> pd.Series: ------- Data column with correct dtype """ - def parse_dates(s): - """ - This is an extremely fast approach to datetime parsing. - For large data, the same dates are often repeated. Rather than - re-parse these, we store all unique dates, parse them, and - use a lookup to convert all dates. - """ - try: - dates = {date: pd.to_datetime(date) for date in s.unique()} - except ValueError: - return None - return s.map(dates) - if pdtypes.is_numeric_dtype(col): unique_values = col.unique() if len(unique_values) <= 2 and ( @@ -1647,13 +1695,12 @@ def parse_dates(s): or len(np.setdiff1d(unique_values, [1, 2])) == 0): return col.astype("category") else: # object - # try parse as date - if None not a date - parsed_col = parse_dates(col) - if parsed_col is not None: - return parsed_col - unique_values = col.unique() - if len(unique_values) < 100 and len(unique_values) < len(col)**0.7: - return col.astype("category") + try: + return pd.to_datetime(col) + except (ValueError, TypeError): + unique_values = col.unique() + if len(unique_values) < 100 and len(unique_values) < len(col)**0.7: + return col.astype("category") return col @@ -1784,22 +1831,20 @@ def pandas_to_table(df): columns = [] # type: List[Tuple[Orange.data.Variable, np.ndarray]] for header, series in df.items(): # type: (Any, pd.Series) - if pdtypes.is_categorical_dtype(series): + if isinstance(series.dtype, CategoricalDtype): coldata = series.values # type: pd.Categorical - categories = natural_sorted(str(c) for c in coldata.categories) - var = Orange.data.DiscreteVariable.make( - str(header), values=categories - ) + categories = natural_sorted(set(str(c) for c in coldata.categories)) + var = Orange.data.DiscreteVariable.make(str(header), values=categories) # Remap the coldata into the var.values order/set - coldata = pd.Categorical( - coldata.astype("str"), categories=var.values - ) + coldata = pd.Categorical(coldata.astype("str"), categories=var.values) codes = coldata.codes assert np.issubdtype(codes.dtype, np.integer) - orangecol = np.array(codes, dtype=np.float) + orangecol = np.array(codes, dtype=float) orangecol[codes < 0] = np.nan elif pdtypes.is_datetime64_any_dtype(series): - # Check that this converts tz local to UTC + # Convert (possible) tz local to UTC + if series.dt.tz is not None: + series = series.dt.tz_convert("UTC").dt.tz_localize(None) series = series.astype(np.dtype("M8[ns]")) coldata = series.values # type: np.ndarray assert coldata.dtype == "M8[ns]" @@ -1837,7 +1882,7 @@ def pandas_to_table(df): if cols_x: X = np.column_stack([a for _, a in cols_x]) else: - X = np.empty((df.shape[0], 0), dtype=np.float) + X = np.empty((df.shape[0], 0), dtype=np.float64) metas = [v for v, _ in cols_m] if cols_m: M = np.column_stack([a for _, a in cols_m]) diff --git a/Orange/widgets/data/owdatainfo.py b/Orange/widgets/data/owdatainfo.py index 2811875d07b..8edc8c4132c 100644 --- a/Orange/widgets/data/owdatainfo.py +++ b/Orange/widgets/data/owdatainfo.py @@ -1,28 +1,34 @@ -from collections import OrderedDict import threading import textwrap +import numpy as np + from Orange.widgets import widget, gui +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input -from Orange.data.table import Table -from Orange.data import StringVariable, DiscreteVariable, ContinuousVariable -from Orange.widgets import report + +from Orange.data import \ + Table, StringVariable, DiscreteVariable, ContinuousVariable + try: from Orange.data.sql.table import SqlTable except ImportError: - SqlTable = None + def is_sql(_): + return False +else: + def is_sql(data): + return isinstance(data, SqlTable) class OWDataInfo(widget.OWWidget): name = "Data Info" id = "orange.widgets.data.info" - description = """Display basic information about the dataset, such - as the number and type of variables in the columns and the number of rows.""" + description = "Display basic information about the data set" icon = "icons/DataInfo.svg" priority = 80 category = "Data" - keywords = ["information", "inspect"] + keywords = "data info, information, inspect" class Inputs: data = Input("Data", Table) @@ -34,174 +40,155 @@ class Inputs: def __init__(self): super().__init__() - self._clear_fields() - - for box in ("Data Set Name", "Data Set Size", "Features", "Targets", - "Meta Attributes", "Location", "Data Attributes"): - name = box.lower().replace(" ", "_") - bo = gui.vBox(self.controlArea, box) - gui.label(bo, self, "%%(%s)s" % name) - - # ensure the widget has some decent minimum width. - self.targets = "Categorical outcome with 123 values" - self.layout().activate() - # NOTE: The minimum width is set on the 'contained' widget and - # not `self`. The layout will set a fixed size to `self` taking - # into account the minimum constraints of the children (it would - # override any minimum/fixed size set on `self`). - self.targets = "" - self.controlArea.setMinimumWidth(self.controlArea.sizeHint().width()) + self.data_desc = {} + self.data_attrs = {} + self.description = gui.widgetLabel( + gui.vBox(self.controlArea, box="Data table properties")) + self.attributes = gui.widgetLabel( + gui.vBox(self.controlArea, box="Additional attributes")) @Inputs.data def data(self, data): if data is None: - self._clear_fields() + self.data_desc = self.data_attrs = {} + self.update_info() + else: + self.data_desc = { + label: value + for label, func in (("Name", self._p_name), + ("Location", self._p_location), + ("Size", self._p_size), + ("Features", self._p_features), + ("Targets", self._p_targets), + ("Metas", self._p_metas), + ("Missing data", self._p_missing)) + if bool(value := func(data))} + self.data_attrs = data.attributes + self.update_info() + + if is_sql(data): + def set_exact_length(): + self.data_desc["Size"] = self._p_size(data) + self.update_info() + + threading.Thread(target=set_exact_length).start() + + def update_info(self): + style = """""" + + def dict_as_table(d): + return "" + \ + "".join(f"" + for label, value in d.items()) + \ + "
    {label}: " + \ + '
    '.join(textwrap.wrap(value, width=60)) + \ + "
    " + + if not self.data_desc: + self.description.setText("No data.") else: - self._set_fields(data) - self._set_report(data) + self.description.setText(style + dict_as_table(self.data_desc)) + self.attributes.setHidden(not self.data_attrs) + if self.data_attrs: + self.attributes.setText( + style + dict_as_table({k: str(v) + for k, v in self.data_attrs.items()})) - def _clear_fields(self): - self.data_set_name = "" - self.data_set_size = "" - self.features = self.targets = self.meta_attributes = "" - self.location = "" - self.data_desc = None - self.data_attributes = "" + def send_report(self): + if self.data_desc: + self.report_items("Data table properties", self.data_desc) + if self.data_attrs: + self.report_items("Additional attributes", self.data_attrs) @staticmethod - def _count(s, tpe): - return sum(isinstance(x, tpe) for x in s) + def _p_name(data): + return getattr(data, "name", "-") + + @staticmethod + def _p_location(data): + if not is_sql(data): + return None - def _set_fields(self, data): - # Attributes are defined in a function called from __init__ - # pylint: disable=attribute-defined-outside-init - def n_or_none(n): - return n or "-" - - def pack_table(info): - return '\n' + "\n".join( - '\n' - '\n'.format( - d, - textwrap.shorten(str(v), width=30, placeholder="...")) - for d, v in info - ) + "
    {}:{}
    \n" - - def pack_counts(s, include_non_primitive=False): - if not s: - return "None" - return pack_table( - (name, n_or_none(self._count(s, type_))) - for name, type_ in ( - ("Categorical", DiscreteVariable), - ("Numeric", ContinuousVariable), - ("Text", StringVariable))[:2 + include_non_primitive] - ) - - domain = data.domain - class_var = domain.class_var + connection_string = ' '.join( + f'{key}={value}' + for key, value in data.connection_params.items() + if value is not None and key != 'password') + return f"SQL Table using connection:
    {connection_string}" + + @staticmethod + def _p_size(data): + n = len(data) + desc = f"{n} {pl(n, 'row')}" + ncols = len(data.domain.variables) + len(data.domain.metas) + desc += f", {ncols} {pl(ncols, 'column')}" sparseness = [s for s, m in (("features", data.X_density), ("meta attributes", data.metas_density), ("targets", data.Y_density)) if m() > 1] if sparseness: - sparseness = "

    Sparse representation: {}

    "\ - .format(", ".join(sparseness)) - else: - sparseness = "" - self.data_set_size = pack_table(( - ("Rows", '~{}'.format(data.approx_len())), - ("Columns", len(domain.variables)+len(domain.metas)))) + sparseness + desc += "; sparse {', '.join(sparseness)}" + return desc - def update_size(): - self.data_set_size = pack_table(( - ("Rows", len(data)), - ("Columns", len(domain.variables)+len(domain.metas)))) + sparseness + @classmethod + def _p_features(cls, data): + return cls._pack_var_counts(data.domain.attributes) - threading.Thread(target=update_size).start() - - self.data_set_name = getattr(data, "name", "N/A") - - self.features = pack_counts(domain.attributes) - self.meta_attributes = pack_counts(domain.metas, True) - if class_var: + def _p_targets(self, data): + if class_var := data.domain.class_var: if class_var.is_continuous: - self.targets = "Numeric target variable" + return "numeric target variable" else: - self.targets = "Categorical outcome with {} values"\ - .format(len(class_var.values)) - elif domain.class_vars: - disc_class = self._count(domain.class_vars, DiscreteVariable) - cont_class = self._count(domain.class_vars, ContinuousVariable) + nclasses = len(class_var.values) + return "categorical outcome with " \ + f"{nclasses} {pl(nclasses, 'class|classes')}" + if class_vars := data.domain.class_vars: + disc_class = self._count(class_vars, DiscreteVariable) + cont_class = self._count(class_vars, ContinuousVariable) if not cont_class: - self.targets = "Multi-target data,\n{} categorical targets"\ - .format(n_or_none(disc_class)) + return f"{disc_class} categorical {pl(disc_class, 'target')}" elif not disc_class: - self.targets = "Multi-target data,\n{} numeric targets"\ - .format(n_or_none(cont_class)) - else: - self.targets = "

    Multi-target data

    \n" + \ - pack_counts(domain.class_vars) - else: - self.targets = "None" + return f"{cont_class} numeric {pl(cont_class, 'target')}" + return "multi-target data,
    " + self._pack_var_counts(class_vars) - if data.attributes: - self.data_attributes = pack_table(data.attributes.items()) - else: - self.data_attributes = "" - - def _set_report(self, data): - # Attributes are defined in a function called from __init__ - # pylint: disable=attribute-defined-outside-init - domain = data.domain - count = self._count - - self.data_desc = dd = OrderedDict() - dd["Name"] = self.data_set_name - - if SqlTable is not None and isinstance(data, SqlTable): - connection_string = ' '.join( - '{}={}'.format(key, value) - for key, value in data.connection_params.items() - if value is not None and key != 'password') - self.location = "Table '{}', using connection:\n{}"\ - .format(data.table_name, connection_string) - dd["Rows"] = data.approx_len() - else: - self.location = "Data is stored in memory" - dd["Rows"] = len(data) - - def join_if(items): - return ", ".join(s.format(n) for s, n in items if n) - - dd["Features"] = len(domain.attributes) > 0 and join_if(( - ("{} categorical", count(domain.attributes, DiscreteVariable)), - ("{} numeric", count(domain.attributes, ContinuousVariable)) - )) - if domain.class_var: - name = domain.class_var.name - if domain.class_var.is_discrete: - dd["Target"] = "categorical outcome '{}'".format(name) - else: - dd["Target"] = "numeric target '{}'".format(name) - elif domain.class_vars: - disc_class = count(domain.class_vars, DiscreteVariable) - cont_class = count(domain.class_vars, ContinuousVariable) - tt = "" - if disc_class: - tt += report.plural("{number} categorical outcome{s}", disc_class) - if cont_class: - tt += report.plural("{number} numeric target{s}", cont_class) - dd["Meta attributes"] = len(domain.metas) > 0 and join_if(( - ("{} categorical", count(domain.metas, DiscreteVariable)), - ("{} numeric", count(domain.metas, ContinuousVariable)), - ("{} text", count(domain.metas, StringVariable)) - )) + @classmethod + def _p_metas(cls, data): + return cls._pack_var_counts(data.domain.metas) - def send_report(self): - if self.data_desc: - self.report_items(self.data_desc) + @staticmethod + def _p_missing(data: Table): + if is_sql(data): + return "(not checked for SQL data)" + + counts = [] + for name, part, n_miss in ((pl(len(data.domain.attributes), "feature"), + data.X, data.get_nan_count_attribute()), + (pl(len(data.domain.class_vars), "targets"), + data.Y, data.get_nan_count_class()), + (pl(len(data.domain.metas), "meta variable"), + data.metas, data.get_nan_count_metas())): + if n_miss: + counts.append( + f"{n_miss} ({n_miss / np.prod(part.shape):.1%}) in {name}") + if not counts: + return "none" + return ", ".join(counts) + + @staticmethod + def _count(s, tpe): + return sum(isinstance(x, tpe) for x in s) + + @classmethod + def _pack_var_counts(cls, s): + counts = ( + (name, cls._count(s, type_)) + for name, type_ in (("categorical", DiscreteVariable), + ("numeric", ContinuousVariable), + ("text", StringVariable))) + return ", ".join(f"{count} {name}" for name, count in counts if count) if __name__ == "__main__": # pragma: no cover - WidgetPreview(OWDataInfo).run(Table("iris")) + WidgetPreview(OWDataInfo).run(Table("heart_disease")) diff --git a/Orange/widgets/data/owdatasampler.py b/Orange/widgets/data/owdatasampler.py index e6d9376a977..76d3cee7682 100644 --- a/Orange/widgets/data/owdatasampler.py +++ b/Orange/widgets/data/owdatasampler.py @@ -10,6 +10,7 @@ from Orange.widgets.settings import Setting from Orange.data import Table from Orange.data.sql.table import SqlTable +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Msg, OWWidget, Input, Output from Orange.util import Reprable @@ -21,8 +22,8 @@ class OWDataSampler(OWWidget): "from the input dataset." icon = "icons/DataSampler.svg" priority = 100 - category = "Data" - keywords = ["random"] + category = "Transform" + keywords = "data sampler, random" _MAX_SAMPLE_SIZE = 2 ** 31 - 1 @@ -67,14 +68,14 @@ class Information(OWWidget.Information): ) class Warning(OWWidget.Warning): - could_not_stratify = Msg("Stratification failed\n{}") - bigger_sample = Msg('Sample is bigger than input') + could_not_stratify = Msg("Stratification failed.\n{}") + bigger_sample = Msg('Sample is bigger than input.') class Error(OWWidget.Error): - too_many_folds = Msg("Number of subsets exceeds data size") - sample_larger_than_data = Msg("Sample can't be larger than data") - not_enough_to_stratify = Msg("Data is too small to stratify") - no_data = Msg("Dataset is empty") + too_many_folds = Msg("Number of subsets exceeds data size.") + sample_larger_than_data = Msg("Sample can't be larger than data.") + not_enough_to_stratify = Msg("Data is too small to stratify.") + no_data = Msg("Dataset is empty.") def __init__(self): super().__init__() @@ -106,7 +107,7 @@ def set_sampling_type_i(): ibox = gui.indentedBox(sampling) self.sampleSizeSpin = gui.spin( ibox, self, "sampleSizeNumber", label="Instances: ", - minv=1, maxv=self._MAX_SAMPLE_SIZE, + minv=0, maxv=self._MAX_SAMPLE_SIZE, callback=set_sampling_type(self.FixedSize), controlWidth=90) gui.checkBox( @@ -310,14 +311,12 @@ def sample(self, data_length, size, stratified): def send_report(self): if self.sampling_type == self.FixedProportion: - tpe = "Random sample with {} % of data".format( - self.sampleSizePercentage) + tpe = f"Random sample with {self.sampleSizePercentage} % of data" elif self.sampling_type == self.FixedSize: if self.sampleSizeNumber == 1: tpe = "Random data instance" else: - tpe = "Random sample with {} data instances".format( - self.sampleSizeNumber) + tpe = f"Random sample with {self.sampleSizeNumber} data instances" if self.replacement: tpe += ", with replacement" elif self.sampling_type == self.CrossValidation: @@ -326,7 +325,7 @@ def send_report(self): elif self.sampling_type == self.Bootstrap: tpe = "Bootstrap" else: # pragma: no cover - tpe = "Undefined" # should not come here at all + assert False if self.stratify: tpe += ", stratified (if possible)" if self.use_seed: @@ -334,9 +333,9 @@ def send_report(self): items = [("Sampling type", tpe)] if self.sampled_instances is not None: items += [ - ("Input", "{} instances".format(len(self.data))), - ("Sample", "{} instances".format(self.sampled_instances)), - ("Remaining", "{} instances".format(self.remaining_instances)), + ("Input", f"{len(self.data)} {pl(len(self.data), 'instance')}"), + ("Sample", f"{self.sampled_instances} {pl(self.sampled_instances, 'instance')}"), + ("Remaining", f"{self.remaining_instances} {pl(self.remaining_instances, 'instance')}"), ] self.report_items(items) @@ -396,11 +395,15 @@ def __call__(self, table): o[sample] = 0 others = np.nonzero(o)[0] return others, sample - if self.n == len(table): + if self.n in (0, len(table)): rgen = np.random.RandomState(self.random_state) - sample = np.arange(self.n) - rgen.shuffle(sample) - return np.array([], dtype=int), sample + shuffled = np.arange(len(table)) + rgen.shuffle(shuffled) + empty = np.array([], dtype=int) + if self.n == 0: + return shuffled, empty + else: + return empty, shuffled elif self.stratified and table.domain.has_discrete_class: test_size = max(len(table.domain.class_var.values), self.n) splitter = skl.StratifiedShuffleSplit( @@ -446,7 +449,7 @@ def __call__(self, table=None): rgen = np.random.RandomState(self.random_state) sample = rgen.randint(0, self.size, self.size) sample.sort() # not needed for the code below, just for the user - insample = np.ones((self.size,), dtype=np.bool) + insample = np.ones((self.size,), dtype=bool) insample[sample] = False remaining = np.flatnonzero(insample) return remaining, sample diff --git a/Orange/widgets/data/owdatasets.py b/Orange/widgets/data/owdatasets.py index 40da89cab56..3bafb6076f2 100644 --- a/Orange/widgets/data/owdatasets.py +++ b/Orange/widgets/data/owdatasets.py @@ -12,7 +12,8 @@ from AnyQt.QtWidgets import ( QLabel, QLineEdit, QTextBrowser, QSplitter, QTreeView, - QStyleOptionViewItem, QStyledItemDelegate, QStyle, QApplication + QStyleOptionViewItem, QStyledItemDelegate, QStyle, QApplication, + QHBoxLayout, QComboBox ) from AnyQt.QtGui import QStandardItemModel, QStandardItem, QBrush, QColor from AnyQt.QtCore import ( @@ -25,7 +26,8 @@ import Orange.data from Orange.misc.environ import data_dir -from Orange.widgets import settings, gui +from Orange.widgets import gui +from Orange.widgets.settings import Setting from Orange.widgets.utils.signals import Output from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import OWWidget, Msg @@ -33,6 +35,15 @@ log = logging.getLogger(__name__) +# These two constants are used in settings (and in the proxy filter model). +# The corresponding options in the combo box are translatable, therefore +# the settings must be stored in language-independent form. +GENERAL_DOMAIN = None +ALL_DOMAINS = "" # The setting is Optional[str], so don't use other types here + +# The number of characters at which filter overrides the domain and language +FILTER_OVERRIDE_LENGTH = 4 + def ensure_local(index_url, file_path, local_cache_path, force=False, progress_advance=None): @@ -100,6 +111,8 @@ class UniformHeightIndicatorDelegate( class Namespace(SimpleNamespace): + PUBLISHED, UNLISTED = range(2) + def __init__(self, **kwargs): self.file_path = None self.prefix = None @@ -119,8 +132,11 @@ def __init__(self, **kwargs): self.references = [] self.seealso = [] self.tags = [] + self.language = "English" + self.domain = None + self.publication_status = self.PUBLISHED - super(Namespace, self).__init__(**kwargs) + super().__init__(**kwargs) # if title missing, use filename if not self.title and self.filename: @@ -137,13 +153,53 @@ def keyPressEvent(self, e): super().keyPressEvent(e) +class SortFilterProxyWithLanguage(QSortFilterProxyModel): + def __init__(self): + super().__init__() + self.__language = None + self.__domain = None + self.__filter = None + + def setFilterFixedString(self, pattern): + self.__filter = pattern and pattern.casefold() + super().setFilterFixedString(pattern) + + def setLanguage(self, language): + self.__language = language + self.invalidateFilter() + + def language(self): + return self.__language + + def setDomain(self, domain): + self.__domain = domain + self.invalidateFilter() + + def domain(self): + return self.__domain + + def filterAcceptsRow(self, row, parent): + source = self.sourceModel() + data = source.index(row, 0).data(Qt.UserRole) + in_filter = ( + self.__filter is not None + and len(self.__filter) >= FILTER_OVERRIDE_LENGTH + and self.__filter in data.title.casefold() + ) + published_ok = data.publication_status == Namespace.PUBLISHED + domain_ok = self.__domain in (ALL_DOMAINS, data.domain) + language_ok = self.__language in (None, data.language) + return (super().filterAcceptsRow(row, parent) + and (published_ok and domain_ok and language_ok + or in_filter)) + class OWDataSets(OWWidget): name = "Datasets" description = "Load a dataset from an online repository" icon = "icons/DataSets.svg" priority = 20 replaces = ["orangecontrib.prototypes.widgets.owdatasets.OWDataSets"] - keywords = ["online", "data sets"] + keywords = "datasets, online, data, sets" want_control_area = False @@ -152,6 +208,12 @@ class OWDataSets(OWWidget): # Take care when refactoring! (used in e.g. single-cell) INDEX_URL = "https://datasets.biolab.si/" DATASET_DIR = "datasets" + DEFAULT_LANG = "English" + ALL_LANGUAGES = "All Languages" + + # These two combo options are translatable; others (domain names) are not + GENERAL_DOMAIN_LABEL = "(General)" + ALL_DOMAINS_LABEL = "(Show all)" # override HEADER_SCHEMA to define new columns # if schema is changed override methods: self.assign_delegates and @@ -179,11 +241,15 @@ class Outputs: data = Output("Data", Orange.data.Table) #: Selected dataset id - selected_id = settings.Setting(None) # type: Optional[str] + selected_id: Optional[str] = Setting(None) + language = Setting(DEFAULT_LANG) + domain = Setting(GENERAL_DOMAIN) + filter_hint: Optional[str] = Setting(None) + settings_version = 2 #: main area splitter state - splitter_state = settings.Setting(b'') # type: bytes - header_state = settings.Setting(b'') # type: bytes + splitter_state = Setting(b'') # type: bytes + header_state = Setting(b'') # type: bytes def __init__(self): super().__init__() @@ -204,10 +270,46 @@ def __init__(self): self.__awaiting_state = None # type: Optional[_FetchState] + layout = QHBoxLayout() self.filterLineEdit = QLineEdit( textChanged=self.filter, placeholderText="Search for data set ..." ) - self.mainArea.layout().addWidget(self.filterLineEdit) + self.filterLineEdit.setToolTip( + "Typing four letters or more overrides domain and language filters") + layout.addWidget(self.filterLineEdit) + + self.combo_elements = [] + + layout.addSpacing(20) + label = QLabel("Show data sets in ") + layout.addWidget(label) + self.combo_elements.append(label) + + lang_combo = self.language_combo = QComboBox() + languages = [self.DEFAULT_LANG, self.ALL_LANGUAGES] + if self.language is not None and self.language not in languages: + languages.insert(1, self.language) + lang_combo.addItems(languages) + if self.language is None: + lang_combo.setCurrentIndex(lang_combo.count() - 1) + else: + lang_combo.setCurrentText(self.language) + lang_combo.activated.connect(self._on_language_changed) + layout.addWidget(lang_combo) + self.combo_elements.append(lang_combo) + + domain_combo = self.domain_combo = QComboBox() + domain_combo.addItem(self.GENERAL_DOMAIN_LABEL) + domain_combo.activated.connect(self._on_domain_changed) + if self.core_widget: + layout.addSpacing(20) + label = QLabel("Domain:") + layout.addWidget(label) + self.combo_elements.append(label) + layout.addWidget(domain_combo) + self.combo_elements.append(domain_combo) + + self.mainArea.layout().addLayout(layout) self.splitter = QSplitter(orientation=Qt.Vertical) @@ -248,10 +350,13 @@ def __init__(self): ) self.mainArea.layout().addWidget(self.splitter) - proxy = QSortFilterProxyModel() + proxy = SortFilterProxyWithLanguage() proxy.setFilterKeyColumn(-1) proxy.setFilterCaseSensitivity(Qt.CaseInsensitive) self.view.setModel(proxy) + if not self.core_widget: + self.domain = ALL_DOMAINS + self.view.model().setDomain(self.domain) if self.splitter_state: self.splitter.restoreState(self.splitter_state) @@ -266,6 +371,20 @@ def __init__(self): w = FutureWatcher(f, parent=self) w.done.connect(self.__set_index) + self._on_language_changed() + + # Single cell add-on has a data set widget that derives from this one + # although this class isn't defined as open. Adding the domain broke + # single-cell. A proper solution would be to split this widget into an + # (open) base class and a closed widget that adds the domain functionality. + # Yet, simply excluding three chunks of code makes this code simpler + # - which is better, if we assume that extending this widget is an anomaly. + @property + def core_widget(self): + # Compare by names; unit tests wrap widget classes in to detect + # missing onDeleteWidget calls + return type(self).__name__ == OWDataSets.__name__ + def assign_delegates(self): # NOTE: All columns must have size hinting delegates. # QTreeView queries only the columns displayed in the viewport so @@ -274,7 +393,7 @@ def assign_delegates(self): self.view.setItemDelegate(UniformHeightDelegate(self)) self.view.setItemDelegateForColumn( self.Header.islocal, - UniformHeightIndicatorDelegate(self, role=Qt.DisplayRole, indicatorSize=4) + UniformHeightIndicatorDelegate(self, indicatorSize=4) ) self.view.setItemDelegateForColumn( self.Header.size, @@ -312,6 +431,39 @@ def _parse_info(self, file_path): islocal=islocal, outdated=outdated, **info) def create_model(self): + self.update_language_combo() + self.update_domain_combo() + return self.update_model() + + def update_language_combo(self): + combo = self.language_combo + current_language = combo.currentText() + allkeys = set(self.allinfo_local) | set(self.allinfo_remote) + languages = {self._parse_info(key).language for key in allkeys} + if self.language is not None: + languages.add(self.language) + languages = sorted(languages) + combo.clear() + if self.DEFAULT_LANG not in languages: + combo.addItem(self.DEFAULT_LANG) + combo.addItems(languages + [self.ALL_LANGUAGES]) + if current_language in languages or current_language == self.ALL_LANGUAGES: + combo.setCurrentText(current_language) + elif self.DEFAULT_LANG in languages: + combo.setCurrentText(self.DEFAULT_LANG) + else: + combo.setCurrentText(self.ALL_LANGUAGES) + + def update_domain_combo(self): + combo = self.domain_combo + allkeys = set(self.allinfo_local) | set(self.allinfo_remote) + domains = {self._parse_info(key).domain for key in allkeys} + domains -= {None, "sc"} + if domains: + combo.addItems(sorted(domains)) + combo.addItem(self.ALL_DOMAINS_LABEL) + + def update_model(self): allkeys = set(self.allinfo_local) | set(self.allinfo_remote) allkeys = sorted(allkeys) @@ -319,10 +471,14 @@ def create_model(self): model.setHorizontalHeaderLabels(self._header_labels) current_index = -1 + localinfo = list_local(self.local_cache_path) for i, file_path in enumerate(allkeys): datainfo = self._parse_info(file_path) item1 = QStandardItem() - item1.setData(" " if datainfo.islocal else "", Qt.DisplayRole) + # this elegant and spotless trick is used for sorting + state = self.indicator_state_for_info(datainfo, localinfo) + item1.setData({None: "", False: " ", True: " "}[state], Qt.DisplayRole) + item1.setData(state, UniformHeightIndicatorDelegate.IndicatorRole) item1.setData(self.IndicatorBrushes[0], Qt.ForegroundRole) item1.setData(datainfo, Qt.UserRole) item2 = QStandardItem(datainfo.title) @@ -342,11 +498,45 @@ def create_model(self): row = [item1, item2, item3, item4, item5, item6, item7] model.appendRow(row) - if os.path.join(*file_path) == self.selected_id: + # for settings do not use os.path.join (Windows separator is different) + if file_path[-1] == self.selected_id: current_index = i + if self.core_widget: + self.domain = datainfo.domain + if self.domain == "sc": # domain from the list of ignored domain + self.domain = ALL_DOMAINS + self.__update_domain_combo() + self._on_domain_changed() return model, current_index + def __update_domain_combo(self): + combo = self.domain_combo + if self.domain == GENERAL_DOMAIN: + combo.setCurrentIndex(0) + elif self.domain == ALL_DOMAINS: + combo.setCurrentIndex(combo.count() - 1) + else: + combo.setCurrentText(self.domain) + + def _on_language_changed(self): + combo = self.language_combo + if combo.currentIndex() == combo.count() - 1: + self.language = None + else: + self.language = combo.currentText() + self.view.model().setLanguage(self.language) + + def _on_domain_changed(self): + combo = self.domain_combo + if combo.currentIndex() == 0: + self.domain = GENERAL_DOMAIN + elif combo.currentIndex() == combo.count() - 1: + self.domain = ALL_DOMAINS + else: + self.domain = combo.currentText() + self.view.model().setDomain(self.domain) + @Slot(object) def __set_index(self, f): # type: (Future) -> None @@ -368,14 +558,26 @@ def __set_index(self, f): self.allinfo_remote = {} model, current_index = self.create_model() + self.set_model(model, current_index) + def set_model(self, model, current_index): self.view.model().setSourceModel(model) + if current_index != -1: + for hint in ( + self.filter_hint, + model.index(current_index, 0).data(Qt.UserRole).title): + if self.view.model().filterAcceptsRow(current_index, + QModelIndex()): + break + self.filterLineEdit.setText(hint) + self.filter() + self.view.selectionModel().selectionChanged.connect( self.__on_selection ) scw = self.view.setColumnWidth - width = self.view.fontMetrics().width + width = self.view.fontMetrics().horizontalAdvance self.view.resizeColumnToContents(0) scw(self.Header.title, width("X" * 37)) scw(self.Header.size, 20 + max(width("888 bytes "), width("9999.9 MB "))) @@ -392,20 +594,26 @@ def __set_index(self, f): QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows) self.commit() + def indicator_state_for_info(self, info, localinfo): + if not info.file_path in localinfo: + return None + return ( + os.path.join(self.local_cache_path, *info.file_path) + == self.current_output) + def __update_cached_state(self): model = self.view.model().sourceModel() - localinfo = list_local(self.local_cache_path) assert isinstance(model, QStandardItemModel) allinfo = [] + localinfo = list_local(self.local_cache_path) for i in range(model.rowCount()): item = model.item(i, 0) info = item.data(Qt.UserRole) - is_local = info.file_path in localinfo - is_current = (is_local and - os.path.join(self.local_cache_path, *info.file_path) - == self.current_output) - item.setData(" " * (is_local + is_current), Qt.DisplayRole) - item.setData(self.IndicatorBrushes[is_current], Qt.ForegroundRole) + state = self.indicator_state_for_info(info, localinfo) + # this elegant and spotless trick is used for sorting + item.setData({None: "", False: " ", True: " "}[state], Qt.DisplayRole) + item.setData(state, UniformHeightIndicatorDelegate.IndicatorRole) + item.setData(self.IndicatorBrushes[bool(state)], Qt.ForegroundRole) allinfo.append(info) def selected_dataset(self): @@ -428,6 +636,18 @@ def selected_dataset(self): def filter(self): filter_string = self.filterLineEdit.text().strip() + enable_combos = len(filter_string) < FILTER_OVERRIDE_LENGTH + if enable_combos is not self.domain_combo.isEnabled(): + for element in self.combo_elements: + element.setEnabled(enable_combos) + if enable_combos: + self.__update_domain_combo() + self.language_combo.setCurrentText(self.language) + else: + self.domain_combo.setCurrentText(self.ALL_DOMAINS_LABEL) + self.language_combo.setCurrentText(self.ALL_LANGUAGES) + + self.filter_hint = filter_string proxyModel = self.view.model() if proxyModel: proxyModel.setFilterFixedString(filter_string) @@ -442,10 +662,8 @@ def __on_selection(self): di = current.data(Qt.UserRole) text = description_html(di) self.descriptionlabel.setText(text) - self.selected_id = os.path.join(di.prefix, di.filename) else: self.descriptionlabel.setText("") - self.selected_id = None def commit(self): """ @@ -458,6 +676,7 @@ def commit(self): di = self.selected_dataset() if di is not None: self.Error.clear() + self.selected_id = di.file_path[-1] if self.__awaiting_state is not None: # disconnect from the __commit_complete @@ -473,8 +692,7 @@ def commit(self): self.__awaiting_state = None if not di.islocal: - pr = progress() - callback = lambda pr=pr: pr.advance.emit() + pr = Progress() pr.advance.connect(self.__progress_advance, Qt.QueuedConnection) self.progressBarInit() @@ -484,7 +702,7 @@ def commit(self): f = self._executor.submit( ensure_local, self.INDEX_URL, di.file_path, self.local_cache_path, force=di.outdated, - progress_advance=callback) + progress_advance=pr.advance.emit) w = FutureWatcher(f, parent=self) w.done.connect(self.__commit_complete) self.__awaiting_state = _FetchState(f, w, pr) @@ -493,6 +711,7 @@ def commit(self): self.setBlocking(False) self.commit_cached(di.file_path) else: + self.selected_id = None self.load_and_output(None) @Slot(object) @@ -535,8 +754,7 @@ def onDeleteWidget(self): self.__awaiting_state.pb.advance.disconnect(self.__progress_advance) self.__awaiting_state = None - @staticmethod - def sizeHint(): + def sizeHint(self): return QSize(1100, 500) def closeEvent(self, event): @@ -558,6 +776,15 @@ def load_and_output(self, path): def load_data(path): return Orange.data.Table(path) + @classmethod + def migrate_settings(cls, settings, version: Optional[int] = None): + selected_id = settings.get("selected_id") + if isinstance(selected_id, str): + # until including 3.36.0 selected dataset was saved with \ on Windows + selected_id = selected_id.replace("\\", "/") + if version is None or version < 2: + settings["selected_id"] = selected_id.split("/")[-1] + class FutureWatcher(QObject): done = Signal(object) @@ -575,7 +802,7 @@ def __on_done(self, f): self.done.emit(self.__future) -class progress(QObject): +class Progress(QObject): advance = Signal() @@ -601,7 +828,7 @@ def make_html_list(items): style = '"margin: 5px; text-indent: -40px; margin-left: 40px;"' def format_item(i): - return '

    {}

    '.format(style, i) + return f'

    {i}

    ' return '\n'.join([format_item(i) for i in items]) @@ -612,11 +839,11 @@ def description_html(datainfo): Summarize a data info as a html fragment. """ html = [] - year = " ({})".format(str(datainfo.year)) if datainfo.year else "" - source = ", from {}".format(datainfo.source) if datainfo.source else "" + year = f" ({datainfo.year})" if datainfo.year else "" + source = f", from {datainfo.source}" if datainfo.source else "" - html.append("{}{}{}".format(escape(datainfo.title), year, source)) - html.append("

    {}

    ".format(datainfo.description)) + html.append(f"{escape(datainfo.title)}{year}{source}") + html.append(f"

    {datainfo.description}

    ") seealso = make_html_list(datainfo.seealso) if seealso: html.append("See Also\n" + seealso + "") diff --git a/Orange/widgets/data/owdiscretize.py b/Orange/widgets/data/owdiscretize.py index 142acd711d7..6700244907e 100644 --- a/Orange/widgets/data/owdiscretize.py +++ b/Orange/widgets/data/owdiscretize.py @@ -1,357 +1,591 @@ import re +import html from enum import IntEnum -from collections import namedtuple -from typing import Optional, Tuple, Iterable, Union, Callable, Any +from typing import Optional, Tuple, Union, Callable, NamedTuple, Dict, List +from AnyQt.QtCore import ( + Qt, QTimer, QPoint, QItemSelectionModel, QSize, QAbstractListModel, +) +from AnyQt.QtGui import ( + QValidator, QPalette, QDoubleValidator, QIntValidator, QColor) from AnyQt.QtWidgets import ( QListView, QHBoxLayout, QStyledItemDelegate, QButtonGroup, QWidget, - QLineEdit, QToolTip, QLabel, QApplication -) -from AnyQt.QtGui import QValidator, QPalette -from AnyQt.QtCore import Qt, QTimer, QPoint -from orangewidget.utils.listview import ListViewSearch + QLineEdit, QToolTip, QLabel, QApplication, + QSpinBox, QSizePolicy, QRadioButton, QComboBox) -import Orange.data +from orangewidget.settings import Setting +from orangewidget.utils import listview + +from Orange.data import ( + Variable, ContinuousVariable, DiscreteVariable, TimeVariable, Domain, Table) import Orange.preprocess.discretize as disc -from Orange.data import Variable -from Orange.widgets import widget, gui, settings -from Orange.widgets.utils import itemmodels, vartype, unique_everseen +from Orange.widgets.utils.localization import pl +from Orange.widgets import widget, gui +from Orange.widgets.utils import unique_everseen +from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output from Orange.widgets.data.oweditdomain import FixedSizeButton -__all__ = ["OWDiscretize"] - -# 'Default' method delegates to 'method' -Default = namedtuple("Default", ["method"]) -Leave = namedtuple("Leave", []) -MDL = namedtuple("MDL", []) -EqualFreq = namedtuple("EqualFreq", ["k"]) -EqualWidth = namedtuple("EqualWidth", ["k"]) -Remove = namedtuple("Remove", []) -Custom = namedtuple("Custom", ["points"]) - - -MethodType = Union[ - Default, - Leave, - MDL, - EqualFreq, - EqualWidth, - Remove, - Custom, -] - -_dispatch = { - Default: - lambda m, data, var: _dispatch[type(m.method)](m.method, data, var), - Leave: lambda m, data, var: var, - MDL: lambda m, data, var: disc.EntropyMDL()(data, var), - EqualFreq: lambda m, data, var: disc.EqualFreq(m.k)(data, var), - EqualWidth: lambda m, data, var: disc.EqualWidth(m.k)(data, var), - Remove: lambda m, data, var: None, - Custom: - lambda m, data, var: - disc.Discretizer.create_discretized_var(var, m.points) +re_custom_sep = re.compile(r"\s*,\s*") +time_units = ["year", "month", "day", "week", "hour", "minute", "second"] +INVALID_WIDTH = "invalid width" +TOO_MANY_INTERVALS = "too many intervals" + + +def _fixed_width_discretization( + data: Table, + var: Union[ContinuousVariable, str, int], + width: str) -> Union[DiscreteVariable, str]: + """ + Discretize numeric variable with fixed bin width. Used in method definition. + + Width is given as string (coming from line edit). The labels for the new + variable will have the same number of digits; this is more appropriate + than the number of digits in the original variable, which may be too large. + + Args: + data: data used to deduce the interval of values + var: variable to discretize + width: interval width + + Returns: + Discrete variable, if successful; a string with error otherwise + """ + digits = len(width) - width.index(".") - 1 if "." in width else 0 + try: + width = float(width) + except ValueError: + return INVALID_WIDTH + if width <= 0: + return INVALID_WIDTH + try: + return disc.FixedWidth(width, digits)(data, var) + except disc.TooManyIntervals: + return TOO_MANY_INTERVALS + + +# pylint: disable=invalid-name +def _fixed_time_width_discretization( + data: Table, + var: Union[TimeVariable, str, int], + width: str, unit: int) -> Union[DiscreteVariable]: + """ + Discretize time variable with fixed bin width. Used in method definition. + + Width is given as string (coming from line edit). + + Args: + data: data used to deduce the interval of values + var: variable to discretize + width: interval width + unit: 0 = year, 1 = month, 2 = week, 3 = day, 4 = hour, 5 = min, 6 = sec + + Returns: + Discrete variable, if successful; a string with error otherwise + """ + try: + width = int(width) + except ValueError: + return INVALID_WIDTH + if width <= 0: + return INVALID_WIDTH + if unit == 3: # week + width *= 7 + unit -= unit >= 3 + try: + return disc.FixedTimeWidth(width, unit)(data, var) + except disc.TooManyIntervals: + return TOO_MANY_INTERVALS + + +def _mdl_discretization( + data: Table, + var: Union[ContinuousVariable, str, int]) -> Union[DiscreteVariable, str]: + if not data.domain.has_discrete_class: + return "no discrete class" + return disc.EntropyMDL()(data, var) + + +def _custom_discretization( + _, + var: Union[ContinuousVariable, str, int], + points: str) -> Union[DiscreteVariable, str]: + """ + Discretize variable using custom thresholds. Used in method definition. + + Thresholds are given as string (coming from line edit). + + Args: + data: data used to deduce the interval of values + var: variable to discretize + points: thresholds + + Returns: + Discrete variable, if successful; a string with error otherwise + """ + try: + cuts = [float(x) for x in re_custom_sep.split(points.strip())] + except ValueError: + cuts = [] + if any(x >= y for x, y in zip(cuts, cuts[1:])): + cuts = [] + if not cuts: + return "invalid cuts" + return disc.Discretizer.create_discretized_var(var, cuts) + + +class Methods(IntEnum): + # pylint: disable=invalid-name + Default, Keep, MDL, EqualFreq, EqualWidth, Remove, Custom, Binning, \ + FixedWidth, FixedWidthTime = range(10) + + +class MethodDesc(NamedTuple): + """ + Definitions of all methods; used for creation of interface and calling + """ + id_: Methods # Method id + label: str # Label used for radio button + short_desc: str # Short descriptions for list views + tooltip: str # Tooltip for radio button + # Discretization function, see, e.g. fixed_width_discretization + function: Optional[Callable[..., Union[DiscreteVariable, str]]] + controls: Tuple[str, ...] = () # Widget attributes with related ux controls + + +Options: Dict[Methods, MethodDesc] = { + method.id_: method + for method in ( + MethodDesc(Methods.Default, + "Use general preset", "preset", + "Treat the variable as defined in general preset", + None, + ()), + MethodDesc(Methods.Keep, + "Keep numeric", "keep", + "Keep the variable as is", + lambda data, var: var, + ()), + MethodDesc(Methods.MDL, + "Entropy vs. MDL", "entropy", + "Split values until MDL exceeds the entropy (Fayyad-Irani)\n" + "(requires discrete class variable)", + _mdl_discretization, + ()), + MethodDesc(Methods.EqualFreq, + "Equal frequency, intervals: ", "equal freq, k={}", + "Create bins with same number of instances", + lambda data, var, k: disc.EqualFreq(k)(data, var), + ("freq_spin", )), + MethodDesc(Methods.EqualWidth, + "Equal width, intervals: ", "equal width, k={}", + "Create bins of the same width", + lambda data, var, k: disc.EqualWidth(k)(data, var), + ("width_spin", )), + MethodDesc(Methods.Remove, + "Remove", "remove", + "Remove variable", + lambda *_: None, + ()), + MethodDesc(Methods.Binning, + "Natural binning, desired bins: ", "binning, desired={}", + "Create bins with nice thresholds; " + "try matching desired number of bins", + lambda data, var, nbins: disc.Binning(nbins)(data, var), + ("binning_spin", )), + MethodDesc(Methods.FixedWidth, + "Fixed width: ", "fixed width {}", + "Create bins with the given width (not for time variables)", + _fixed_width_discretization, + ("width_line", )), + MethodDesc(Methods.FixedWidthTime, + "Time interval: ", "time interval, {} {}", + "Create bins with the give width (for time variables)", + _fixed_time_width_discretization, + ("width_time_line", "width_time_unit")), + MethodDesc(Methods.Custom, + "Custom: ", "custom: {}", + "Use manually specified thresholds", + _custom_discretization, + ("threshold_line", )) + ) } -# Variable discretization state (back compat for deserialization) -DState = namedtuple( - "DState", - ["method", # discretization method - "points", # induced cut points - "disc_var"] # induced discretized variable -) +class VarHint(NamedTuple): + """Description for settings""" + method_id: Methods + args: Tuple[Union[str, float, int]] -def is_discretized(var): - return isinstance(var.compute_value, disc.Discretizer) +class DiscDesc(NamedTuple): + """Data for list view model""" + hint: VarHint + points: str + values: Tuple[str] -def variable_key(var): - return vartype(var), var.name +KeyType = Optional[Tuple[str, bool]] +DefaultHint = VarHint(Methods.Keep, ()) +DefaultKey = None -def button_group_reset(group): - button = group.checkedButton() - if button is not None: - group.setExclusive(False) - button.setChecked(False) - group.setExclusive(True) +def variable_key(var: ContinuousVariable) -> KeyType: + """Key for that variable in var_hints and discretized_vars""" + return var.name, isinstance(var, TimeVariable) -class DiscDelegate(QStyledItemDelegate): - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - state = index.data(Qt.UserRole) - var = index.data(Qt.EditRole) - if state is not None: - if isinstance(var, Variable): - fmt = var.repr_val - else: - fmt = str - extra = self.cutsText(state, fmt) - option.text = option.text + ": " + extra +class ListViewSearch(listview.ListViewSearch): + """ + A list view with two components shown above it: + - a listview containing a single item representing default settings + - a filter for search - @staticmethod - def cutsText(state: DState, fmt: Callable[[Any], str] = str): - # This function has many branches, but they don't hurt readabability - # pylint: disable=too-many-branches - method = state.method - # Need a better way to distinguish discretization states - # i.e. between 'induced no points v.s. 'removed by choice' - if state.points is None and state.disc_var is not None: - points = "" - elif state.points is None: - points = "..." - elif state.points == []: - points = "" + The class is based on listview.ListViewSearch and needs to have the same + name in order to override its private method __layout. + + Inherited __init__ calls __layout, so `default_view` must be constructed + there. Construction before calling super().__init__ doesn't work because + PyQt does not allow it. + """ + class DiscDelegate(QStyledItemDelegate): + """ + A delegate that shows items (variables) with specific settings in bold + """ + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + option.font.setBold(index.data(Qt.UserRole).hint is not None) + + def __init__(self, *args, **kwargs): + self.default_view = None + super().__init__(preferred_size=QSize(350, -1), *args, **kwargs) + self.setItemDelegate(self.DiscDelegate(self)) + + def select_default(self): + """Select the item representing default settings""" + index = self.default_view.model().index(0) + self.default_view.selectionModel().select( + index, QItemSelectionModel.Select) + + # pylint: disable=unused-private-member + def __layout(self): + if self.default_view is None: # __layout was called from __init__ + view = self.default_view = QListView(self) + view.setModel(DefaultDiscModel()) + view.verticalScrollBar().setDisabled(True) + view.horizontalScrollBar().setDisabled(True) + view.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + view.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + font = view.font() + font.setBold(True) + view.setFont(font) else: - points = ", ".join(map(fmt, state.points)) - - if isinstance(method, Default): - name = None - elif isinstance(method, Leave): - name = "(leave)" - elif isinstance(method, MDL): - name = "(entropy)" - elif isinstance(method, EqualFreq): - name = "(equal frequency k={})".format(method.k) - elif isinstance(method, EqualWidth): - name = "(equal width k={})".format(method.k) - elif isinstance(method, Remove): - name = "(removed)" - elif isinstance(method, Custom): - name = "(custom)" + view = self.default_view + + # Put the list view with default on top + margins = self.viewportMargins() + def_height = view.sizeHintForRow(0) + 2 * view.spacing() + 2 + view.setGeometry(0, 0, self.geometry().width(), def_height) + view.setFixedHeight(def_height) + + # Then search + search = self.__search + src_height = search.sizeHint().height() + size = self.size() + search.setGeometry(0, def_height + 2, size.width(), src_height) + + # Then the real list view + margins.setTop(def_height + 2 + src_height) + self.setViewportMargins(margins) + + +def format_desc(hint: VarHint) -> str: + """Describe the method and its parameters; used in list views and report""" + if hint is None: + return Options[Methods.Default].short_desc + desc = Options[hint.method_id].short_desc + if hint.method_id == Methods.FixedWidthTime: + width, unit = hint.args + try: + width = int(width) + except ValueError: + unit = f"{time_units[unit]}(s)" else: - assert False + unit = f"{pl(width, time_units[unit])}" + return desc.format(width, unit) + return desc.format(*hint.args) - if name is not None: - return points + " " + name - else: - return points +class DiscDomainModel(DomainModel): + """ + Domain model that adds description of discretization methods and thresholds + + Also provides a tooltip that shows bins, that is, labels of the discretized + variable. + """ + def data(self, index, role=Qt.DisplayRole): + if role == Qt.ToolTipRole: + var = self[index.row()] + data = index.data(Qt.UserRole) + if not isinstance(data, DiscDesc): + return super().data(index, role) + tip = f"{var.name}: " + values = map(html.escape, data.values) + if not data.values: + return None + if len(data.values) <= 3: + return f'

    {tip}' \ + f'{",  ".join(values)}

    ' + else: + return tip + "
    " \ + + "".join(f"- {value}
    " for value in values) + value = super().data(index, role) + if role == Qt.DisplayRole: + try: + hint, points, values = index.data(Qt.UserRole) + except TypeError: + pass # don't have user role (yet) + else: + value += ": " + format_desc(hint) + if points: + value += " " + points + return value -#: Discretization methods -class Methods(IntEnum): - Default, Leave, MDL, EqualFreq, EqualWidth, Remove, Custom = range(7) - @staticmethod - def from_method(method): - return Methods[type(method).__name__] +class DefaultDiscModel(QAbstractListModel): + """ + A model used for showing "General preset" above the list view with var + """ + icon = None + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if DefaultDiscModel.icon is None: + DefaultDiscModel.icon = gui.createAttributePixmap( + "★", QColor(0, 0, 0, 0), Qt.black) + self.hint: VarHint = DefaultHint -def parse_float(string: str) -> Optional[float]: - try: - return float(string) - except ValueError: + @staticmethod + def rowCount(parent): + return 0 if parent.isValid() else 1 + + @staticmethod + def columnCount(parent): + return 0 if parent.isValid() else 1 + + def data(self, _, role=Qt.DisplayRole): + if role == Qt.DisplayRole: + return "General preset: " + format_desc(self.hint) + elif role == Qt.DecorationRole: + return DefaultDiscModel.icon + elif role == Qt.ToolTipRole: + return "Default setting for variables without specific setings" return None + def setData(self, index, value, role=Qt.DisplayRole): + if role == Qt.UserRole: + self.hint = value + self.dataChanged.emit(index, index) + class IncreasingNumbersListValidator(QValidator): """ - Match a comma separated list of non-empty and increasing number strings. - - Example - ------- - >>> v = IncreasingNumbersListValidator() - >>> v.validate("", 0) # Acceptable - (2, '', 0) - >>> v.validate("1", 1) # Acceptable - (2, '1', 1) - >>> v.validate("1,,", 1) # Intermediate - (1, '1,,', 1) + A validator for custom thresholds + + Requires a string with increasing comma-separated values. If the string + ends with number followed by space, it inserts a comma. """ @staticmethod - def itersplit(string: str) -> Iterable[Tuple[int, int]]: - sepiter = re.finditer(r"(? Tuple[QValidator.State, str, int]: - state = QValidator.Acceptable - # Matches non-complete intermediate numbers (while editing) - intermediate = re.compile(r"([+-]?\s?\d*\s?\d*\.?\d*\s?\d*)") - values = [] - for start, end in self.itersplit(string): - valuestr = string[start:end].strip() - if not valuestr: - # Middle element is empty (will be fixed by fixup) - continue - value = parse_float(valuestr) - if value is None: - if intermediate.fullmatch(valuestr): - state = min(state, QValidator.Intermediate) - continue - return QValidator.Invalid, string, pos - if values and value <= values[-1]: - state = min(state, QValidator.Intermediate) - else: - values.append(value) - return state, string, pos + def validate(string: str, pos: int) -> Tuple[QValidator.State, str, int]: + for i, c in enumerate(string, start=1): + if c not in "+-., 0123456789": + return QValidator.Invalid, string, i + prev = None + if pos == len(string) >= 2 \ + and string[-1] == " " and string[-2].isdigit(): + string = string[:-1] + ", " + pos += 1 + for valuestr in re_custom_sep.split(string.strip()): + try: + value = float(valuestr) + except ValueError: + return QValidator.Intermediate, string, pos + if prev is not None and value <= prev: + return QValidator.Intermediate, string, pos + prev = value + return QValidator.Acceptable, string, pos - def fixup(self, string): - # type: (str) -> str - """ - Fixup the input. Remove empty parts from the string. - """ - parts = [string[start: end] for start, end in self.itersplit(string)] - parts = [part for part in parts if part.strip()] - return ", ".join(parts) - - -def show_tip( - widget: QWidget, pos: QPoint, text: str, timeout=-1, - textFormat=Qt.AutoText, wordWrap=None -): - propname = __name__ + "::show_tip_qlabel" - if timeout < 0: - timeout = widget.toolTipDuration() - if timeout < 0: - timeout = 5000 + 40 * max(0, len(text) - 100) - tip = widget.property(propname) - if not text and tip is None: - return - - def hide(): - w = tip.parent() - w.setProperty(propname, None) - tip.timer.stop() - tip.close() - tip.deleteLater() - - if not isinstance(tip, QLabel): - tip = QLabel(objectName="tip-label", focusPolicy=Qt.NoFocus) - tip.setBackgroundRole(QPalette.ToolTipBase) - tip.setForegroundRole(QPalette.ToolTipText) - tip.setPalette(QToolTip.palette()) - tip.setFont(QApplication.font("QTipLabel")) - tip.timer = QTimer(tip, singleShot=True, objectName="hide-timer") - tip.timer.timeout.connect(hide) - widget.setProperty(propname, tip) - tip.setParent(widget, Qt.ToolTip) - - tip.setText(text) - tip.setTextFormat(textFormat) - if wordWrap is None: - wordWrap = textFormat != Qt.PlainText - tip.setWordWrap(wordWrap) - - if not text: - hide() - else: - tip.timer.start(timeout) - tip.show() - tip.move(pos) + @staticmethod + def show_tip( + widget: QWidget, pos: QPoint, text: str, timeout=-1, + textFormat=Qt.AutoText, wordWrap=None): + """Show a tooltip; used for invalid custom thresholds""" + propname = __name__ + "::show_tip_qlabel" + if timeout < 0: + timeout = widget.toolTipDuration() + if timeout < 0: + timeout = 5000 + 40 * max(0, len(text) - 100) + tip = widget.property(propname) + if not text and tip is None: + return + + def hide(): + w = tip.parent() + w.setProperty(propname, None) + tip.timer.stop() + tip.close() + tip.deleteLater() + + if not isinstance(tip, QLabel): + tip = QLabel(objectName="tip-label", focusPolicy=Qt.NoFocus) + tip.setBackgroundRole(QPalette.ToolTipBase) + tip.setForegroundRole(QPalette.ToolTipText) + tip.setPalette(QToolTip.palette()) + tip.setFont(QApplication.font("QTipLabel")) + tip.setContentsMargins(2, 2, 2, 2) + tip.timer = QTimer(tip, singleShot=True, objectName="hide-timer") + tip.timer.timeout.connect(hide) + widget.setProperty(propname, tip) + tip.setParent(widget, Qt.ToolTip) + + tip.setText(text) + tip.setTextFormat(textFormat) + if wordWrap is None: + wordWrap = textFormat != Qt.PlainText + tip.setWordWrap(wordWrap) + + if not text: + hide() + else: + tip.timer.start(timeout) + tip.show() + tip.move(pos) + + +# These are no longer used, but needed for loading and migrating old pickles. +# We insert them into namespace instead of normally defining them, in order +# to hide it from IDE's and avoid mistakenly using them. +# pylint: disable=wrong-import-position,wrong-import-order +from collections import namedtuple +globals().update(dict( + DState=namedtuple( + "DState", + ["method", # discretization method + "points", # induced cut points + "disc_var"], + defaults=(None, None) # induced discretized variable + ), + Default=namedtuple("Default", ["method"]), + Leave=namedtuple("Leave", []), + MDL=namedtuple("MDL", []), + EqualFreq=namedtuple("EqualFreq", ["k"]), + EqualWidth=namedtuple("EqualWidth", ["k"]), + Remove=namedtuple("Remove", []), + Custom=namedtuple("Custom", ["points"]) +)) class OWDiscretize(widget.OWWidget): # pylint: disable=too-many-instance-attributes name = "Discretize" - description = "Discretize the numeric data features." + description = "Discretize numeric variables" + category = "Transform" icon = "icons/Discretize.svg" - keywords = ["bin", "categorical", "nominal", "ordinal"] + keywords = "discretize, bin, categorical, nominal, ordinal" + priority = 2130 class Inputs: - data = Input("Data", Orange.data.Table, doc="Input data table") + data = Input("Data", Table, doc="Input data table") class Outputs: - data = Output("Data", Orange.data.Table, doc="Table with discretized features") - - settingsHandler = settings.DomainContextHandler() - settings_version = 2 - saved_var_states = settings.ContextSetting({}) + data = Output("Data", Table, doc="Table with categorical features") - #: The default method name - default_method_name = settings.Setting(Methods.EqualFreq.name) - #: The k for Equal{Freq,Width} - default_k = settings.Setting(3) - #: The default cut points for custom entry - default_cutpoints: Tuple[float, ...] = settings.Setting(()) - autosend = settings.Setting(True) + settings_version = 3 - #: Discretization methods - Default, Leave, MDL, EqualFreq, EqualWidth, Remove, Custom = list(Methods) + #: Default setting (key DefaultKey) and specific settings for variables; + # if variable is not in the dict, it uses default + var_hints: Dict[KeyType, VarHint] = Setting( + {DefaultKey: DefaultHint}, schema_only=True) + autosend = Setting(True) want_main_area = False - resizing_enabled = False def __init__(self): super().__init__() #: input data self.data = None - self.class_var = None - #: Current variable discretization state - self.var_state = {} - #: Saved variable discretization settings (context setting) - self.saved_var_states = {} - - self.method = Methods.Default - self.k = 5 - self.cutpoints = () - - box = gui.vBox(self.controlArea, self.tr("Default Discretization")) - self._default_method_ = 0 - self.default_bbox = rbox = gui.radioButtons( - box, self, "_default_method_", callback=self._default_disc_changed) - self.default_button_group = bg = rbox.findChild(QButtonGroup) - bg.buttonClicked[int].connect(self.set_default_method) - - rb = gui.hBox(rbox) - self.left = gui.vBox(rb) - right = gui.vBox(rb) - rb.layout().setStretch(0, 1) - rb.layout().setStretch(1, 1) - self.options = [ - (Methods.Default, self.tr("Default")), - (Methods.Leave, self.tr("Leave numeric")), - (Methods.MDL, self.tr("Entropy-MDL discretization")), - (Methods.EqualFreq, self.tr("Equal-frequency discretization")), - (Methods.EqualWidth, self.tr("Equal-width discretization")), - (Methods.Remove, self.tr("Remove numeric variables")), - (Methods.Custom, self.tr("Manual")), - ] - - for id_, opt in self.options[1:]: - t = gui.appendRadioButton(rbox, opt) - bg.setId(t, id_) - t.setChecked(id_ == self.default_method) - [right, self.left][opt.startswith("Equal")].layout().addWidget(t) - - def _intbox(parent, attr, callback): - box = gui.indentedBox(parent) - s = gui.spin( - box, self, attr, minv=2, maxv=10, label="Num. of intervals:", - callback=callback) - s.setMaximumWidth(60) + #: Cached discretized variables + self.discretized_vars: Dict[KeyType, DiscreteVariable] = {} + + # Indicates that buttons, spins, edit and combos are being changed + # programmatically (when interface is changed due to selection change), + # so this should not trigger update of hints and invalidation of + # discretization in `self.discretized_vars`. + self.__interface_update = False + + box = gui.hBox(self.controlArea, True, spacing=8) + self._create_var_list(box) + self._create_buttons(box) + gui.auto_apply(self.buttonsArea, self, "autosend") + gui.rubber(self.buttonsArea) + self.varview.select_default() + + def _create_var_list(self, box): + """Create list view with variables""" + # If we decide to not elide, remove the `uniformItemSize` argument + self.varview = ListViewSearch( + selectionMode=QListView.ExtendedSelection, uniformItemSizes=True) + self.varview.setModel( + DiscDomainModel( + valid_types=(ContinuousVariable, TimeVariable), + order=DiscDomainModel.MIXED + )) + self.varview.selectionModel().selectionChanged.connect( + self._var_selection_changed) + self.varview.default_view.selectionModel().selectionChanged.connect( + self._default_selected) + self._update_default_model() + box.layout().addWidget(self.varview) + + def _create_buttons(self, box): + """Create radio buttons""" + def intspin(): + s = QSpinBox(self) + s.setMinimum(2) + s.setMaximum(10) + s.setFixedWidth(60) s.setAlignment(Qt.AlignRight) - gui.rubber(s.box) - return box.box + s.setContentsMargins(0, 0, 0, 0) + return s, s.valueChanged - self.k_general = _intbox(self.left, "default_k", - self._default_disc_changed) - self.k_general.layout().setContentsMargins(0, 0, 0, 0) + def widthline(validator): + s = QLineEdit(self) + s.setFixedWidth(60) + s.setAlignment(Qt.AlignRight) + s.setValidator(validator) + s.setContentsMargins(0, 0, 0, 0) + return s, s.textChanged def manual_cut_editline(text="", enabled=True) -> QLineEdit: edit = QLineEdit( text=text, placeholderText="e.g. 0.0, 0.5, 1.0", - toolTip="Enter fixed discretization cut points (a comma " - "separated list of strictly increasing numbers e.g. " - "0.0, 0.5, 1.0).", + toolTip='

    ' + + 'Enter cut points as a comma-separate list of \n' + 'strictly increasing numbers e.g. 0.0, 0.5, 1.0).

    ', enabled=enabled, ) + edit.setValidator(IncreasingNumbersListValidator()) + edit.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + @edit.textChanged.connect def update(): validator = edit.validator() - if validator is not None: + if validator is not None and edit.text().strip(): state, _, _ = validator.validate(edit.text(), 0) else: state = QValidator.Acceptable @@ -370,431 +604,428 @@ def update(): p = edit.mapToGlobal(cr.bottomRight()) edit.setPalette(palette) if state != QValidator.Acceptable and edit.isVisible(): - show_tip(edit, p, edit.toolTip(), textFormat=Qt.RichText) + validator.show_tip(edit, p, edit.toolTip(), + textFormat=Qt.RichText) else: - show_tip(edit, p, "") - return edit - - self.manual_cuts_edit = manual_cut_editline( - text=", ".join(map(str, self.default_cutpoints)), - enabled=self.default_method == Methods.Custom, - ) - - def set_manual_default_cuts(): - text = self.manual_cuts_edit.text() - self.default_cutpoints = tuple( - float(s.strip()) for s in text.split(",") if s.strip()) - self._default_disc_changed() - self.manual_cuts_edit.editingFinished.connect(set_manual_default_cuts) - - validator = IncreasingNumbersListValidator() - self.manual_cuts_edit.setValidator(validator) - ibox = gui.indentedBox(right, orientation=Qt.Horizontal) - ibox.layout().addWidget(self.manual_cuts_edit) - - right.layout().addStretch(10) - self.left.layout().addStretch(10) - - self.connect_control( - "default_cutpoints", - lambda values: self.manual_cuts_edit.setText(", ".join(map(str, values))) - ) - vlayout = QHBoxLayout() - box = gui.widgetBox( - self.controlArea, "Individual Attribute Settings", - orientation=vlayout, spacing=8 - ) - - # List view with all attributes - self.varview = ListViewSearch( - selectionMode=QListView.ExtendedSelection, - uniformItemSizes=True, - ) - self.varview.setItemDelegate(DiscDelegate()) - self.varmodel = itemmodels.VariableListModel() - self.varview.setModel(self.varmodel) - self.varview.selectionModel().selectionChanged.connect( - self._var_selection_changed - ) - - vlayout.addWidget(self.varview) - # Controls for individual attr settings - self.bbox = controlbox = gui.radioButtons( - box, self, "method", callback=self._disc_method_changed - ) - vlayout.addWidget(controlbox) - self.variable_button_group = bg = controlbox.findChild(QButtonGroup) - for id_, opt in self.options[:5]: - b = gui.appendRadioButton(controlbox, opt) - bg.setId(b, id_) - - self.k_specific = _intbox(controlbox, "k", self._disc_method_changed) - - gui.appendRadioButton(controlbox, "Remove attribute", id=Methods.Remove) - b = gui.appendRadioButton(controlbox, "Manual", id=Methods.Custom) - - self.manual_cuts_specific = manual_cut_editline( - text=", ".join(map(str, self.cutpoints)), - enabled=self.method == Methods.Custom - ) - self.manual_cuts_specific.setValidator(validator) - b.toggled[bool].connect(self.manual_cuts_specific.setEnabled) - - def set_manual_cuts(): - text = self.manual_cuts_specific.text() - points = [t for t in text.split(",") if t.split()] - self.cutpoints = tuple(float(t) for t in points) - self._disc_method_changed() - self.manual_cuts_specific.editingFinished.connect(set_manual_cuts) - - self.connect_control( - "cutpoints", - lambda values: self.manual_cuts_specific.setText(", ".join(map(str, values))) - ) - ibox = gui.indentedBox(controlbox, orientation=Qt.Horizontal) - self.copy_current_to_manual_button = b = FixedSizeButton( - text="CC", toolTip="Copy the current cut points to manual mode", - enabled=False - ) - b.clicked.connect(self._copy_to_manual) - ibox.layout().addWidget(self.manual_cuts_specific) - ibox.layout().addWidget(b) - - gui.rubber(controlbox) - controlbox.setEnabled(False) - bg.button(self.method) - self.controlbox = controlbox + validator.show_tip(edit, p, "") + return edit, edit.textChanged + + children = [] + + def button(id_, *controls, stretch=True): + layout = QHBoxLayout() + desc = Options[id_] + button = QRadioButton(desc.label) + button.setToolTip(desc.tooltip) + self.button_group.addButton(button, id_) + layout.addWidget(button) + if controls: + if stretch: + layout.addStretch(1) + for c, signal in controls: + layout.addWidget(c) + if signal is not None: + @signal.connect + def arg_changed(): + self.button_group.button(id_).setChecked(True) + self.update_hints(id_) + + children.append(layout) + button_box.layout().addLayout(layout) + return (*controls, (None, ))[0][0] + + button_box = gui.vBox(box) + button_box.layout().setSpacing(0) + button_box.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)) + self.button_group = QButtonGroup(self) + self.button_group.idClicked.connect(self.update_hints) + + button(Methods.Default) + button(Methods.Keep) + button(Methods.Remove) + + self.binning_spin = button(Methods.Binning, intspin()) + validator = QDoubleValidator() + validator.setBottom(0) + self.width_line = button(Methods.FixedWidth, widthline(validator)) + + self.width_time_unit = u = QComboBox(self) + u.setContentsMargins(0, 0, 0, 0) + u.addItems([f"{unit}(s)" for unit in time_units]) + validator = QIntValidator() + validator.setBottom(1) + self.width_time_line = button(Methods.FixedWidthTime, + widthline(validator), + (u, u.currentTextChanged)) + + self.freq_spin = button(Methods.EqualFreq, intspin()) + self.width_spin = button(Methods.EqualWidth, intspin()) + button(Methods.MDL) + + self.copy_to_custom = FixedSizeButton( + text="CC", toolTip="Copy the current cut points to manual mode") + self.copy_to_custom.clicked.connect(self._copy_to_manual) + self.threshold_line = button(Methods.Custom, + manual_cut_editline(), + (self.copy_to_custom, None), + stretch=False) + # Increase the height of smaller items to make the spacing look more + # uniform. Setting the same height for all make it look too large. + heights = [w.sizeHint().height() for w in children] + maxheight = max(heights) + midheight = (min(heights) + maxheight) // 2 + for widg, h in zip(children, heights): + widg.itemAt(0).widget().setFixedHeight(max(h, midheight)) + button_box.layout().addStretch(1) + + def _update_default_model(self): + """Update data in the model showing default settings""" + model = self.varview.default_view.model() + model.setData(model.index(0), self.var_hints[DefaultKey], Qt.UserRole) + + def _set_mdl_button(self): + """Disable MDL discretization for data with non-discrete class""" + mdl_button = self.button_group.button(Methods.MDL) + if self.data is None or self.data.domain.has_discrete_class: + mdl_button.setEnabled(True) + else: + if mdl_button.isChecked(): + self._check_button(Methods.Keep, True) + mdl_button.setEnabled(False) + + def _check_button(self, method_id: Methods, checked: bool): + """Checks the given button""" + self.button_group.button(method_id).setChecked(checked) + + def _uncheck_all_buttons(self): + """Uncheck all radio buttons""" + group = self.button_group + button = group.checkedButton() + if button is not None: + group.setExclusive(False) + button.setChecked(False) + group.setExclusive(True) + + def _set_radio_enabled(self, method_id: Methods, value: bool): + """Enable/disable radio button and related controls""" + if self.button_group.button(method_id).isChecked() and not value: + self._uncheck_all_buttons() + self.button_group.button(method_id).setEnabled(value) + for control_name in Options[method_id].controls: + getattr(self, control_name).setEnabled(value) + + def _get_values(self, method_id: Methods) -> Tuple[Union[int, float, str]]: + """Return parameters from controls pertaining to the given method""" + controls = Options[method_id].controls + values = [] + for control_name in controls: + control = getattr(self, control_name) + if isinstance(control, QSpinBox): + values.append(control.value()) + elif isinstance(control, QComboBox): + values.append(control.currentIndex()) + else: + values.append(control.text()) + return tuple(values) - gui.auto_apply(self.buttonsArea, self, "autosend") + def _set_values(self, method_id: Methods, + values: Tuple[Union[str, int, float]]): + """ + Set controls pertaining to the given method to parameters from hint + """ + controls = Options[method_id].controls + for control_name, value in zip(controls, values): + control = getattr(self, control_name) + if isinstance(control, QSpinBox): + control.setValue(value) + elif isinstance(control, QComboBox): + control.setCurrentIndex(value) + else: + control.setText(value) - self._update_spin_positions() + def varkeys_for_selection(self) -> List[KeyType]: + """ + Return list of KeyType's for selected variables (for indexing var_hints) - @property - def default_method(self) -> Methods: - return Methods[self.default_method_name] + If 'Default settings' are selected, this returns DefaultKey + """ + model = self.varview.model() + varkeys = [variable_key(model[index.row()]) + for index in self.varview.selectionModel().selectedRows()] + return varkeys or [DefaultKey] # default settings are selected - @default_method.setter - def default_method(self, method): - self.set_default_method(method) + def update_hints(self, method_id: Methods): + """ + Callback for radio buttons and for controls regulating parameters - def set_default_method(self, method: Methods): - if isinstance(method, int): - method = Methods(method) - else: - method = Methods.from_method(method) + This function: + - updates `var_hints` for all selected methods + - invalidates (removes) `discretized_vars` for affected variables + - calls _update_discretizations to compute and commit new discretization + - calls deferred commit - if method != self.default_method: - self.default_method_name = method.name - self.default_button_group.button(method).setChecked(True) - self._default_disc_changed() - self.manual_cuts_edit.setEnabled(method == Methods.Custom) + Data for list view models is updated in _update_discretizations + """ + if self.__interface_update: + return - @Inputs.data - def set_data(self, data): - self.closeContext() - self.data = data - if self.data is not None: - self._initialize(data) - self.openContext(data) - # Restore the per variable discretization settings - self._restore(self.saved_var_states) - # Complete the induction of cut points - self._update_points() + method_id = Methods(method_id) + args = self._get_values(method_id) + keys = self.varkeys_for_selection() + if method_id == Methods.Default: + for key in keys: + if key in self.var_hints: + del self.var_hints[key] else: - self._clear() - self.unconditional_commit() - - def _initialize(self, data): - # Initialize the default variable states for new data. - self.class_var = data.domain.class_var - cvars = [var for var in data.domain.variables - if var.is_continuous] - self.varmodel[:] = cvars - - has_disc_class = data.domain.has_discrete_class - - def set_enabled(box: QWidget, id_: Methods, state: bool): - bg = box.findChild(QButtonGroup) - b = bg.button(id_) - b.setEnabled(state) - - set_enabled(self.default_bbox, self.MDL, has_disc_class) - bg = self.bbox.findChild(QButtonGroup) - b = bg.button(Methods.MDL) - b.setEnabled(has_disc_class) - set_enabled(self.bbox, self.MDL, has_disc_class) - - # If the newly disabled MDL button is checked then change it - if not has_disc_class and self.default_method == self.MDL: - self.default_method = Methods.Leave - if not has_disc_class and self.method == self.MDL: - self.method = Methods.Default - - # Reset (initialize) the variable discretization states. - self._reset() - - def _restore(self, saved_state): - # Restore variable states from a saved_state dictionary. - def_method = self._current_default_method() - for i, var in enumerate(self.varmodel): - key = variable_key(var) - if key in saved_state: - state = saved_state[key] - if isinstance(state.method, Default): - state = DState(Default(def_method), None, None) - self._set_var_state(i, state) - - def _reset(self): - # restore the individual variable settings back to defaults. - def_method = self._current_default_method() - self.var_state = {} - for i in range(len(self.varmodel)): - state = DState(Default(def_method), None, None) - self._set_var_state(i, state) - - def _set_var_state(self, index, state): - # set the state of variable at `index` to `state`. - self.var_state[index] = state - self.varmodel.setData(self.varmodel.index(index), state, Qt.UserRole) - - def _clear(self): - self.data = None - self.varmodel[:] = [] - self.var_state = {} - self.saved_var_states = {} - self.default_button_group.button(self.MDL).setEnabled(True) - self.variable_button_group.button(self.MDL).setEnabled(True) + self.var_hints.update(dict.fromkeys(keys, VarHint(method_id, args))) + if keys == [DefaultKey]: + invalidate = set(self.discretized_vars) - set(self.var_hints) + else: + invalidate = keys + for key in invalidate: + del self.discretized_vars[key] - def _update_points(self): + if keys == [DefaultKey]: + self._update_default_model() + self._update_discretizations() + self.commit.deferred() + + def _update_discretizations(self): """ - Update the induced cut points. + Compute invalidated (missing) discretizations + + Also set data for list view models for all invalidated variables """ if self.data is None: return - def induce_cuts(method, data, var): - dvar = _dispatch[type(method)](method, data, var) - if dvar is None: - # removed - return [], None - elif dvar is var: - # no transformation took place - return None, var - elif is_discretized(dvar): - return dvar.compute_value.points, dvar - raise ValueError - - for i, var in enumerate(self.varmodel): - state = self.var_state[i] - if state.points is None and state.disc_var is None: - points, dvar = induce_cuts(state.method, self.data, var) - new_state = state._replace(points=points, disc_var=dvar) - self._set_var_state(i, new_state) - - def _current_default_method(self): - method = self.default_method - k = self.default_k - if method == Methods.Leave: - def_method = Leave() - elif method == Methods.MDL: - def_method = MDL() - elif method == Methods.EqualFreq: - def_method = EqualFreq(k) - elif method == Methods.EqualWidth: - def_method = EqualWidth(k) - elif method == Methods.Remove: - def_method = Remove() - elif method == Methods.Custom: - def_method = Custom(self.default_cutpoints) - else: - assert False - return def_method - - def _current_method(self): - if self.method == Methods.Default: - method = Default(self._current_default_method()) - elif self.method == Methods.Leave: - method = Leave() - elif self.method == Methods.MDL: - method = MDL() - elif self.method == Methods.EqualFreq: - method = EqualFreq(self.k) - elif self.method == Methods.EqualWidth: - method = EqualWidth(self.k) - elif self.method == Methods.Remove: - method = Remove() - elif self.method == Methods.Custom: - method = Custom(self.cutpoints) + default_hint = self.var_hints[DefaultKey] + model = self.varview.model() + for index, var in enumerate(model): + key = variable_key(var) + if key in self.discretized_vars: + continue # still valid + var_hint = self.var_hints.get(key) + points, dvar = self._discretize_var(var, var_hint or default_hint) + self.discretized_vars[key] = dvar + values = getattr(dvar, "values", ()) + model.setData(model.index(index), + DiscDesc(var_hint, points, values), + Qt.UserRole) + + def _discretize_var(self, var: ContinuousVariable, hint: VarHint) \ + -> Tuple[str, Optional[Variable]]: + """ + Discretize using method and data in the hint. + + Returns a description (list of points or error/warning) and a + - discrete variable + - same variable (if kept numeric) + - None (if removed or errored) + """ + if isinstance(var, TimeVariable): + if hint.method_id in (Methods.FixedWidth, Methods.Custom): + return ": ", var else: - assert False - return method - - def _update_spin_positions(self): - kmethods = [Methods.EqualFreq, Methods.EqualWidth] - self.k_general.setDisabled(self.default_method not in kmethods) - if self.default_method == Methods.EqualFreq: - self.left.layout().insertWidget(1, self.k_general) - elif self.default_method == Methods.EqualWidth: - self.left.layout().insertWidget(2, self.k_general) - - self.k_specific.setDisabled(self.method not in kmethods) - if self.method == Methods.EqualFreq: - self.bbox.layout().insertWidget(4, self.k_specific) - elif self.method == Methods.EqualWidth: - self.bbox.layout().insertWidget(5, self.k_specific) - - def _default_disc_changed(self): - self._update_spin_positions() - method = self._current_default_method() - state = DState(Default(method), None, None) - for i, _ in enumerate(self.varmodel): - if isinstance(self.var_state[i].method, Default): - self._set_var_state(i, state) - self._update_points() - self.commit() - - def _disc_method_changed(self): - self._update_spin_positions() - indices = self.selected_indices() - method = self._current_method() - state = DState(method, None, None) - for idx in indices: - self._set_var_state(idx, state) - self._update_points() - self._copy_to_manual_update_enabled() - self.commit() + if hint.method_id == Methods.FixedWidthTime: + return ": ", var + + function = Options[hint.method_id].function + dvar = function(self.data, var, *hint.args) + if isinstance(dvar, str): + return f" <{dvar}>", None # error + if dvar is None: + return "", None # removed + elif dvar is var: + return "", var # no transformation + thresholds = dvar.compute_value.points + if len(thresholds) == 0: + return " ", None + return "(" + ", ".join(map(var.repr_val, thresholds))+ ")", dvar def _copy_to_manual(self): - indices = self.selected_indices() - # set of all methods for the current selection - if len(indices) != 1: - return - index = indices[0] - state = self.var_state[index] - var = self.varmodel[index] - fmt = var.repr_val - points = state.points - if points is None: - points = () - else: - points = tuple(state.points) - state = state._replace(method=Custom(points), points=None, disc_var=None) - self._set_var_state(index, state) - self.method = Methods.Custom - self.cutpoints = points - self.manual_cuts_specific.setText(", ".join(map(fmt, points))) - self._update_points() - self.commit() - - def _copy_to_manual_update_enabled(self): - indices = self.selected_indices() - methods = [self.var_state[i].method for i in indices] - self.copy_current_to_manual_button.setEnabled( - len(indices) == 1 and not isinstance(methods[0], Custom)) - - def _var_selection_changed(self, *_): - self._copy_to_manual_update_enabled() - indices = self.selected_indices() - # set of all methods for the current selection - methods = [self.var_state[i].method for i in indices] - - def key(method): - if isinstance(method, Default): - return Default, (None, ) - return type(method), tuple(method) - - mset = list(unique_everseen(methods, key=key)) - - self.controlbox.setEnabled(len(mset) > 0) - if len(mset) == 1: - method = mset.pop() - self.method = Methods.from_method(method) - if isinstance(method, (EqualFreq, EqualWidth)): - self.k = method.k - elif isinstance(method, Custom): - self.cutpoints = method.points - else: - # deselect the current button - self.method = -1 - bg = self.controlbox.group - button_group_reset(bg) - self._update_spin_positions() - - def selected_indices(self): - rows = self.varview.selectionModel().selectedRows() - return [index.row() for index in rows] - - def method_for_index(self, index): - state = self.var_state[index] - return state.method - - def discretized_var(self, index): - # type: (int) -> Optional[Orange.data.DiscreteVariable] - state = self.var_state[index] - if state.disc_var is not None and state.points == []: - # Removed by MDL Entropy - return None - else: - return state.disc_var + """ + Callback for 'CC' button + + Sets selected variables' method to "Custom" and copies thresholds + to their VarHints. Variables that are not discretized (for any reason) + are skipped. + + Discretizations are invalidated and then updated + (`_update_discretizations`). - def discretized_domain(self): + If all selected variables have the same thresholds, it copies it to + the line edit. Otherwise it unchecks all radio buttons to keep the + interface consistent. """ - Return the current effective discretized domain. + varkeys = self.varkeys_for_selection() + texts = set() + for key in varkeys: + dvar = self.discretized_vars.get(key) + fmt = self.data.domain[key[0]].repr_val + if isinstance(dvar, DiscreteVariable): + text = ", ".join(map(fmt, dvar.compute_value.points)) + texts.add(text) + self.var_hints[key] = VarHint(Methods.Custom, (text, )) + del self.discretized_vars[key] + try: + self.__interface_update = True + if len(texts) == 1: + self.threshold_line.setText(texts.pop()) + else: + self._uncheck_all_buttons() + finally: + self.__interface_update = False + self._update_discretizations() + self.commit.deferred() + + def _default_selected(self, selected): + """Callback for selecting 'Default setting'""" + if not selected: + # Prevent infinite recursion (with _var_selection_changed) + return + self.varview.selectionModel().clearSelection() + self._update_interface() + + set_enabled = self._set_radio_enabled + set_enabled(Methods.Default, False) + set_enabled(Methods.FixedWidth, True) + set_enabled(Methods.FixedWidthTime, True) + set_enabled(Methods.Custom, True) + self.copy_to_custom.setEnabled(False) + + def _var_selection_changed(self, _): + """Callback for changed selection in listview with variables""" + selected = self.varview.selectionModel().selectedIndexes() + if not selected: + # Prevent infinite recursion (with _default_selected) + return + self.varview.default_view.selectionModel().clearSelection() + self._update_interface() + + set_enabled = self._set_radio_enabled + vars_ = [self.data.domain[name] + for name, _ in self.varkeys_for_selection()] + no_time = not any(isinstance(var, TimeVariable) for var in vars_) + all_time = all(isinstance(var, TimeVariable) for var in vars_) + set_enabled(Methods.Default, True) + set_enabled(Methods.FixedWidth, no_time) + set_enabled(Methods.Custom, no_time) + self.copy_to_custom.setEnabled(no_time) + set_enabled(Methods.FixedWidthTime, all_time) + + def _update_interface(self): """ - if self.data is None: - return None + Update the user interface according to selection - # a mapping of all applied changes for variables in `varmodel` - mapping = {var: self.discretized_var(i) - for i, var in enumerate(self.varmodel)} + - If VarHints for all selected variables are the same, check the + corresponding radio button and fill the corresponding controls; + - otherwise, uncheck all radios. + """ + if self.__interface_update: + return - def disc_var(source): - return mapping.get(source, source) + try: + self.__interface_update = True + keys = self.varkeys_for_selection() + mset = list(unique_everseen(map(self.var_hints.get, keys))) + if len(mset) != 1: + self._uncheck_all_buttons() + return - # map the full input domain to the new variables (where applicable) - attributes = [disc_var(v) for v in self.data.domain.attributes] - attributes = [v for v in attributes if v is not None] + if mset == [None]: + method_id, args = Methods.Default, () + else: + method_id, args = mset.pop() + self._check_button(method_id, True) + self._set_values(method_id, args) + finally: + self.__interface_update = False - class_vars = [disc_var(v) for v in self.data.domain.class_vars] - class_vars = [v for v in class_vars if v is not None] + @Inputs.data + def set_data(self, data: Optional[Table]): + self.discretized_vars = {} + self.data = data + self.varview.model().set_domain(None if data is None else data.domain) + self._update_discretizations() + self._update_default_model() + self.varview.select_default() + self._set_mdl_button() + self.commit.now() + + @gui.deferred + def commit(self): + if self.data is None: + self.Outputs.data.send(None) + return - domain = Orange.data.Domain( - attributes, class_vars, metas=self.data.domain.metas - ) - return domain + def part(variables: List[Variable]) -> List[Variable]: + return [dvar + for dvar in (self.discretized_vars.get(variable_key(v), v) + for v in variables) + if dvar] - def commit(self): - output = None - if self.data is not None: - domain = self.discretized_domain() - output = self.data.transform(domain) + d = self.data.domain + domain = Domain(part(d.attributes), part(d.class_vars), part(d.metas)) + output = self.data.transform(domain) self.Outputs.data.send(output) - def storeSpecificSettings(self): - super().storeSpecificSettings() - self.saved_var_states = { - variable_key(var): - self.var_state[i]._replace(points=None, disc_var=None) - for i, var in enumerate(self.varmodel) - } - def send_report(self): - self.report_items(( - ("Default method", self.options[self.default_method][1]),)) - if self.varmodel: - self.report_items("Thresholds", [ - (var.name, - DiscDelegate.cutsText(self.var_state[i], var.repr_val) or "leave numeric") - for i, var in enumerate(self.varmodel)]) + dmodel = self.varview.default_view.model() + desc = dmodel.data(dmodel.index(0)) + self.report_items((tuple(desc.split(": ", maxsplit=1)), )) + model = self.varview.model() + reported = [] + for row in range(model.rowCount()): + name = model[row].name + desc = model.data(model.index(row), Qt.UserRole) + if desc.hint is not None: + name = f"{name} ({format_desc(desc.hint)})" + reported.append((name, ', '.join(desc.values))) + self.report_items("Variables", reported) @classmethod - def migrate_settings(cls, settings, version): # pylint: disable=redefined-outer-name + def migrate_settings(cls, settings, version): if version is None or version < 2: # was stored as int indexing Methods (but offset by 1) default = settings.pop("default_method", 0) default = Methods(default + 1) settings["default_method_name"] = default.name + if version is None or version < 3: + method_name = settings.pop("default_method_name", + DefaultHint.method_id.name) + k = settings.pop("default_k", 3) + cut_points = settings.pop("default_cutpoints", ()) + + method_id = getattr(Methods, method_name) + if method_id in (Methods.EqualFreq, Methods.EqualWidth): + args = (k, ) + elif method_id == Methods.Custom: + args = (cut_points, ) + else: + args = () + default_hint = VarHint(method_id, args) + var_hints = {DefaultKey: default_hint} + for context in settings.pop("context_settings", []): + values = context.values + if "saved_var_states" not in values: + continue + var_states, _ = values.pop("saved_var_states") + for (tpe, name), dstate in var_states.items(): + key = (name, tpe == 4) # time variable == 4 + method = dstate.method + method_name = type(method).__name__.replace("Leave", "Keep") + if method_name == "Default": + continue + if method_name == "Custom": + args = (", ".join(f"{x:g}" for x in method.points), ) + else: + args = tuple(method) + var_hints[key] = VarHint(getattr(Methods, method_name), args) + settings["var_hints"] = var_hints + if __name__ == "__main__": # pragma: no cover - WidgetPreview(OWDiscretize).run(Orange.data.Table("brown-selected")) + #WidgetPreview(OWDiscretize).run(Table("/Users/janez/Downloads/banking-crises.tab")) + WidgetPreview(OWDiscretize).run(Table("heart_disease")) diff --git a/Orange/widgets/data/oweditdomain.py b/Orange/widgets/data/oweditdomain.py index 5f916d66663..2bf4bebda07 100644 --- a/Orange/widgets/data/oweditdomain.py +++ b/Orange/widgets/data/oweditdomain.py @@ -5,41 +5,58 @@ A widget for manual editing of a domain's attributes. """ +from __future__ import annotations + +import re import warnings from xml.sax.saxutils import escape -from itertools import zip_longest, repeat, chain -from contextlib import contextmanager +from itertools import zip_longest, repeat, chain, groupby from collections import namedtuple, Counter from functools import singledispatch, partial +from operator import itemgetter from typing import ( Tuple, List, Any, Optional, Union, Dict, Sequence, Iterable, NamedTuple, - FrozenSet, Type, Callable, TypeVar, Mapping, Hashable, cast + FrozenSet, Type, Callable, TypeVar, Mapping, Hashable, cast, Set ) import numpy as np -import pandas as pd + from AnyQt.QtWidgets import ( QWidget, QListView, QTreeView, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit, QAction, QActionGroup, QGroupBox, QStyledItemDelegate, QStyleOptionViewItem, QStyle, QSizePolicy, QDialogButtonBox, QPushButton, QCheckBox, QComboBox, QStackedLayout, - QDialog, QRadioButton, QGridLayout, QLabel, QSpinBox, QDoubleSpinBox, - QAbstractItemView, QMenu + QDialog, QRadioButton, QLabel, QSpinBox, QDoubleSpinBox, + QAbstractItemView, QMenu, QToolTip, QStackedWidget +) +from AnyQt.QtGui import ( + QStandardItemModel, QStandardItem, QKeySequence, QIcon, QBrush, QPalette, + QHelpEvent, QColor ) -from AnyQt.QtGui import QStandardItemModel, QStandardItem, QKeySequence, QIcon from AnyQt.QtCore import ( Qt, QSize, QModelIndex, QAbstractItemModel, QPersistentModelIndex, QRect, - QPoint, + QPoint, QItemSelectionModel ) from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot +from orangecanvas.gui.utils import luminance +from orangecanvas.utils import assocf, findf +from orangewidget.utils.listview import ListViewSearch + import Orange.data -from Orange.preprocess.transformation import Transformation, Identity, Lookup -from Orange.widgets import widget, gui, settings -from Orange.widgets.utils import itemmodels +from Orange.data import to_datetime +from Orange.data.io_util import first_non_natstr, guess_datetime_format +from Orange.preprocess.transformation import ( + Transformation, Identity, Lookup, MappingTransform +) +from Orange.misc.collections import DictMissingConst +from Orange.util import frompyfunc +from Orange.widgets import widget, gui +from Orange.widgets.settings import Setting +from Orange.widgets.utils import itemmodels, ftry, disconnected, unique_everseen as unique from Orange.widgets.utils.buttons import FixedSizeButton -from Orange.widgets.utils.itemmodels import signal_blocking +from Orange.widgets.utils.itemmodels import signal_blocking, create_list_model from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output @@ -47,19 +64,33 @@ MArray = np.ma.MaskedArray DType = Union[np.dtype, type] -A = TypeVar("A") # pylint: disable=invalid-name -B = TypeVar("B") # pylint: disable=invalid-name V = TypeVar("V", bound=Orange.data.Variable) # pylint: disable=invalid-name H = TypeVar("H", bound=Hashable) # pylint: disable=invalid-name - -def unique(sequence: Iterable[H]) -> Iterable[H]: - """ - Return unique elements in `sequence`, preserving their (first seen) order. - """ - # depending on Python >= 3.6 'ordered' dict implementation detail. - return iter(dict.fromkeys(sequence)) - +MAX_HINTS = 1000 +CUSTOM_TOOLTIP = """%a Weekday abbreviated name +%A Weekday full name +%w Weekday as a number (0=Sunday, 6=Saturday) +%d Day of the month (01-31) +%b Month abbreviated name +%B Month full name +%m Month as a number (01-12) +%y Year without century (00-99) +%Y Year with century +%H Hour (00-23) +%I Hour (01-12) +%p AM or PM +%M Minute (00-59) +%S Second (00-59) +%f Microsecond (000000-999999) +%z UTC offset in the form +HHMM or -HHMM +%Z Time zone name +%j Day of the year (001-366) +%U Week number of the year (Sunday as the first day of the week) +%W Week number of the year (Monday as the first day of the week) +%c Locale's appropriate date and time representation +%x Locale's appropriate date representation +%X Locale's appropriate time representation""" class _DataType: def __eq__(self, other): @@ -74,19 +105,6 @@ def __ne__(self, other): def __hash__(self): return hash((type(self), super().__hash__())) - def name_type(self): - """ - Returns a tuple with name and type of the variable. - It is used since it is forbidden to use names of variables in settings. - """ - type_number = { - "Categorical": 0, - "Real": 2, - "Time": 3, - "String": 4 - } - return self.name, type_number[type(self).__name__] - #: An ordered sequence of key, value pairs (variable annotations) AnnotationsType = Tuple[Tuple[str, str], ...] @@ -98,8 +116,7 @@ class Categorical( _DataType, NamedTuple("Categorical", [ ("name", str), ("categories", Tuple[str, ...]), - ("annotations", AnnotationsType), - ("linked", bool) + ("annotations", AnnotationsType) ])): pass @@ -108,27 +125,28 @@ class Real( ("name", str), # a precision (int, and a format specifier('f', 'g', or '') ("format", Tuple[int, str]), - ("annotations", AnnotationsType), - ("linked", bool) + ("annotations", AnnotationsType) ])): pass class String( _DataType, NamedTuple("String", [ ("name", str), - ("annotations", AnnotationsType), - ("linked", bool) + ("annotations", AnnotationsType) ])): pass class Time( _DataType, NamedTuple("Time", [ ("name", str), - ("annotations", AnnotationsType), - ("linked", bool) + ("annotations", AnnotationsType) ])): pass +class RestoreOriginal: + # Indicator type used only for UserRole in ComboBox + pass + Variable = Union[Categorical, Real, Time, String] VariableTypes = (Categorical, Real, Time, String) @@ -187,11 +205,10 @@ class Unlink(_DataType, namedtuple("Unlink", [])): """Unlink variable from its source, that is, remove compute_value""" + Transform = Union[Rename, CategoriesMapping, Annotate, Unlink] TransformTypes = (Rename, CategoriesMapping, Annotate, Unlink) -CategoricalTransformTypes = (CategoriesMapping, Unlink) - # Reinterpret vector transformations. class CategoricalVector( @@ -233,7 +250,7 @@ def __call__(self, vector: DataVector) -> StringVector: if isinstance(var, String): return vector return StringVector( - String(var.name, var.annotations, False), + String(var.name, var.annotations), lambda: as_string(vector.data()), ) @@ -253,11 +270,11 @@ def data() -> MArray: a = categorical_to_string_vector(d, var.values) return MArray(as_float_or_nan(a, where=a.mask), mask=a.mask) return RealVector( - Real(var.name, (6, 'g'), var.annotations, var.linked), data + Real(var.name, (6, 'g'), var.annotations), data ) elif isinstance(var, Time): return RealVector( - Real(var.name, (6, 'g'), var.annotations, var.linked), + Real(var.name, (6, 'g'), var.annotations), lambda: vector.data().astype(float) ) elif isinstance(var, String): @@ -265,7 +282,7 @@ def data(): s = vector.data() return MArray(as_float_or_nan(s, where=s.mask), mask=s.mask) return RealVector( - Real(var.name, (6, "g"), var.annotations, var.linked), data + Real(var.name, (6, "g"), var.annotations), data ) raise AssertionError @@ -281,39 +298,69 @@ def __call__(self, vector: DataVector) -> CategoricalVector: if isinstance(var, (Real, Time, String)): data, values = categorical_from_vector(vector.data()) return CategoricalVector( - Categorical(var.name, values, var.annotations, var.linked), + Categorical(var.name, values, var.annotations), lambda: data ) raise AssertionError -class AsTime(_DataType, namedtuple("AsTime", [])): +class StrpTime(_DataType, namedtuple("StrpTime", ["label", "formats", "have_date", "have_time"])): + """Use format on variable interpreted as time""" + + +class TimeUnit(_DataType, namedtuple("TimeUnit", ["label", "unit"])): + pass + + +class _AsTime(NamedTuple): + param: StrpTime | TimeUnit | None = None + + +class AsTime(_DataType, _AsTime): """Reinterpret as a datetime vector""" + @property + def unit(self): + if self.param is None: + return "s" + return self.param.unit + + @property + def formats(self): + if self.param is None: + return None # default is guess + return self.param.formats + def __call__(self, vector: DataVector) -> TimeVector: var, _ = vector if isinstance(var, Time): return vector elif isinstance(var, Real): + unit = self.param.unit if isinstance(self.param, TimeUnit) else "us" + if unit == "Y0": + def data(): + return (vector.data() - 1970).astype("M8[Y]").astype("us") + else: + def data(): + return vector.data().astype(f"M8[{unit}]").astype("us") return TimeVector( - Time(var.name, var.annotations, var.linked), - lambda: vector.data().astype("M8[us]") + Time(var.name, var.annotations), data ) elif isinstance(var, Categorical): def data(): d = vector.data() s = categorical_to_string_vector(d, var.values) - dt = pd.to_datetime(s, errors="coerce").values.astype("M8[us]") - return MArray(dt, mask=d.mask) + a = to_datetime(s, errors="coerce") + return MArray(a, mask=d.mask) return TimeVector( - Time(var.name, var.annotations, var.linked), data + Time(var.name, var.annotations), data ) elif isinstance(var, String): def data(): s = vector.data() - dt = pd.to_datetime(s, errors="coerce").values.astype("M8[us]") - return MArray(dt, mask=s.mask) + a = to_datetime(s, errors="coerce") + return MArray(a, mask=s.mask) return TimeVector( - Time(var.name, var.annotations, var.linked), data + Time(var.name, var.annotations), data ) raise AssertionError @@ -321,6 +368,14 @@ def data(): ReinterpretTransform = Union[AsCategorical, AsContinuous, AsTime, AsString] ReinterpretTransformTypes = (AsCategorical, AsContinuous, AsTime, AsString) +TypeTransformers = { + Real: AsContinuous, + Categorical: AsCategorical, + Time: AsTime, + String: AsString, + RestoreOriginal: RestoreOriginal +} + def deconstruct(obj): # type: (tuple) -> Tuple[str, Tuple[Any, ...]] @@ -357,8 +412,8 @@ def reconstruct(tname, args): """ try: constructor = globals()[tname] - except KeyError: - raise NameError(tname) + except KeyError as exc: + raise NameError(tname) from exc return constructor(*args) @@ -469,27 +524,34 @@ def get_dict(self): return rval -class VariableEditor(QWidget): - """ - An editor widget for a variable. - - Can edit the variable name, and its attributes dictionary. - """ +class BaseEditor(QWidget): variable_changed = Signal() def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) - self.var = None # type: Optional[Variable] layout = QVBoxLayout() self.setLayout(layout) - self.form = form = QFormLayout( + self.form = QFormLayout( fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow, objectName="editor-form-layout" ) layout.addLayout(self.form) + +class VariableEditor(BaseEditor): + """ + An editor widget for a variable. + + Can edit the variable name, and its attributes dictionary. + """ + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self.var = None # type: Optional[Variable] + + form = self.form + self.name_edit = QLineEdit(objectName="name-editor") self.name_edit.editingFinished.connect( lambda: self.name_edit.isModified() and self.on_name_changed() @@ -507,7 +569,7 @@ def __init__(self, parent=None, **kwargs): self.unlink_var_cb.toggled.connect(self._set_unlink) form.addRow("", self.unlink_var_cb) - vlayout = QVBoxLayout(margin=0, spacing=1) + vlayout = QVBoxLayout(spacing=1) self.labels_edit = view = QTreeView( objectName="annotation-pairs-edit", rootIsDecorated=False, @@ -606,7 +668,7 @@ def set_data(self, var, transform=()): else: self.add_label_action.actionGroup().setEnabled(False) - self.unlink_var_cb.setDisabled(var is None or not var.linked) + self.unlink_var_cb.setDisabled(var is None) def get_data(self): """Retrieve the modified variable. @@ -620,7 +682,7 @@ def get_data(self): tr.append(Rename(name)) if self.var.annotations != labels: tr.append(Annotate(labels)) - if self.var.linked and self.unlink_var_cb.isChecked(): + if self.unlink_var_cb.isChecked(): tr.append(Unlink()) return self.var, tr @@ -694,7 +756,7 @@ def __init__( label3 = QLabel("occurrences") label4 = QLabel("most frequent values") - self.frequent_abs_spin = spin2 = QSpinBox() + self.frequent_abs_spin = spin2 = QSpinBox(alignment=Qt.AlignRight) max_val = len(data) spin2.setMinimum(1) spin2.setMaximum(max_val) @@ -704,7 +766,7 @@ def __init__( ) spin2.valueChanged.connect(self._frequent_abs_spin_changed) - self.frequent_rel_spin = spin3 = QDoubleSpinBox() + self.frequent_rel_spin = spin3 = QDoubleSpinBox(alignment=Qt.AlignRight) spin3.setMinimum(0) spin3.setDecimals(1) spin3.setSingleStep(0.1) @@ -714,7 +776,7 @@ def __init__( spin3.setSuffix(" %") spin3.valueChanged.connect(self._frequent_rel_spin_changed) - self.n_values_spin = spin4 = QSpinBox() + self.n_values_spin = spin4 = QSpinBox(alignment=Qt.AlignRight) spin4.setMinimum(0) spin4.setMaximum(len(variable.categories)) spin4.setValue( @@ -727,21 +789,29 @@ def __init__( ) spin4.valueChanged.connect(self._n_values_spin_spin_changed) - grid_layout = QGridLayout() + grid_layout = QVBoxLayout() # first row - grid_layout.addWidget(radio1, 0, 0, 1, 2) + row = QHBoxLayout() + row.addWidget(radio1) + grid_layout.addLayout(row) # second row - grid_layout.addWidget(radio2, 1, 0, 1, 2) - grid_layout.addWidget(spin2, 1, 2) - grid_layout.addWidget(label2, 1, 3) + row = QHBoxLayout() + row.addWidget(radio2) + row.addWidget(spin2) + row.addWidget(label2) + grid_layout.addLayout(row) # third row - grid_layout.addWidget(radio3, 2, 0, 1, 2) - grid_layout.addWidget(spin3, 2, 2) - grid_layout.addWidget(label3, 2, 3) + row = QHBoxLayout() + row.addWidget(radio3) + row.addWidget(spin3) + row.addWidget(label3) + grid_layout.addLayout(row) # fourth row - grid_layout.addWidget(radio4, 3, 0) - grid_layout.addWidget(spin4, 3, 1) - grid_layout.addWidget(label4, 3, 2, 1, 2) + row = QHBoxLayout() + row.addWidget(radio4) + row.addWidget(spin4) + row.addWidget(label4) + grid_layout.addLayout(row) group_box = QGroupBox() group_box.setLayout(grid_layout) @@ -853,15 +923,6 @@ def get_dialog_settings(self) -> Dict[str, Any]: return settings_dict -@contextmanager -def disconnected(signal, slot, connection_type=Qt.AutoConnection): - signal.disconnect(slot) - try: - yield - finally: - signal.connect(slot, connection_type) - - #: In 'reordable' models holds the original position of the item #: (if applicable). SourcePosRole = Qt.UserRole @@ -1114,7 +1175,7 @@ def __init__(self, *args, **kwargs): flags=Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable ) - vlayout = QVBoxLayout(spacing=1, margin=0) + vlayout = QVBoxLayout(spacing=1) self.values_edit = QListView( editTriggers=QListView.DoubleClicked | QListView.EditKeyPressed, selectionMode=QListView.ExtendedSelection, @@ -1130,7 +1191,7 @@ def __init__(self, *args, **kwargs): self.values_model.rowsMoved.connect(self.on_value_selection_changed) vlayout.addWidget(self.values_edit) - hlayout = QHBoxLayout(spacing=1, margin=0) + hlayout = QHBoxLayout(spacing=1) self.categories_action_group = group = QActionGroup( self, objectName="action-group-categories", enabled=False @@ -1300,7 +1361,7 @@ def set_data_categorical(self, var, values, transform=()): SourceNameRole: ci } else: - assert False, "invalid mapping: {!r}".format(tr.mapping) + assert False, f"invalid mapping: {tr.mapping}" items.append(item) elif var is not None: items = [ @@ -1430,8 +1491,7 @@ def _remove_category(self): # new level -> remove it model.removeRow(index.row()) else: - assert False, "invalid state '{}' for {}" \ - .format(state, index.row()) + assert False, f"invalid state '{state}' for {index.row()}" def _add_category(self): """ @@ -1515,9 +1575,130 @@ class ContinuousVariableEditor(VariableEditor): pass +class ComboBox(QComboBox): + # QComboBox.findData does not work for (named)tuples? + def findData(self, data, role=Qt.ItemDataRole.UserRole) -> int: + idx = findf(range(self.count()), lambda i: self.itemData(i, role) == data) + if idx is None: + idx = -1 + return idx + class TimeVariableEditor(VariableEditor): - # TODO: enable editing of display format... - pass + CUSTOM_FORMAT_LABEL = "Custom format" + FORMATS = [("Detect automatically", (None, 1, 1))] + list( + Orange.data.TimeVariable.ADDITIONAL_FORMATS.items() + ) + UNITS = [ + ("Default", "s"), + ("Nanosecond", "ns"), + ("Microsecond", "us"), + ("Millisecond", "ms"), + ("Second", "s"), + ("Minute", "m"), + ("Hour", "h"), + ("Day", "D"), + ("Month", "M"), + ("Year", "Y0"), # Since 0AD, others are since Unix epoch + ("Years since 1970", "Y"), + ] + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + form = self.layout().itemAt(0) + + self.format_cb = ComboBox() + self._formats_model = create_list_model([ + {Qt.DisplayRole: name, Qt.UserRole: StrpTime(name, *data)} + for name, data in self.FORMATS + ] + [{Qt.DisplayRole: self.CUSTOM_FORMAT_LABEL}] + ) + self._units_model = create_list_model([ + {Qt.DisplayRole: name, Qt.UserRole: TimeUnit(name, data)} + for name, data in self.UNITS + ]) + self.format_cb.setModel(self._formats_model) + self.format_cb.currentIndexChanged.connect(self.variable_changed) + self.custom_edit = QLineEdit(objectName="custom-format-line-edit") + self.custom_edit.setPlaceholderText("%Y-%m-%d %H:%M:%S") + self.custom_edit.setToolTip(CUSTOM_TOOLTIP) + self.custom_edit.editingFinished.connect(self._on_custom_change) + + # Format/Unit label switches at runtime + self._label_stack = QStackedWidget(frameShape=QStackedWidget.NoFrame) + self._label_stack.addWidget(QLabel("Format:", alignment=Qt.AlignRight | Qt.AlignVCenter)) + self._label_stack.addWidget(QLabel("Unit:", alignment=Qt.AlignRight | Qt.AlignVCenter)) + + form.insertRow(2, self._label_stack, self.format_cb) + form.insertRow(3, "Custom format:", self.custom_edit) + + def _set_format_enable(self, enable: bool, enable_custom:bool): + self.format_cb.setEnabled(enable) + self.custom_edit.setEnabled(enable_custom) + + def _orig_var(self) -> Variable | None: + return self.parent().var if self.parent() is not None else None + + def set_data(self, var, transform=()): + super().set_data(var, transform) + # This is bad + orig_var = self._orig_var() + enabled = True, True + tr = None + if isinstance(orig_var, Time): + enabled = False, False + model = self._formats_model + elif isinstance(orig_var, Real): + enabled = True, False + model = self._units_model + tr = findf(transform, lambda tr: isinstance(tr, TimeUnit)) + else: + model = self._formats_model + tr = findf(transform, lambda tr: isinstance(tr, StrpTime)) + self.format_cb.setModel(model) + self._label_stack.setCurrentIndex(0 if model is self._formats_model else 1) + self._set_format_enable(*enabled) + if tr is not None: + if isinstance(tr, StrpTime): + if tr.label is not None: + index = self.format_cb.findText(tr.label) + self.format_cb.setCurrentIndex(index) + elif tr.formats and tr.formats[0] is not None: + self.custom_edit.setText(tr.formats[0]) + self.format_cb.setCurrentIndex(self.format_cb.count() - 1) + else: + self.format_cb.setCurrentIndex(0) + elif isinstance(tr, TimeUnit): + index = self.format_cb.findData(tr) + index = 0 if index < 0 else index + self.format_cb.setCurrentIndex(index) + + def get_data(self): + var, trs = super().get_data() + orig_var = self._orig_var() + if var is not None and not isinstance(orig_var, Time): + # do not add StrpTime when transforming from time to time + if self.format_cb.currentText() == self.CUSTOM_FORMAT_LABEL: + custom_text = self.custom_edit.text() + date_pat = r"%(-?)d|%(b|B)|%(-?)m|%(y|Y)|%(-?)j|%(-?)U|%(-?)W|%(a|A)|%w" + time_pat = r"%(-?)H|%(-?)I|%p|%(-?)M|%(-?)S|%f" + have_date = int(bool(re.search(date_pat, custom_text))) + have_time = int(bool(re.search(time_pat, custom_text))) + # this is done to ensure that the custom format is correct + if not have_date and not have_time: + trf = StrpTime(None, (None,), have_date, have_time) + else: + trf = StrpTime(None, (custom_text,), have_date, have_time) + else: + trf = self.format_cb.currentData() + assert trf is not None + trs.insert(0, trf) + return var, trs + + def _on_custom_change(self): + if self.format_cb.currentText() != self.CUSTOM_FORMAT_LABEL: + self.format_cb.setCurrentIndex(self.format_cb.count() - 1) + else: + self.variable_changed.emit() def variable_icon(var): @@ -1541,6 +1722,12 @@ def variable_icon(var): #: (`List[Union[ReinterpretTransform, Transform]]`) TransformRole = Qt.UserRole + 42 +#: Any warnings applying to the transform (`list[tuple[Msg, str]]`) +RestoreWarningRole = TransformRole + 1 + +#: Hint key that was used to load stored settings. +RestoreHintKey = RestoreWarningRole + 1 + class VariableEditDelegate(QStyledItemDelegate): ReinterpretNames = { @@ -1577,8 +1764,7 @@ def initStyleOption(self, option, index): text = var.name for tr in transform: if isinstance(tr, Rename): - text = ("{} \N{RIGHTWARDS ARROW} {}" - .format(var.name, tr.name)) + text = f"{var.name} \N{RIGHTWARDS ARROW} {tr.name}" for tr in transform: if isinstance(tr, ReinterpretTransformTypes): text += f" (reinterpreted as " \ @@ -1588,11 +1774,45 @@ def initStyleOption(self, option, index): # mark as changed (maybe also change color, add text, ...) option.font.setItalic(True) + multiplicity = index.data(MultiplicityRole) + warnings_ = index.data(RestoreWarningRole) + + def set_color(palette: QPalette, color): + palette.setBrush(QPalette.Text, QBrush(color)) + palette.setBrush(QPalette.HighlightedText, QBrush(color)) + + if isinstance(multiplicity, int) and multiplicity > 1: + set_color(option.palette, Qt.red) + elif warnings_: + set_color(option.palette, self.warning_text_color(option.palette)) + + @staticmethod + def warning_text_color(palette: QPalette): + background = palette.color(QPalette.ColorRole.Base) + if luminance(background) > 0.5: + return QColor(255, 148, 11) + else: + return QColor(Qt.GlobalColor.yellow) + + def helpEvent(self, event: QHelpEvent, view: QAbstractItemView, + option: QStyleOptionViewItem, index: QModelIndex) -> bool: + multiplicity = index.data(MultiplicityRole) + name = VariableListModel.effective_name(index) + if isinstance(multiplicity, int) and multiplicity > 1 \ + and name is not None: + QToolTip.showText( + event.globalPos(), f"Name `{name}` is duplicated", + view.viewport() + ) + return True + else: # pragma: no cover + return super().helpEvent(event, view, option, index) + # Item model for edited variables (Variable). Define a display role to be the # source variable name. This is used only in keyboard search. The display is # otherwise completely handled by a delegate. -class VariableListModel(itemmodels.PyListModel): +class VariableListModel(CountedListModel): def data(self, index, role=Qt.DisplayRole): # type: (QModelIndex, Qt.ItemDataRole) -> Any row = index.row() @@ -1606,6 +1826,32 @@ def data(self, index, role=Qt.DisplayRole): return item.vtype.name return super().data(index, role) + def key(self, index): + return VariableListModel.effective_name(index) + + def keyRoles(self): # type: () -> FrozenSet[int] + return frozenset((Qt.DisplayRole, Qt.EditRole, TransformRole)) + + @staticmethod + def effective_name(index) -> Optional[str]: + item = index.data(Qt.EditRole) + if isinstance(item, DataVectorTypes): + var = item.vtype + elif isinstance(item, VariableTypes): + var = item + else: + return None + tr = index.data(TransformRole) + return effective_name(var, tr or []) + + +def effective_name(var: Variable, tr: Sequence[Transform]) -> str: + name = var.name + for t in tr: + if isinstance(t, Rename): + name = t.name + return name + class ReinterpretVariableEditor(VariableEditor): """ @@ -1619,21 +1865,31 @@ class ReinterpretVariableEditor(VariableEditor): type(None): -1, } + _editors_by_transform = { + AsCategorical: 0, + AsContinuous: 1, + AsString: 2, + AsTime: 3, + type(None): 5 + } + def __init__(self, parent=None, **kwargs): - # Explicitly skip VariableEditor's __init__, this is ugly but we have + # Explicitly skip BaseEditor's __init__, this is ugly but we have # a completely different layout/logic as a compound editor (should - # really not subclass VariableEditor). - super(VariableEditor, self).__init__(parent, **kwargs) # pylint: disable=bad-super-call + # really not subclass BaseEditor). + super(BaseEditor, self).__init__(parent, **kwargs) # pylint: disable=bad-super-call + self.variables = None # type: Optional[Tuple[Variable]] self.var = None # type: Optional[Variable] self.__transform = None # type: Optional[ReinterpretTransform] - self.__data = None # type: Optional[DataVector] + self.__transforms = () # type: Sequence[Sequence[Transform]] + self.__data = None # type: Union[None, DataVector, Tuple[DataVector]] #: Stored transform state indexed by variable. Used to preserve state #: between type switches. self.__history = {} # type: Dict[Variable, List[Transform]] self.setLayout(QStackedLayout()) - def decorate(editor: VariableEditor) -> VariableEditor: + def decorate(editor: BaseEditor) -> VariableEditor: """insert an type combo box into a `editor`'s layout.""" form = editor.layout().itemAt(0) assert isinstance(form, QFormLayout) @@ -1642,7 +1898,12 @@ def decorate(editor: VariableEditor) -> VariableEditor: typecb.addItem(variable_icon(Real), "Numeric", Real) typecb.addItem(variable_icon(String), "Text", String) typecb.addItem(variable_icon(Time), "Time", Time) - typecb.activated[int].connect(self.__reinterpret_activated) + if type(editor) is BaseEditor: # pylint: disable=unidiomatic-typecheck + typecb.addItem("(Restore original)", RestoreOriginal) + typecb.addItem("") + typecb.activated[int].connect(self.__reinterpret_activated_multi) + else: + typecb.activated[int].connect(self.__reinterpret_activated_single) form.insertRow(1, "Type:", typecb) # Insert the typecb after name edit in the focus chain name_edit = editor.findChild(QLineEdit, ) @@ -1656,16 +1917,32 @@ def decorate(editor: VariableEditor) -> VariableEditor: cedit = decorate(ContinuousVariableEditor()) tedit = decorate(TimeVariableEditor()) sedit = decorate(VariableEditor()) + medit = decorate(BaseEditor()) - for ed in [dedit, cedit, tedit, sedit]: + for ed in [dedit, cedit, tedit, sedit, medit]: ed.variable_changed.connect(self.variable_changed) self.layout().addWidget(dedit) self.layout().addWidget(cedit) self.layout().addWidget(sedit) self.layout().addWidget(tedit) + self.layout().addWidget(medit) + + # pylint: disable=arguments-differ,arguments-renamed + def set_data(self, + data: Sequence[DataVector], + transforms: Sequence[Sequence[Transform]] = None) -> None: + if transforms is None: + transforms = ([], ) * len(data) + else: + assert len(data) == len(transforms) + if len(data) > 1: + self._set_data_multi(data, transforms) + else: + self._set_data_single(data[0] if data else None, + transforms[0] if transforms else None) - def set_data(self, data, transform=()): # pylint: disable=arguments-differ + def _set_data_single(self, data, transform=()): # pylint: disable=arguments-differ # type: (Optional[DataVector], Sequence[Transform]) -> None """ Set the editor data. @@ -1683,11 +1960,13 @@ def set_data(self, data, transform=()): # pylint: disable=arguments-differ _tr = transform[0] if isinstance(_tr, ReinterpretTransformTypes): type_transform = _tr - transform = transform[1:] + # Extend type_transform's parameters + transform = list(type_transform) + transform[1:] assert not any(isinstance(t, ReinterpretTransformTypes) for t in transform) self.__transform = type_transform self.__data = data + self.variables = None self.var = data.vtype if data is not None else None if type_transform is not None and data is not None: @@ -1709,26 +1988,114 @@ def set_data(self, data, transform=()): # pylint: disable=arguments-differ cb = w.findChild(QComboBox, "type-combo") cb.setCurrentIndex(index) + def _set_data_multi(self, + data: Sequence[DataVector], + transforms: Sequence[Sequence[Transform]] = ()) -> None: + assert len(data) == len(transforms) + self.__data = data + self.var = None + self.variables = tuple(d.vtype for d in self.__data) + + self.__transforms = transforms + type_transforms: Set[Type[Optional[ReinterpretTransform]]] = { + type( + transform[0] + if transform and isinstance(transform[0], ReinterpretTransformTypes) + else None) + for transform in transforms + } + if len(type_transforms) == 1: + self.__transform = type_transforms.pop()() + else: + self.__transform = None + + self.layout().setCurrentIndex(4) + w = self.layout().currentWidget() + assert isinstance(w, BaseEditor) + + cb = w.findChild(QComboBox, "type-combo") + index = self._editors_by_transform[type(self.__transform)] + cb.setCurrentIndex(index) + def get_data(self): + if self.variables is None: + return self._get_data_single() + else: + return self._get_data_multi() + + @staticmethod + def _contract_transform(rtrs: ReinterpretTransform, trs: list[Transform]): + if isinstance(rtrs, AsTime): + needle = findf(trs, lambda tr: isinstance(tr, (StrpTime, TimeUnit))) + if needle is not None: + trs.remove(needle) + return AsTime(needle), trs + return rtrs, trs + + def _get_data_single(self): # type: () -> Tuple[Variable, Sequence[Transform]] editor = self.layout().currentWidget() # type: VariableEditor var, tr = editor.get_data() - if type(var) != type(self.var): # pylint: disable=unidiomatic-typecheck + if type(var) is not type(self.var): assert self.__transform is not None var = self.var - tr = [self.__transform, *tr] - return var, tr + rtr, tr = self._contract_transform(self.__transform, tr) + tr = [rtr, *tr] + return (var, ), (tr, ) + + def _get_data_multi(self): + # type: () -> Tuple[Variable, Sequence[Transform]] + if self.__transform is None: + transforms = self.__transforms + else: + rev_transforms = {v: k for k, v in TypeTransformers.items()} + target = rev_transforms[type(self.__transform)] + if target in (RestoreOriginal, None): + gen_target_spec = None + else: + gen_target_spec = self.Specific.get(target, ()) + + transforms = [] + for var, tr in zip(self.variables, self.__transforms): + if tr and isinstance(tr[0], ReinterpretTransformTypes): + source_type = rev_transforms[type(tr[0])] + else: + source_type = type(var) + source_spec = self.Specific.get(source_type) + if gen_target_spec is None: + target_spec = self.Specific.get(type(var)) + else: + target_spec = gen_target_spec + + # Remove type reinterpretation and + # transformation specific to source type that aren't + # applicable to destination type + tr = [ + t for t in tr + if not ( + isinstance(t, ReinterpretTransformTypes) + or (source_spec and isinstance(t, source_spec) + and not (target_spec and isinstance(t, target_spec)) + ) + ) + ] + # pylint: disable=unidiomatic-typecheck + if target is not RestoreOriginal and type(var) is not target: + tr = [self.__transform, *tr] + transforms.append(tr) + return self.variables, transforms + + Specific = { + Categorical: (CategoriesMapping, ) + } - def __reinterpret_activated(self, index): + def __reinterpret_activated_single(self, index): layout = self.layout() assert isinstance(layout, QStackedLayout) if index == layout.currentIndex(): return current = layout.currentWidget() assert isinstance(current, VariableEditor) - Specific = { - Categorical: CategoricalTransformTypes - } _var, _tr = current.get_data() if _var is not None: self.__history[_var] = _tr @@ -1736,7 +2103,7 @@ def __reinterpret_activated(self, index): var = self.var transform = self.__transform # take/preserve the general transforms that apply to all types - specific = Specific.get(type(var), ()) + specific = self.Specific.get(type(var), ()) _tr = [t for t in _tr if not isinstance(t, specific)] layout.setCurrentIndex(index) @@ -1747,17 +2114,9 @@ def __reinterpret_activated(self, index): target = cb.itemData(index, Qt.UserRole) assert issubclass(target, VariableTypes) if not isinstance(var, target): - if target == Real: - transform = AsContinuous() - elif target == Categorical: - transform = AsCategorical() - elif target == Time: - transform = AsTime() - elif target == String: - transform = AsString() + transform = TypeTransformers[target]() else: transform = None - var = self.var self.__transform = transform data = None @@ -1770,7 +2129,7 @@ def __reinterpret_activated(self, index): else: tr = [] # type specific transform - specific = Specific.get(type(var), ()) + specific = self.Specific.get(type(var), ()) # merge tr and _tr tr = _tr + [t for t in tr if isinstance(t, specific)] with disconnected( @@ -1784,6 +2143,29 @@ def __reinterpret_activated(self, index): w.set_data(var, transform=tr) self.variable_changed.emit() + def __reinterpret_activated_multi(self, index): + layout = self.layout() + assert isinstance(layout, QStackedLayout) + w = layout.currentWidget() + cb = w.findChild(QComboBox, "type-combo") + target = cb.itemData(index, Qt.UserRole) + if target is None: + transform = target + else: + transform = TypeTransformers[target]() + if transform == self.__transform: + return + self.__transform = transform + self.variable_changed.emit() + + def clear(self): + self.variables = self.var = None + layout = self.layout() + assert isinstance(layout, QStackedLayout) + w = layout.currentWidget() + if isinstance(w, VariableEditor): + w.clear() + def set_merge_context(self, merge_context): self.disc_edit.merge_dialog_settings = merge_context @@ -1796,7 +2178,7 @@ class OWEditDomain(widget.OWWidget): description = "Rename variables, edit categories and variable annotations." icon = "icons/EditDomain.svg" priority = 3125 - keywords = ["rename", "drop", "reorder", "order"] + keywords = "edit domain, rename, drop, reorder, order" class Inputs: data = Input("Data", Orange.data.Table) @@ -1807,13 +2189,19 @@ class Outputs: class Error(widget.OWWidget.Error): duplicate_var_name = widget.Msg("A variable name is duplicated.") - settingsHandler = settings.DomainContextHandler() - settings_version = 2 + class Warning(widget.OWWidget.Warning): + transform_restore_failed = widget.Msg( + "Failed to restore transform {} for column {}" + ) + cat_mapping_does_not_apply = widget.Msg( + "Categories mapping for {} does not apply to current input" + ) + + settings_version = 5 - _domain_change_store = settings.ContextSetting({}) - _selected_item = settings.ContextSetting(None) # type: Optional[Tuple[str, int]] - _merge_dialog_settings = settings.ContextSetting({}) - output_table_name = settings.ContextSetting("") + _domain_change_hints: dict = Setting({}, schema_only=True) + _merge_dialog_settings = Setting({}, schema_only=True) + output_table_name = Setting("", schema_only=True) want_main_area = False @@ -1821,7 +2209,7 @@ def __init__(self): super().__init__() self.data = None # type: Optional[Orange.data.Table] #: The current selected variable index - self.selected_index = -1 + self._selected_items = [] self._invalidated = False self.typeindex = 0 @@ -1829,8 +2217,8 @@ def __init__(self): box = gui.vBox(main, "Variables") self.variables_model = VariableListModel(parent=self) - self.variables_view = self.domain_view = QListView( - selectionMode=QListView.SingleSelection, + self.variables_view = self.domain_view = ListViewSearch( + selectionMode=QListView.ExtendedSelection, uniformItemSizes=True, ) self.variables_view.setItemDelegate(VariableEditDelegate(self)) @@ -1851,21 +2239,21 @@ def __init__(self): gui.rubber(self.buttonsArea) bbox = gui.hBox(self.buttonsArea) - breset_all = gui.button( + gui.button( bbox, self, "Reset All", objectName="button-reset-all", toolTip="Reset all variables to their input state.", autoDefault=False, callback=self.reset_all ) - breset = gui.button( + gui.button( bbox, self, "Reset Selected", objectName="button-reset", toolTip="Rest selected variable to its input state.", autoDefault=False, callback=self.reset_selected ) - bapply = gui.button( + gui.button( bbox, self, "Apply", objectName="button-apply", toolTip="Apply changes and commit data on output.", @@ -1879,14 +2267,17 @@ def __init__(self): @Inputs.data def set_data(self, data): """Set input dataset.""" - self.closeContext() + if data is not None: + self._selected_items = [ + index.data() + for index in self.variables_view.selectedIndexes()] + self.clear() self.data = data if self.data is not None: self.setup_model(data) self.le_output_name.setPlaceholderText(data.name) - self.openContext(self.data) self._editor.set_merge_context(self._merge_dialog_settings) self._restore() else: @@ -1899,48 +2290,46 @@ def clear(self): self.data = None self.variables_model.clear() self.clear_editor() - assert self.selected_index == -1 - self.selected_index = -1 - - self._selected_item = None - self._domain_change_store = {} self._merge_dialog_settings = {} + self.Warning.clear() def reset_selected(self): """Reset the currently selected variable to its original state.""" - ind = self.selected_var_index() - if ind >= 0: - model = self.variables_model + model = self.variables_model + editor = self._editor + modified = [] + for ind in self.selected_var_indices(): midx = model.index(ind) + model.setData(midx, [], TransformRole) + model.setData(midx, None, RestoreWarningRole) + key = model.data(midx, RestoreHintKey) var = midx.data(Qt.EditRole) - tr = midx.data(TransformRole) - if not tr: - return # nothing to reset - editor = self._editor + self._domain_change_hints.pop(key, None) + modified.append(var) + if modified: with disconnected(editor.variable_changed, self._on_variable_changed): - model.setData(midx, [], TransformRole) - editor.set_data(var, transform=[]) + self._editor.set_data(modified) self._invalidate() + self._update_restore_warnings() def reset_all(self): """Reset all variables to their original state.""" - self._domain_change_store = {} if self.data is not None: model = self.variables_model for i in range(model.rowCount()): midx = model.index(i) model.setData(midx, [], TransformRole) - index = self.selected_var_index() - if index >= 0: - self.open_editor(index) + model.setData(midx, None, RestoreWarningRole) + key = model.data(midx, RestoreHintKey) + self._domain_change_hints.pop(key, None) + self.open_editor() self._invalidate() + self._update_restore_warnings() - def selected_var_index(self): - """Return the current selected variable index.""" - rows = self.variables_view.selectedIndexes() - assert len(rows) <= 1 - return rows[0].row() if rows else -1 + def selected_var_indices(self): + """Return the current selected variable indices.""" + return [index.row() for index in self.variables_view.selectedIndexes()] def setup_model(self, data: Orange.data.Table): model = self.variables_model @@ -1963,51 +2352,104 @@ def setup_model(self, data: Orange.data.Table): for i, d in enumerate(columns): model.setData(model.index(i), d, Qt.EditRole) - def _restore(self, ): + def _sanitize_transform( + self, var: Variable, trs: Sequence[Transform] + ) -> tuple[Sequence[Transform], Sequence[tuple[Msg, str]]]: + def does_categories_mapping_apply( + var: Categorical, tr: CategoriesMapping) -> bool: + return set(var.categories) \ + == set(ci for ci, _ in tr.mapping if ci is not None) + msgs = [] + if isinstance(var, Categorical): + trs_ = [] + for tr in trs: + if isinstance(tr, CategoriesMapping): + if does_categories_mapping_apply(var, tr): + trs_.append(tr) + else: + + msgs.append((self.Warning.cat_mapping_does_not_apply, var.name)) + else: + trs_.append(tr) + return trs_, msgs + else: + return trs, msgs + + def _restore(self): """ Restore the edit transform from saved state. """ model = self.variables_model + hints = self._domain_change_hints + first_key = None for i in range(model.rowCount()): midx = model.index(i, 0) coldesc = model.data(midx, Qt.EditRole) # type: DataVector - tr = self._restore_transform(coldesc.vtype) - if tr: - model.setData(midx, tr, TransformRole) + res = self._find_stored_transform(coldesc.vtype) + if res: + key, tr = res + if tr: + self._store_transform(coldesc.vtype, tr, key) + tr, msgs = self._sanitize_transform(coldesc.vtype, tr) + model.setData(midx, tr, TransformRole) + model.setData(midx, msgs, RestoreWarningRole) + model.setData(midx, key, RestoreHintKey) + if first_key is None: + first_key = key + # Reduce the number of hints to MAX_HINTS, but keep all current hints + # Current hints start with `first_key`. + while len(hints) > MAX_HINTS and \ + (key := next(iter(hints))) != first_key: + del hints[key] # pylint: disable=unsupported-delete-operation + + self._update_restore_warnings() # Restore the current variable selection - i = -1 - if self._selected_item is not None: - for i, vec in enumerate(model): - if vec.vtype.name_type() == self._selected_item: - break - if i == -1 and model.rowCount(): - i = 0 - - if i != -1: - itemmodels.select_row(self.variables_view, i) - - def _on_selection_changed(self): - self.selected_index = self.selected_var_index() - if self.selected_index != -1: - self._selected_item = self.variables_model[self.selected_index].vtype.name_type() - else: - self._selected_item = None - self.open_editor(self.selected_index) + selected_rows = [i for i, vec in enumerate(model) + if vec.vtype.name in self._selected_items] + if not selected_rows and model.rowCount(): + selected_rows = [0] + itemmodels.select_rows(self.variables_view, selected_rows) + + def _update_restore_warnings(self): + def messages(midx): + msgs = model.data(midx, RestoreWarningRole) + return msgs or [] + model = self.variables_model + msgs = chain.from_iterable( + messages(model.index(i)) for i in range(model.rowCount()) + ) + self.Warning.cat_mapping_does_not_apply.clear() + # Show warnings for non-applicable transforms + for msg, names in groupby(sorted(msgs), key=itemgetter(0)): + msg(", ".join(map(itemgetter(1), names))) + + def _on_selection_changed(self, _, deselected): + # If the user deselected the last item, select it back with disabled + # signals, so nothing happens + if not self.selected_var_indices(): + sel_model = self.variables_view.selectionModel() + with disconnected(sel_model.selectionChanged, + self._on_selection_changed): + sel_model.select(deselected, QItemSelectionModel.Select) + return - def open_editor(self, index): - # type: (int) -> None + self.open_editor() + + def open_editor(self): self.clear_editor() - model = self.variables_model - if not 0 <= index < model.rowCount(): + + indices = self.selected_var_indices() + if not indices: return - idx = model.index(index, 0) - vector = model.data(idx, Qt.EditRole) - tr = model.data(idx, TransformRole) - if tr is None: - tr = [] + + model = self.variables_model + + vectors = [model.index(idx, 0).data(Qt.EditRole) for idx in indices] + transforms = [model.index(idx, 0).data(TransformRole) or () + for idx in indices] editor = self._editor - editor.set_data(vector, transform=tr) + editor.set_data(vectors, transforms=transforms) editor.variable_changed.connect( self._on_variable_changed, Qt.UniqueConnection ) @@ -2018,39 +2460,61 @@ def clear_editor(self): current.variable_changed.disconnect(self._on_variable_changed) except TypeError: pass - current.set_data(None) - current.layout().currentWidget().clear() + current.set_data((), ()) + current.clear() @Slot() def _on_variable_changed(self): """User edited the current variable in editor.""" - assert 0 <= self.selected_index <= len(self.variables_model) editor = self._editor - var, transform = editor.get_data() model = self.variables_model - midx = model.index(self.selected_index, 0) - model.setData(midx, transform, TransformRole) - self._store_transform(var, transform) + for idx, var, transform in zip(self.selected_var_indices(), + *editor.get_data()): + midx = model.index(idx, 0) + model.setData(midx, transform, TransformRole) + model.setData(midx, None, RestoreWarningRole) + self._store_transform(var, transform) self._invalidate() + self._update_restore_warnings() - def _store_transform(self, var, transform): - # type: (Variable, List[Transform]) -> None - self._domain_change_store[deconstruct(var)] = [deconstruct(t) for t in transform] - - def _restore_transform(self, var): - # type: (Variable) -> List[Transform] - tr_ = self._domain_change_store.get(deconstruct(var), []) - tr = [] + def _store_transform( + self, var: Variable, transform: Sequence[Transform] | None, deconvar=None + ) -> None: + deconvar = deconvar or deconstruct(var) + # Remove the existing key (if any) to put the new one at the end, + # to make sure it comes after the sentinel + self._domain_change_hints.pop(deconvar, None) + # pylint: disable=unsupported-assignment-operation + if transform: + self._domain_change_hints[deconvar] = \ + [deconstruct(t) for t in transform] + + def _find_stored_transform( + self, var: Variable + ) -> Tuple[tuple, Sequence[Transform]] | None: + """Find stored transform for `var`.""" + def reconstruct_transform(tr_: list[tuple]) -> list[Transform]: + trs = [] + for t in tr_: + try: + trs.append(cast(Transform, reconstruct(*t))) + except (AttributeError, TypeError, NameError): + self.Warning.transform_restore_failed( + str(t), var.name, exc_info=True, + ) + return trs + + hints = self._domain_change_hints + key = deconstruct(var) + tr = hints.get(key) # exact match + if tr is not None: + return key, reconstruct_transform(tr) - for t in tr_: - try: - tr.append(reconstruct(*t)) - except (NameError, TypeError) as err: - warnings.warn( - "Failed to restore transform: {}, {!r}".format(t, err), - UserWarning, stacklevel=2 - ) - return tr + # match by name and type only + item = assocf(hints.items(), + lambda k: k[0] == key[0] and k[1][0] == var.name) + if item is not None: + return item[0], reconstruct_transform(item[1]) def _invalidate(self): self._set_modified(True) @@ -2085,8 +2549,7 @@ def state(i): state = [state(i) for i in range(model.rowCount())] input_vars = data.domain.variables + data.domain.metas if self.output_table_name in ("", data.name) \ - and not any(requires_transform(var, trs) - for var, (_, trs) in zip(input_vars, state)): + and all(tr is None or not tr for _, tr in state): self.Outputs.data.send(data) return @@ -2162,7 +2625,7 @@ def send_report(self): parts.append(report_transform(vector.vtype, trs)) if parts: html = ("
      " + - "".join(map("
    • {}
    • ".format, parts)) + + "".join(f"
    • {part}
    • " for part in parts) + "
    ") else: html = "No changes" @@ -2172,7 +2635,6 @@ def send_report(self): @classmethod def migrate_context(cls, context, version): - # pylint: disable=bad-continuation if version is None or version <= 1: hints_ = context.values.get("domain_change_hints", ({}, -2))[0] store = [] @@ -2214,6 +2676,49 @@ def migrate_context(cls, context, version): store.append((deconstruct(src), [deconstruct(tr) for tr in trs])) context.values["_domain_change_store"] = (dict(store), -2) + @classmethod + def migrate_settings(cls, settings, version): + if version == 2 and "context_settings" in settings: + contexts = settings["context_settings"] + valuess = [] + for context in contexts: + cls.migrate_context(context, context.values["__version__"]) + valuess.append(context.values) + # Fix the order of keys + hints = dict.fromkeys( + chain(*(values["_domain_change_store"][0] + for values in reversed(valuess))) + ) + settings["output_table_name"] = "" + for values in valuess: + hints.update(values["_domain_change_store"][0]) + new_name, _ = values.pop("output_table_name", ("", -2)) + if new_name: + settings["output_table_name"] = new_name + while len(hints) > MAX_HINTS: + del hints[next(iter(hints))] + settings["_domain_change_hints"] = hints + del settings["context_settings"] + + if version < 4 and "_domain_change_hints" in settings: + settings["_domain_change_hints"] = { + (name, desc[:-1]): trs + for (name, desc), trs in settings["_domain_change_hints"].items() + } + if version < 5 and "_domain_change_hints" in settings: + hints = settings["_domain_change_hints"] + for k, trs in list(hints.items()): + r = findf(enumerate(trs), lambda tr: tr[1][0] == "StrpTime") + if r is None: + continue + i, strp = r + trs.pop(i) + r = findf(enumerate(trs), lambda tr: tr[1][0] == "AsTime") + if r is None: + continue + i, _ = r + trs[i] = ("AsTime", (strp,)) + def enumerate_columns( table: Orange.data.Table @@ -2235,18 +2740,16 @@ def table_column_data( var: Union[Orange.data.Variable, int], dtype=None ) -> MArray: - col, copy = table.get_column_view(var) + col = table.get_column(var) var = table.domain[var] # type: Orange.data.Variable if var.is_primitive() and not np.issubdtype(col.dtype, np.inexact): col = col.astype(float) - copy = True if dtype is None: if isinstance(var, Orange.data.TimeVariable): - dtype = np.dtype("M8[us]") - col = col * 1e6 + dtype = np.dtype(float, metadata={"__formatter": var.repr_val}) elif isinstance(var, Orange.data.ContinuousVariable): - dtype = np.dtype(float) + dtype = np.dtype(float, metadata={"__formatter": var.repr_val}) elif isinstance(var, Orange.data.DiscreteVariable): _values = tuple(var.values) _n_values = len(_values) @@ -2258,12 +2761,9 @@ def table_column_data( else: assert False mask = orange_isna(var, col) + col = col.astype(dtype) - if dtype != col.dtype: - col = col.astype(dtype) - copy = True - - if not copy: + if col.base is not None: col = col.copy() return MArray(col, mask=mask) @@ -2293,13 +2793,13 @@ def type_char(value: ReinterpretTransform) -> str: return ReinterpretTypeCode.get(type(value), "?") def strike(text): - return "{}".format(escape(text)) + return f"{escape(text)}" def i(text): - return "{}".format(escape(text)) + return f"{escape(text)}" def text(text): - return "{}".format(escape(text)) + return f"{escape(text)}" assert trs rename = annotate = catmap = unlink = None reinterpret = None @@ -2317,12 +2817,10 @@ def text(text): reinterpret = tr if reinterpret is not None: - header = "{} → ({}) {}".format( - var.name, type_char(reinterpret), - rename.name if rename is not None else var.name - ) + header = f"{var.name} → ({type_char(reinterpret)}) " \ + f"{rename.name if rename is not None else var.name}" elif rename is not None: - header = "{} → {}".format(var.name, rename.name) + header = f"{var.name} → {rename.name}" else: header = var.name if unlink is not None: @@ -2362,9 +2860,9 @@ def text(text): i(name) + " : " + text(old[name]) + " → " + text(new[name]) ) - html = ["
    {}
    ".format(header)] + html = [f"
    {header}
    "] for title, contents in filter(None, [values_section, annotate_section]): - section_header = "
    {}:
    ".format(title) + section_header = f"
    {title}:
    " section_contents = "
    \n".join(contents) html.append(section_header) html.append( @@ -2392,15 +2890,14 @@ def abstract(var): (key, str(value)) for key, value in var.attributes.items() )) - linked = var.compute_value is not None if isinstance(var, Orange.data.DiscreteVariable): - return Categorical(var.name, tuple(var.values), annotations, linked) + return Categorical(var.name, tuple(var.values), annotations) elif isinstance(var, Orange.data.TimeVariable): - return Time(var.name, annotations, linked) + return Time(var.name, annotations) elif isinstance(var, Orange.data.ContinuousVariable): - return Real(var.name, (var.number_of_decimals, 'f'), annotations, linked) + return Real(var.name, (var.number_of_decimals, 'f'), annotations) elif isinstance(var, Orange.data.StringVariable): - return String(var.name, annotations, linked) + return String(var.name, annotations) else: raise TypeError @@ -2410,7 +2907,7 @@ def _parse_attributes(mapping): # Use the same functionality that parses attributes # when reading text files return Orange.data.Flags([ - "{}={}".format(*item) for item in mapping + f"{item[0]}={item[1]}" for item in mapping ]).attributes @@ -2430,23 +2927,13 @@ def apply_transform(var, table, trs): def requires_unlink(var: Orange.data.Variable, trs: List[Transform]) -> bool: - # Variable is only unlinked if it has compute_value or if it has other - # transformations (that might had added compute_value) + # Variable is only unlinked if it has compute_value or if it has other + # transformations (that might have added compute_value) return trs is not None \ and any(isinstance(tr, Unlink) for tr in trs) \ and (var.compute_value is not None or len(trs) > 1) -def requires_transform(var: Orange.data.Variable, trs: List[Transform]) -> bool: - # Unlink is treated separately: Unlink is required only if the variable - # has compute_value. Hence tranform is required if it has any - # transformations other than Unlink, or if unlink is indeed required. - return trs is not None and ( - not all(isinstance(tr, Unlink) for tr in trs) - or requires_unlink(var, trs) - ) - - @singledispatch def apply_transform_var(var, trs): # type: (Orange.data.Variable, List[Transform]) -> Orange.data.Variable @@ -2481,7 +2968,7 @@ def positions(values): dest_codes = positions(dest_values) if mapping is not None: # construct a lookup table - lookup = np.full(len(source_values), np.nan, dtype=np.float) + lookup = np.full(len(source_values), np.nan, dtype=float) for ci, cj in mapping: if ci is not None and cj is not None: i, j = source_codes[ci], dest_codes[cj] @@ -2545,43 +3032,6 @@ def apply_transform_string(var, trs): return variable -def ftry( - func: Callable[..., A], - error: Union[Type[BaseException], Tuple[Type[BaseException]]], - default: B -) -> Callable[..., Union[A, B]]: - """ - Wrap a `func` such that if `errors` occur `default` is returned instead.""" - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except error: - return default - return wrapper - - -class DictMissingConst(dict): - """ - `dict` with a constant for `__missing__()` value. - """ - __slots__ = ("__missing",) - - def __init__(self, missing, *args, **kwargs): - self.__missing = missing - super().__init__(*args, **kwargs) - - def __missing__(self, key): - return self.__missing - - def __eq__(self, other): - return super().__eq__(other) and isinstance(other, DictMissingConst) \ - and (self.__missing == other[()] # get `__missing` - or np.isnan(self.__missing) and np.isnan(other[()])) - - def __hash__(self): - return hash((self.__missing, frozenset(self.items()))) - - def make_dict_mapper( mapping: Mapping, dtype: Optional[DType] = None ) -> Callable: @@ -2591,29 +3041,7 @@ def make_dict_mapper( `make_dict_mapper` it is used as a the default return dtype, otherwise the default dtype is `object`. """ - _vmapper = np.frompyfunc(mapping.__getitem__, 1, 1) - - def mapper(arr, out=None, dtype=dtype, **kwargs): - arr = np.asanyarray(arr) - if out is None and dtype is not None and arr.shape != (): - out = np.empty_like(arr, dtype) - return _vmapper(arr, out, dtype=dtype, **kwargs) - return mapper - - -def time_parse(values: Sequence[str], name="__"): - tvar = Orange.data.TimeVariable(name) - parse_time = ftry(tvar.parse, ValueError, np.nan) - _values = [parse_time(v) for v in values] - if np.all(np.isnan(_values)): - # try parsing it with pandas (like in transform) - dti = pd.to_datetime(values, errors="coerce") - _values = datetime_to_epoch(dti) - date_only = getattr(dti, "_is_dates_only", False) - if np.all(dti != pd.NaT): - tvar.have_date = True - tvar.have_time = not date_only - return tvar, _values + return frompyfunc(mapping.__getitem__, 1, 1, dtype) as_string = np.frompyfunc(str, 1, 1) @@ -2631,12 +3059,12 @@ def as_float_or_nan( where conversion failed with NaN. """ if out is None: - out = np.full(arr.shape, np.nan, np.float if dtype is None else dtype) + out = np.full(arr.shape, np.nan, float if dtype is None else dtype) if np.issubdtype(arr.dtype, np.inexact) or \ np.issubdtype(arr.dtype, np.integer): np.copyto(out, arr, casting="unsafe", where=where) return out - return _parse_float(arr, out, where=where, **kwargs) + return _parse_float(arr, out, where=where, casting="unsafe", **kwargs) def copy_attributes(dst: V, src: Orange.data.Variable) -> V: @@ -2664,8 +3092,13 @@ def apply_reinterpret_d(var, tr, data): return var elif isinstance(tr, AsString): f = Lookup(var, np.array(var.values, dtype=object), unknown="") - rvar = Orange.data.StringVariable( - name=var.name, compute_value=f + rvar = Orange.data.StringVariable(name=var.name, compute_value=f) + elif isinstance(tr, AsTime): + f1 = Lookup(var, np.array(var.values, dtype=object), unknown="") + f2 = ReparseTimeTransform(var, tr.param) + rvar = Orange.data.TimeVariable( + name=var.name, compute_value=ChainTransform(var, (f1, f2)), + have_date=f2.tr.have_date, have_time=f2.tr.have_time ) elif isinstance(tr, AsContinuous): f = Lookup(var, np.array(list(map(parse_float, var.values))), @@ -2673,13 +3106,6 @@ def apply_reinterpret_d(var, tr, data): rvar = Orange.data.ContinuousVariable( name=var.name, compute_value=f, sparse=var.sparse ) - elif isinstance(tr, AsTime): - _tvar, values = time_parse(var.values) - f = Lookup(var, np.array(values), unknown=np.nan) - rvar = Orange.data.TimeVariable( - name=var.name, have_date=_tvar.have_date, - have_time=_tvar.have_time, compute_value=f, - ) else: assert False return copy_attributes(rvar, var) @@ -2688,17 +3114,15 @@ def apply_reinterpret_d(var, tr, data): @apply_reinterpret.register(Orange.data.ContinuousVariable) def apply_reinterpret_c(var, tr, data: MArray): if isinstance(tr, AsCategorical): - # This is ill defined and should not result in a 'compute_value' + # This is ill-defined and should not result in a 'compute_value' # (post-hoc expunge from the domain once translated?) values, index = categorize_unique(data) coldata = index.astype(float) coldata[index.mask] = np.nan tr = LookupMappingTransform( - var, DictMissingConst( - np.nan, {v: i for i, v in enumerate(values)} - ) + var, {v: i for i, v in enumerate(values)}, dtype=np.float64, unknown=np.nan ) - values = tuple(as_string(values)) + values = tuple(column_str_repr(var, values)) rvar = Orange.data.DiscreteVariable( name=var.name, values=values, compute_value=tr ) @@ -2706,12 +3130,13 @@ def apply_reinterpret_c(var, tr, data: MArray): return var elif isinstance(tr, AsString): tstr = ToStringTransform(var) - rvar = Orange.data.StringVariable( - name=var.name, compute_value=tstr - ) + rvar = Orange.data.StringVariable(name=var.name, compute_value=tstr) elif isinstance(tr, AsTime): + trs = ReinterpretTimeWithUnit(var, tr.unit) rvar = Orange.data.TimeVariable( - name=var.name, compute_value=Identity(var) + name=var.name, compute_value=trs, + have_time=tr.unit in ("ns", "us", "ms", "s", "m", "h"), + have_date=True, ) else: assert False @@ -2721,12 +3146,10 @@ def apply_reinterpret_c(var, tr, data: MArray): @apply_reinterpret.register(Orange.data.StringVariable) def apply_reinterpret_s(var: Orange.data.StringVariable, tr, data: MArray): if isinstance(tr, AsCategorical): - # This is ill defined and should not result in a 'compute_value' + # This is ill-defined and should not result in a 'compute_value' # (post-hoc expunge from the domain once translated?) _, values = categorical_from_vector(data) - mapping = DictMissingConst( - np.nan, {v: float(i) for i, v in enumerate(values)} - ) + mapping = {v: float(i) for i, v in enumerate(values)} tr = LookupMappingTransform(var, mapping) rvar = Orange.data.DiscreteVariable( name=var.name, values=values, compute_value=tr @@ -2735,14 +3158,14 @@ def apply_reinterpret_s(var: Orange.data.StringVariable, tr, data: MArray): rvar = Orange.data.ContinuousVariable( var.name, compute_value=ToContinuousTransform(var) ) - elif isinstance(tr, AsString): - return var elif isinstance(tr, AsTime): - tvar, _ = time_parse(np.unique(data.data[~data.mask])) + f = ReparseTimeTransform(var, tr.param) rvar = Orange.data.TimeVariable( - name=var.name, have_date=tvar.have_date, have_time=tvar.have_time, - compute_value=ReparseTimeTransform(var) + name=var.name, compute_value=f, + have_time=f.tr.have_time, have_date=f.tr.have_date ) + elif isinstance(tr, AsString): + return var else: assert False return copy_attributes(rvar, var) @@ -2751,15 +3174,12 @@ def apply_reinterpret_s(var: Orange.data.StringVariable, tr, data: MArray): @apply_reinterpret.register(Orange.data.TimeVariable) def apply_reinterpret_t(var: Orange.data.TimeVariable, tr, data): if isinstance(tr, AsCategorical): + _, names = categorical_from_vector(data) values, _ = categorize_unique(data) - or_values = values.astype(float) / 1e6 - mapping = DictMissingConst( - np.nan, {v: i for i, v in enumerate(or_values)} - ) + mapping = {v: i for i, v in enumerate(values)} tr = LookupMappingTransform(var, mapping) - values = tuple(as_string(values)) rvar = Orange.data.DiscreteVariable( - name=var.name, values=values, compute_value=tr + name=var.name, values=names, compute_value=tr ) elif isinstance(tr, AsContinuous): rvar = Orange.data.TimeVariable( @@ -2790,18 +3210,17 @@ class ToStringTransform(Transformation): """ Transform a variable to string. """ + InheritEq = True def transform(self, c): if self.variable.is_string: return c - elif self.variable.is_discrete or self.variable.is_time: - r = column_str_repr(self.variable, c) - elif self.variable.is_continuous: - r = as_string(c) + r = column_str_repr(self.variable, c) mask = orange_isna(self.variable, c) return np.where(mask, "", r) class ToContinuousTransform(Transformation): + InheritEq = True def transform(self, c): if self.variable.is_time: return c @@ -2819,11 +3238,15 @@ def transform(self, c): raise TypeError -def datetime_to_epoch(dti: pd.DatetimeIndex) -> np.ndarray: - """Convert datetime to epoch""" - data = dti.values.astype("M8[us]") +def datetime64_to_epoch(dt: np.ndarray, only_time) -> np.ndarray: + """Convert datetime64 to seconds from epoch""" + data = dt.astype("M8[us]") mask = np.isnat(data) - data = data.astype(float) / 1e6 + if only_time: + days = data.astype("M8[D]") + delta = data - days + data = np.datetime64("1970-01-01") + delta + data = data.astype(np.float64) / 1e6 data[mask] = np.nan return data @@ -2832,40 +3255,70 @@ class ReparseTimeTransform(Transformation): """ Re-parse the column's string repr as datetime. """ + def __init__(self, variable, tr: StrpTime | None): + super().__init__(variable) + self.tr = tr if tr is not None else StrpTime("", None, 1, 1) + def transform(self, c): - c = column_str_repr(self.variable, c) - c = pd.to_datetime(c, errors="coerce") - return datetime_to_epoch(c) + # if self.formats is none guess format option is selected + formats = list(self.tr.formats) if self.tr.formats is not None else [] + for f in formats + [None]: + if f is None: + idx = first_non_natstr(c) + if idx is not None: + f = guess_datetime_format(c[idx]) + if f is None: + continue + d = to_datetime(c, f, errors="coerce") + if (~np.isnat(d)).any(): + return datetime64_to_epoch(d, only_time=not self.tr.have_date) + return np.nan + def __eq__(self, other): + return super().__eq__(other) and self.tr == other.tr -class LookupMappingTransform(Transformation): - """ - Map values via a dictionary lookup. - """ - def __init__( - self, - variable: Orange.data.Variable, - mapping: Mapping, - dtype: Optional[np.dtype] = None - ) -> None: + def __hash__(self): + return hash((super().__hash__(), self.tr)) + + +class ReinterpretTimeWithUnit(Transformation): + def __init__(self, variable, unit: str): super().__init__(variable) - self.mapping = mapping - self.dtype = dtype - self._mapper = make_dict_mapper(mapping, dtype) + self.unit = unit def transform(self, c): - return self._mapper(c) + if self.unit == "Y0": # Year since 0 AD + r = (c - 1970).astype("M8[Y]") + else: # {unit} since Unix epoch + r = c.astype(f"M8[{self.unit}]") + return datetime64_to_epoch(r, only_time=False) + + def __eq__(self, other): + return super().__eq__(other) and self.unit == other.unit - def __reduce_ex__(self, protocol): - return type(self), (self.variable, self.mapping, self.dtype) + def __hash__(self): + return hash((super().__hash__(), self.unit)) + + +class ChainTransform(Transformation): + """Apply a sequence of transformations.""" + def __init__(self, variable, transforms: Sequence[Transformation]): + super().__init__(variable) + self.transforms = transforms + + def transform(self, c): + for tr in self.transforms: + c = tr.transform(c) + return c def __eq__(self, other): - return self.variable == other.variable \ - and self.mapping == other.mapping \ - and self.dtype == other.dtype + return super().__eq__(other) and self.transforms == other.transforms def __hash__(self): - return hash((type(self), self.variable, self.mapping, self.dtype)) + return hash((super().__hash__(), *self.transforms)) + +# Alias for back compatibility (unpickling transforms) +LookupMappingTransform = MappingTransform @singledispatch diff --git a/Orange/widgets/data/owfeatureconstructor.py b/Orange/widgets/data/owfeatureconstructor.py index 48b84219f20..add2114c157 100644 --- a/Orange/widgets/data/owfeatureconstructor.py +++ b/Orange/widgets/data/owfeatureconstructor.py @@ -11,52 +11,67 @@ import builtins import math import random -import logging import ast import types import unicodedata +from concurrent.futures import CancelledError +from dataclasses import dataclass from traceback import format_exception_only from collections import namedtuple, OrderedDict -from itertools import chain, count -from typing import List, Dict, Any +from itertools import chain, count, starmap +from typing import List, Dict, Any, Mapping, Optional import numpy as np from AnyQt.QtWidgets import ( - QSizePolicy, QAbstractItemView, QComboBox, QFormLayout, QLineEdit, + QSizePolicy, QAbstractItemView, QComboBox, QLineEdit, QHBoxLayout, QVBoxLayout, QStackedWidget, QStyledItemDelegate, - QPushButton, QMenu, QListView, QFrame, QLabel) -from AnyQt.QtGui import QKeySequence, QColor + QPushButton, QMenu, QListView, QFrame, QLabel, QMessageBox, + QGridLayout, QWidget, QCheckBox) +from AnyQt.QtGui import QKeySequence from AnyQt.QtCore import Qt, pyqtSignal as Signal, pyqtProperty as Property + +from orangecanvas.localization import pl from orangewidget.utils.combobox import ComboBoxSearch import Orange -from Orange.data.util import get_unique_names +from Orange.preprocess.transformation import MappingTransform +from Orange.util import frompyfunc +from Orange.data import Variable, Table, Value, Instance from Orange.widgets import gui -from Orange.widgets.settings import ContextSetting, DomainContextHandler -from Orange.widgets.utils import itemmodels, vartype +from Orange.widgets.settings import Setting, DomainContextHandler +from Orange.widgets.utils import ( + itemmodels, vartype, ftry, unique_everseen as unique +) from Orange.widgets.utils.sql import check_sql_input -from Orange.widgets import report from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import OWWidget, Msg, Input, Output +from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState + FeatureDescriptor = \ - namedtuple("FeatureDescriptor", ["name", "expression"]) + namedtuple("FeatureDescriptor", ["name", "expression", "meta"], + defaults=[False]) ContinuousDescriptor = \ namedtuple("ContinuousDescriptor", - ["name", "expression", "number_of_decimals"]) + ["name", "expression", "number_of_decimals", "meta"], + defaults=[False]) DateTimeDescriptor = \ namedtuple("DateTimeDescriptor", - ["name", "expression"]) + ["name", "expression", "meta"], + defaults=[False]) DiscreteDescriptor = \ namedtuple("DiscreteDescriptor", - ["name", "expression", "values", "ordered"]) + ["name", "expression", "values", "ordered", "meta"], + defaults=[False]) -StringDescriptor = namedtuple("StringDescriptor", ["name", "expression"]) +StringDescriptor = \ + namedtuple("StringDescriptor", + ["name", "expression", "meta"], + defaults=[True]) -#warningIcon = gui.createAttributePixmap('!', QColor((202, 0, 32))) def make_variable(descriptor, compute_value): if isinstance(descriptor, ContinuousDescriptor): @@ -101,6 +116,13 @@ def selected_row(view): class FeatureEditor(QFrame): + ExpressionTooltip = """ +Use variable names as values in expression. +Categorical features are passed as strings +(note the change in behaviour from Orange 3.30). + +""".lstrip() + FUNCTIONS = dict(chain([(key, val) for key, val in math.__dict__.items() if not key.startswith("_")], [(key, val) for key, val in builtins.__dict__.items() @@ -114,21 +136,22 @@ class FeatureEditor(QFrame): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - layout = QFormLayout( - fieldGrowthPolicy=QFormLayout.ExpandingFieldsGrow - ) + layout = QGridLayout() layout.setContentsMargins(0, 0, 0, 0) self.nameedit = QLineEdit( placeholderText="Name...", sizePolicy=QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) ) + + self.metaattributecb = QCheckBox("Meta attribute") + self.expressionedit = QLineEdit( placeholderText="Expression...", toolTip=self.ExpressionTooltip) self.attrs_model = itemmodels.VariableListModel( - ["Select Feature"], parent=self) + ["Select Column"], parent=self) self.attributescb = ComboBoxSearch( minimumContentsLength=16, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, @@ -137,29 +160,29 @@ def __init__(self, *args, **kwargs): self.attributescb.setModel(self.attrs_model) sorted_funcs = sorted(self.FUNCTIONS) - self.funcs_model = itemmodels.PyListModelTooltip() + self.funcs_model = itemmodels.PyListModelTooltip( + chain(["Select Function"], sorted_funcs), + chain([''], [self.FUNCTIONS[func].__doc__ for func in sorted_funcs]) + ) self.funcs_model.setParent(self) - self.funcs_model[:] = chain(["Select Function"], sorted_funcs) - self.funcs_model.tooltips[:] = chain( - [''], - [self.FUNCTIONS[func].__doc__ for func in sorted_funcs]) - self.functionscb = ComboBoxSearch( minimumContentsLength=16, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, sizePolicy=QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)) self.functionscb.setModel(self.funcs_model) - hbox = QHBoxLayout() - hbox.addWidget(self.attributescb) - hbox.addWidget(self.functionscb) + layout.addWidget(self.nameedit, 0, 0) + layout.addWidget(self.metaattributecb, 1, 0) + layout.addWidget(self.expressionedit, 0, 1, 1, 2) + layout.addWidget(self.attributescb, 1, 1) + layout.addWidget(self.functionscb, 1, 2) + layout.addWidget(QWidget(), 2, 0) - layout.addRow(self.nameedit, self.expressionedit) - layout.addRow(self.tr(""), hbox) self.setLayout(layout) self.nameedit.editingFinished.connect(self._invalidate) + self.metaattributecb.clicked.connect(self._invalidate) self.expressionedit.textChanged.connect(self._invalidate) self.attributescb.currentIndexChanged.connect(self.on_attrs_changed) self.functionscb.currentIndexChanged.connect(self.on_funcs_changed) @@ -182,6 +205,7 @@ def modified(self): def setEditorData(self, data, domain): self.nameedit.setText(data.name) + self.metaattributecb.setChecked(data.meta) self.expressionedit.setText(data.expression) self.setModified(False) self.featureChanged.emit() @@ -193,7 +217,8 @@ def setEditorData(self, data, domain): def editorData(self): return FeatureDescriptor(name=self.nameedit.text(), - expression=self.nameedit.text()) + expression=self.nameedit.text(), + meta=self.metaattributecb.isChecked()) def _invalidate(self): self.setModified(True) @@ -231,18 +256,20 @@ def insert_into_expression(self, what): class ContinuousFeatureEditor(FeatureEditor): - ExpressionTooltip = "A numeric expression" + ExpressionTooltip = "A numeric expression\n\n" \ + + FeatureEditor.ExpressionTooltip def editorData(self): return ContinuousDescriptor( name=self.nameedit.text(), + expression=self.expressionedit.text(), + meta=self.metaattributecb.isChecked(), number_of_decimals=None, - expression=self.expressionedit.text() ) class DateTimeFeatureEditor(FeatureEditor): - ExpressionTooltip = \ + ExpressionTooltip = FeatureEditor.ExpressionTooltip + \ "Result must be a string in ISO-8601 format " \ "(e.g. 2019-07-30T15:37:27 or a part thereof),\n" \ "or a number of seconds since Jan 1, 1970." @@ -250,12 +277,13 @@ class DateTimeFeatureEditor(FeatureEditor): def editorData(self): return DateTimeDescriptor( name=self.nameedit.text(), - expression=self.expressionedit.text() + expression=self.expressionedit.text(), + meta=self.metaattributecb.isChecked(), ) class DiscreteFeatureEditor(FeatureEditor): - ExpressionTooltip = \ + ExpressionTooltip = FeatureEditor.ExpressionTooltip + \ "Result must be a string, if values are not explicitly given\n" \ "or a zero-based integer indices into a list of values given below." @@ -271,7 +299,8 @@ def __init__(self, *args, **kwargs): layout = self.layout() label = QLabel(self.tr("Values (optional)")) label.setToolTip(tooltip) - layout.addRow(label, self.valuesedit) + layout.addWidget(label, 2, 0) + layout.addWidget(self.valuesedit, 2, 1, 1, 2) def setEditorData(self, data, domain): self.valuesedit.setText( @@ -285,6 +314,7 @@ def editorData(self): values = tuple(filter(None, [v.replace(r"\,", ",").strip() for v in values])) return DiscreteDescriptor( name=self.nameedit.text(), + meta=self.metaattributecb.isChecked(), values=values, ordered=False, expression=self.expressionedit.text() @@ -292,11 +322,18 @@ def editorData(self): class StringFeatureEditor(FeatureEditor): - ExpressionTooltip = "A string expression" + ExpressionTooltip = "A string expression\n\n" \ + + FeatureEditor.ExpressionTooltip + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.metaattributecb.setChecked(True) + self.metaattributecb.setDisabled(True) def editorData(self): return StringDescriptor( name=self.nameedit.text(), + meta=True, expression=self.expressionedit.text() ) @@ -330,43 +367,141 @@ def data(self, index, role=Qt.DisplayRole): return super().data(index, role) -class FeatureConstructorHandler(DomainContextHandler): - """Context handler that filters descriptors""" +def freevars(exp: ast.AST, env: List[str]): + """ + Return names of all free variables in a parsed (expression) AST. - def is_valid_item(self, setting, item, attrs, metas): - """Check if descriptor `item` can be used with given domain. + Parameters + ---------- + exp : ast.AST + An expression ast (ast.parse(..., mode="single")) + env : List[str] + Environment - Return True if descriptor's expression contains only - available variables and descriptors name does not clash with - existing variables. - """ - if item.name in attrs or item.name in metas: - return False + Returns + ------- + freevars : List[str] - try: - exp_ast = ast.parse(item.expression, mode="eval") - # ast.parse can return arbitrary errors, not only SyntaxError - # pylint: disable=broad-except - except Exception: - return False - - available = dict(globals()["__GLOBALS"]) - for var in attrs: - available[sanitized_name(var)] = None - for var in metas: - available[sanitized_name(var)] = None - - if freevars(exp_ast, available): - return False - return True + See also + -------- + ast + + """ + # pylint: disable=too-many-return-statements,too-many-branches + etype = type(exp) + if etype in [ast.Expr, ast.Expression]: + return freevars(exp.body, env) + elif etype == ast.BoolOp: + return sum((freevars(v, env) for v in exp.values), []) + elif etype == ast.BinOp: + return freevars(exp.left, env) + freevars(exp.right, env) + elif etype == ast.UnaryOp: + return freevars(exp.operand, env) + elif etype == ast.Lambda: + args = exp.args + assert isinstance(args, ast.arguments) + arg_names = [a.arg for a in chain(args.posonlyargs, args.args)] + arg_names += [args.vararg.arg] if args.vararg else [] + arg_names += [a.arg for a in args.kwonlyargs] if args.kwonlyargs else [] + arg_names += [args.kwarg.arg] if args.kwarg else [] + vars_ = chain.from_iterable( + freevars(e, env) for e in chain(args.defaults, args.kw_defaults) + ) + return list(vars_) + freevars(exp.body, env + arg_names) + elif etype == ast.IfExp: + return (freevars(exp.test, env) + freevars(exp.body, env) + + freevars(exp.orelse, env)) + elif etype == ast.Dict: + return sum((freevars(v, env) + for v in chain(exp.keys, exp.values)), []) + elif etype == ast.Set: + return sum((freevars(v, env) for v in exp.elts), []) + elif etype in [ast.SetComp, ast.ListComp, ast.GeneratorExp, ast.DictComp]: + env_ext = [] + vars_ = [] + for gen in exp.generators: + target_names = freevars(gen.target, []) # assigned names + vars_iter = freevars(gen.iter, env + env_ext) + env_ext += target_names + vars_ifs = list(chain(*(freevars(ifexp, env + target_names) + for ifexp in gen.ifs or []))) + vars_ += vars_iter + vars_ifs + + if etype == ast.DictComp: + vars_ = (freevars(exp.key, env_ext) + + freevars(exp.value, env_ext) + + vars_) + else: + vars_ = freevars(exp.elt, env + env_ext) + vars_ + return vars_ + # Yield, YieldFrom??? + elif etype == ast.Compare: + return sum((freevars(v, env) + for v in [exp.left] + exp.comparators), []) + elif etype == ast.Call: + return sum(map(lambda e: freevars(e, env), + chain([exp.func], + exp.args or [], + [k.value for k in exp.keywords or []])), + []) + elif etype == ast.Starred: + # a 'starred' call parameter (e.g. a and b in `f(x, *a, *b)` + return freevars(exp.value, env) + elif etype == ast.Constant: + return [] + elif etype == ast.Attribute: + return freevars(exp.value, env) + elif etype == ast.Subscript: + return freevars(exp.value, env) + freevars(exp.slice, env) + elif etype == ast.Name: + return [exp.id] if exp.id not in env else [] + elif etype == ast.List: + return sum((freevars(e, env) for e in exp.elts), []) + elif etype == ast.Tuple: + return sum((freevars(e, env) for e in exp.elts), []) + elif etype == ast.Slice: + return sum((freevars(e, env) + for e in filter(None, [exp.lower, exp.upper, exp.step])), + []) + elif etype == ast.keyword: + return freevars(exp.value, env) + else: + raise ValueError(exp) -class OWFeatureConstructor(OWWidget): - name = "Feature Constructor" +class _FeatureConstructorHandler(DomainContextHandler): + """ContextHandler for backwards compatibility only. + This widget used to have context dependent settings. This ensures the + last stored context is propagated to regular settings instead. + """ + MAX_SAVED_CONTEXTS = 1 + + def initialize(self, instance, data=None): + super().initialize(instance, data) + if instance.context_settings: + # Use the very last context + ctx = instance.context_settings[0] + def pick_first(item): + # Return first element of item if item is a tuple + if isinstance(item, tuple): + return item[0] + else: + return item + instance.descriptors = ctx.values.get("descriptors", []) + instance.expressions_with_values = pick_first( + ctx.values.get("expressions_with_values", False) + ) + instance.currentIndex = pick_first(ctx.values.get("currentIndex", -1)) + + +class OWFeatureConstructor(OWWidget, ConcurrentWidgetMixin): + name = "Formula" description = "Construct new features (data columns) from a set of " \ "existing features in the input dataset." + category = "Transform" icon = "icons/FeatureConstructor.svg" - keywords = ['function', 'lambda'] + keywords = "feature constructor, function, lambda, calculation" + priority = 2240 class Inputs: data = Input("Data", Orange.data.Table) @@ -376,9 +511,13 @@ class Outputs: want_main_area = False - settingsHandler = FeatureConstructorHandler() - descriptors = ContextSetting([]) - currentIndex = ContextSetting(-1) + # NOTE: The context handler is here for settings migration only. + settingsHandler = _FeatureConstructorHandler() + descriptors = Setting([], schema_only=True) + expressions_with_values = Setting(False, schema_only=True) + currentIndex = Setting(-1, schema_only=True) + + settings_version = 4 EDITORS = [ (ContinuousDescriptor, ContinuousFeatureEditor), @@ -390,13 +529,17 @@ class Outputs: class Error(OWWidget.Error): more_values_needed = Msg("Categorical feature {} needs more values.") invalid_expressions = Msg("Invalid expressions: {}.") + transform_error = Msg("{}") - class Warning(OWWidget.Warning): - renamed_var = Msg("Recently added variable has been renamed, " - "to avoid duplicates.\n") + class Information(OWWidget.Information): + replaces_existing = Msg( + "A new variable that is named the same as an existing one will " + "replace it on the output." + ) def __init__(self): super().__init__() + ConcurrentWidgetMixin.__init__(self) self.data = None self.editors = {} @@ -428,33 +571,36 @@ def __init__(self): shortcut=QKeySequence.New ) + def reserved_names(): + return set(desc.name for desc in self.featuremodel) + def unique_name(fmt, reserved): candidates = (fmt.format(i) for i in count(1)) return next(c for c in candidates if c not in reserved) def generate_newname(fmt): - return unique_name(fmt, self.reserved_names()) + return unique_name(fmt, reserved_names()) menu = QMenu(self.addbutton) cont = menu.addAction("Numeric") cont.triggered.connect( lambda: self.addFeature( - ContinuousDescriptor(generate_newname("X{}"), "", 3)) + ContinuousDescriptor(generate_newname("X{}"), "", 3, meta=False)) ) disc = menu.addAction("Categorical") disc.triggered.connect( lambda: self.addFeature( - DiscreteDescriptor(generate_newname("D{}"), "", (), False)) + DiscreteDescriptor(generate_newname("D{}"), "", (), False, meta=False)) ) string = menu.addAction("Text") string.triggered.connect( lambda: self.addFeature( - StringDescriptor(generate_newname("S{}"), "")) + StringDescriptor(generate_newname("S{}"), "", meta=True)) ) datetime = menu.addAction("Date/Time") datetime.triggered.connect( lambda: self.addFeature( - DateTimeDescriptor(generate_newname("T{}"), "")) + DateTimeDescriptor(generate_newname("T{}"), "", meta=False)) ) menu.addSeparator() @@ -478,7 +624,7 @@ def generate_newname(fmt): toplayout.addWidget(self.editorstack, 10) # Layout for the list view - layout = QVBoxLayout(spacing=1, margin=0) + layout = QVBoxLayout(spacing=1) self.featuremodel = DescriptorModel(parent=self) self.featureview = QListView( @@ -497,8 +643,15 @@ def generate_newname(fmt): box.layout().addLayout(layout, 1) + self.fix_button = gui.button( + self.buttonsArea, self, "Upgrade Expressions", + callback=self.fix_expressions) + self.fix_button.setHidden(True) gui.button(self.buttonsArea, self, "Send", callback=self.apply, default=True) + if self.descriptors: + self.featuremodel[:] = list(self.descriptors) + def setCurrentIndex(self, index): index = min(index, len(self.featuremodel) - 1) self.currentIndex = index @@ -521,19 +674,17 @@ def _on_selectedVariableChanged(self, selected, *_): def _on_modified(self): if self.currentIndex >= 0: - self.Warning.clear() + self.Information.clear() editor = self.editorstack.currentWidget() - proposed = editor.editorData().name - unique = get_unique_names(self.reserved_names(self.currentIndex), - proposed) - - feature = editor.editorData() - if editor.editorData().name != unique: - self.Warning.renamed_var() - feature = feature.__class__(unique, *feature[1:]) - - self.featuremodel[self.currentIndex] = feature + self.featuremodel[self.currentIndex] = editor.editorData() self.descriptors = list(self.featuremodel) + editor_names = [v.name for v in self.descriptors] + if self.data is not None: + exist_names = [v.name for v in self.data.domain] + else: + exist_names = [] + if set(editor_names).intersection(exist_names): + self.Information.replaces_existing() def setDescriptors(self, descriptors): """ @@ -542,50 +693,27 @@ def setDescriptors(self, descriptors): self.descriptors = descriptors self.featuremodel[:] = list(self.descriptors) - def reserved_names(self, idx_=None): - varnames = [] - if self.data is not None: - varnames = [var.name for var in - self.data.domain.variables + self.data.domain.metas] - varnames += [desc.name for idx, desc in enumerate(self.featuremodel) - if idx != idx_] - return set(varnames) - @Inputs.data @check_sql_input def setData(self, data=None): """Set the input dataset.""" - self.closeContext() - self.data = data + self.setCurrentIndex(self.currentIndex) # Update editor - if self.data is not None: - descriptors = list(self.descriptors) - currindex = self.currentIndex - self.descriptors = [] - self.currentIndex = -1 - self.openContext(data) - - if descriptors != self.descriptors or \ - self.currentIndex != currindex: - # disconnect from the selection model while reseting the model - selmodel = self.featureview.selectionModel() - selmodel.selectionChanged.disconnect( - self._on_selectedVariableChanged) - - self.featuremodel[:] = list(self.descriptors) - self.setCurrentIndex(self.currentIndex) - - selmodel.selectionChanged.connect( - self._on_selectedVariableChanged) - + self.fix_button.setHidden(not self.expressions_with_values) self.editorstack.setEnabled(self.currentIndex >= 0) def handleNewSignals(self): if self.data is not None: self.apply() else: + self.cancel() self.Outputs.data.send(None) + self.fix_button.setHidden(True) + + def onDeleteWidget(self): + self.shutdown() + super().onDeleteWidget() def addFeature(self, descriptor): self.featuremodel.append(descriptor) @@ -613,11 +741,14 @@ def duplicateFeature(self): @staticmethod def check_attrs_values(attr, data): - for i in range(len(data)): - for var in attr: - if not math.isnan(data[i, var]) \ - and int(data[i, var]) >= len(var.values): - return var.name + for var in attr: + col = data.get_column(var) + mask = ~np.isnan(col) + grater_or_equal = np.greater_equal( + col, len(var.values), out=mask, where=mask + ) + if grater_or_equal.any(): + return var.name return None def _validate_descriptors(self, desc): @@ -645,46 +776,17 @@ def validate(source): return final def apply(self): - def report_error(err): - log = logging.getLogger(__name__) - log.error("", exc_info=True) - self.error("".join(format_exception_only(type(err), err)).rstrip()) - + self.cancel() self.Error.clear() - if self.data is None: return desc = list(self.featuremodel) desc = self._validate_descriptors(desc) - try: - new_variables = construct_variables(desc, self.data) - # user's expression can contain arbitrary errors - except Exception as err: # pylint: disable=broad-except - report_error(err) - return - - attrs = [var for var in new_variables if var.is_primitive()] - metas = [var for var in new_variables if not var.is_primitive()] - new_domain = Orange.data.Domain( - self.data.domain.attributes + tuple(attrs), - self.data.domain.class_vars, - metas=self.data.domain.metas + tuple(metas) - ) - - try: - for variable in new_variables: - variable.compute_value.mask_exceptions = False - data = self.data.transform(new_domain) - # user's expression can contain arbitrary errors - # pylint: disable=broad-except - except Exception as err: - report_error(err) - return - finally: - for variable in new_variables: - variable.compute_value.mask_exceptions = True + self.start(run, self.data, desc, self.expressions_with_values) + def on_done(self, result: "Result") -> None: + data, attrs = result.data, result.new_variables disc_attrs_not_ok = self.check_attrs_values( [var for var in attrs if var.is_discrete], data) if disc_attrs_not_ok: @@ -693,142 +795,165 @@ def report_error(err): self.Outputs.data.send(data) + def on_exception(self, ex: Exception): + self.Error.transform_error( + "".join(format_exception_only(type(ex), ex)).rstrip(), + exc_info=ex + ) + self.Outputs.data.send(None) + + def on_partial_result(self, _): + pass + def send_report(self): items = OrderedDict() for feature in self.featuremodel: if isinstance(feature, DiscreteDescriptor): - items[feature.name] = "{} (categorical with values {}{})".format( - feature.expression, feature.values, - "; ordered" * feature.ordered) + desc = "categorical" + if feature.values: + desc += " with values " \ + + ", ".join(f"'{val}'" for val in feature.values) + if feature.ordered: + desc += "; ordered" elif isinstance(feature, ContinuousDescriptor): - items[feature.name] = "{} (numeric)".format(feature.expression) + desc = "numeric" elif isinstance(feature, DateTimeDescriptor): - items[feature.name] = "{} (date/time)".format(feature.expression) + desc = "date/time" else: - items[feature.name] = "{} (text)".format(feature.expression) - self.report_items( - report.plural("Constructed feature{s}", len(items)), items) + desc = "text" + items[feature.name] = f"{feature.expression} ({desc})" + self.report_items(f"Constructed {pl(len(items), 'feature')}", items) + + def fix_expressions(self): + dlg = QMessageBox( + QMessageBox.Question, + "Fix Expressions", + "This widget's behaviour has changed. Values of categorical " + "variables are now inserted as their textual representations " + "(strings); previously they appeared as integer numbers, with an " + "attribute '.value' that contained the text.\n\n" + "The widget currently runs in compatibility mode. After " + "expressions are updated, manually check for their correctness.") + dlg.addButton("Update", QMessageBox.ApplyRole) + dlg.addButton("Cancel", QMessageBox.RejectRole) + if dlg.exec() == QMessageBox.RejectRole: + return + def fixer(mo): + var = domain[mo.group(2)] + if mo.group(3) == ".value": # uses string; remove `.value` + return "".join(mo.group(1, 2, 4)) + # Uses ints: get them by indexing + return mo.group(1) + "{" + \ + ", ".join(f"'{val}': {i}" + for i, val in enumerate(var.values)) + \ + f"}}[{var.name}]" + mo.group(4) + + domain = self.data.domain + disc_vars = "|".join(f"{var.name}" + for var in chain(domain.variables, domain.metas) + if var.is_discrete) + expr = re.compile(r"(^|\W)(" + disc_vars + r")(\.value)?(\W|$)") + self.descriptors[:] = [ + descriptor._replace( + expression=expr.sub(fixer, descriptor.expression)) + for descriptor in self.descriptors] + + self.expressions_with_values = False + self.fix_button.hide() + index = self.currentIndex + self.featuremodel[:] = list(self.descriptors) + self.setCurrentIndex(index) + self.apply() + + @classmethod + def migrate_context(cls, context, version): + if version is None or version < 2: + used_vars = set(chain(*( + freevars(ast.parse(descriptor.expression, mode="eval"), []) + for descriptor in context.values["descriptors"] + if descriptor.expression))) + disc_vars = {name + for (name, vtype) in chain(context.attributes.items(), + context.metas.items()) + if vtype == 1} + if used_vars & disc_vars: + context.values["expressions_with_values"] = True + + +@dataclass +class Result: + data: Table + new_variables: List[Variable] + + +def run(data: Table, desc, use_values, task: TaskState) -> Result: + if task.is_interruption_requested(): + raise CancelledError # pragma: no cover + new_variables = construct_variables(desc, data, use_values) + # Explicit cancellation point after `construct_variables` which can + # already run `compute_value`. + if task.is_interruption_requested(): + raise CancelledError # pragma: no cover + attrs, class_vars, metas = [], [], [] + metas_new = [] + new_vars = {v.name: (d, v) for d, v in zip(desc, new_variables)} + + def append_replace_move(vars_: List[Variable], var: Variable, moving=True): + """ + Append, replace or move to metas the `var` to `vars_` list + depending on if an entry exist in the `new_vars` (note: `new_vars` is + modified in place). If `moving` is `False` move to metas is not + allowed i.e. we are processing metas themselves. + """ + new = new_vars.get(var.name, None) + if new is not None: # replacing an existing variable + d, new_var = new + if d.meta and moving: # moving to meta + metas_new.append(new_var) + else: + vars_.append(new_var) + new_vars.pop(var.name) + else: + vars_.append(var) -def freevars(exp, env): - """ - Return names of all free variables in a parsed (expression) AST. + for var in data.domain.attributes: + append_replace_move(attrs, var) - Parameters - ---------- - exp : ast.AST - An expression ast (ast.parse(..., mode="single")) - env : List[str] - Environment + for var in data.domain.class_vars: + append_replace_move(class_vars, var) - Returns - ------- - freevars : List[str] + for var in data.domain.metas: + append_replace_move(metas, var, moving=False) - See also - -------- - ast + # existing names that were moved to metas will precede new named vars. + metas.extend(metas_new) + # remaining `new_vars` entries are new; append to corresponding variables + # list + attrs.extend(v for d, v in new_vars.values() if not d.meta) + metas.extend(v for d, v in new_vars.values() if d.meta) - """ - # pylint: disable=too-many-return-statements,too-many-branches - etype = type(exp) - if etype in [ast.Expr, ast.Expression]: - return freevars(exp.body, env) - elif etype == ast.BoolOp: - return sum((freevars(v, env) for v in exp.values), []) - elif etype == ast.BinOp: - return freevars(exp.left, env) + freevars(exp.right, env) - elif etype == ast.UnaryOp: - return freevars(exp.operand, env) - elif etype == ast.Lambda: - args = exp.args - assert isinstance(args, ast.arguments) - argnames = [a.arg for a in args.args] - argnames += [args.vararg.arg] if args.vararg else [] - argnames += [a.arg for a in args.kwonlyargs] if args.kwonlyargs else [] - argnames += [args.kwarg] if args.kwarg else [] - return freevars(exp.body, env + argnames) - elif etype == ast.IfExp: - return (freevars(exp.test, env) + freevars(exp.body, env) + - freevars(exp.orelse, env)) - elif etype == ast.Dict: - return sum((freevars(v, env) - for v in chain(exp.keys, exp.values)), []) - elif etype == ast.Set: - return sum((freevars(v, env) for v in exp.elts), []) - elif etype in [ast.SetComp, ast.ListComp, ast.GeneratorExp, ast.DictComp]: - env_ext = [] - vars_ = [] - for gen in exp.generators: - target_names = freevars(gen.target, []) # assigned names - vars_iter = freevars(gen.iter, env) - env_ext += target_names - vars_ifs = list(chain(*(freevars(ifexp, env + target_names) - for ifexp in gen.ifs or []))) - vars_ += vars_iter + vars_ifs - - if etype == ast.DictComp: - vars_ = (freevars(exp.key, env_ext) + - freevars(exp.value, env_ext) + - vars_) - else: - vars_ = freevars(exp.elt, env + env_ext) + vars_ - return vars_ - # Yield, YieldFrom??? - elif etype == ast.Compare: - return sum((freevars(v, env) - for v in [exp.left] + exp.comparators), []) - elif etype == ast.Call: - return sum(map(lambda e: freevars(e, env), - chain([exp.func], - exp.args or [], - [k.value for k in exp.keywords or []])), - []) - elif etype == ast.Starred: - # a 'starred' call parameter (e.g. a and b in `f(x, *a, *b)` - return freevars(exp.value, env) - elif etype in [ast.Num, ast.Str, ast.Ellipsis, ast.Bytes, ast.NameConstant]: - return [] - elif etype == ast.Constant: - return [] - elif etype == ast.Attribute: - return freevars(exp.value, env) - elif etype == ast.Subscript: - return freevars(exp.value, env) + freevars(exp.slice, env) - elif etype == ast.Name: - return [exp.id] if exp.id not in env else [] - elif etype == ast.List: - return sum((freevars(e, env) for e in exp.elts), []) - elif etype == ast.Tuple: - return sum((freevars(e, env) for e in exp.elts), []) - elif etype == ast.Slice: - return sum((freevars(e, env) - for e in filter(None, [exp.lower, exp.upper, exp.step])), - []) - elif etype == ast.ExtSlice: - return sum((freevars(e, env) for e in exp.dims), []) - elif etype == ast.Index: - return freevars(exp.value, env) - elif etype == ast.keyword: - return freevars(exp.value, env) - else: - raise ValueError(exp) + new_domain = Orange.data.Domain(attrs, class_vars, metas) + try: + for variable in new_variables: + variable.compute_value.mask_exceptions = False + data = data.transform(new_domain) + finally: + for variable in new_variables: + variable.compute_value.mask_exceptions = True + return Result(data, list(new_variables)) def validate_exp(exp): """ Validate an `ast.AST` expression. - Only expressions with no list,set,dict,generator comprehensions - are accepted. - Parameters ---------- exp : ast.AST A parsed abstract syntax tree - """ - # pylint: disable=too-many-branches + # pylint: disable=too-many-branches,too-many-return-statements if not isinstance(exp, ast.AST): raise TypeError("exp is not a 'ast.AST' instance") @@ -841,12 +966,21 @@ def validate_exp(exp): return all(map(validate_exp, [exp.left, exp.right])) elif etype == ast.UnaryOp: return validate_exp(exp.operand) + elif etype == ast.Lambda: + return all(validate_exp(e) for e in exp.args.defaults) and \ + all(validate_exp(e) for e in exp.args.kw_defaults) and \ + validate_exp(exp.body) elif etype == ast.IfExp: return all(map(validate_exp, [exp.test, exp.body, exp.orelse])) elif etype == ast.Dict: return all(map(validate_exp, chain(exp.keys, exp.values))) elif etype == ast.Set: return all(map(validate_exp, exp.elts)) + elif etype in (ast.SetComp, ast.ListComp, ast.GeneratorExp): + return validate_exp(exp.elt) and all(map(validate_exp, exp.generators)) + elif etype == ast.DictComp: + return validate_exp(exp.key) and validate_exp(exp.value) and \ + all(map(validate_exp, exp.generators)) elif etype == ast.Compare: return all(map(validate_exp, [exp.left] + exp.comparators)) elif etype == ast.Call: @@ -854,10 +988,7 @@ def validate_exp(exp): [k.value for k in exp.keywords or []]) return all(map(validate_exp, subexp)) elif etype == ast.Starred: - assert isinstance(exp.ctx, ast.Load) return validate_exp(exp.value) - elif etype in [ast.Num, ast.Str, ast.Bytes, ast.Ellipsis, ast.NameConstant]: - return True elif etype == ast.Constant: return True elif etype == ast.Attribute: @@ -865,7 +996,6 @@ def validate_exp(exp): elif etype == ast.Subscript: return all(map(validate_exp, [exp.value, exp.slice])) elif etype in {ast.List, ast.Tuple}: - assert isinstance(exp.ctx, ast.Load) return all(map(validate_exp, exp.elts)) elif etype == ast.Name: return True @@ -878,19 +1008,21 @@ def validate_exp(exp): return validate_exp(exp.value) elif etype == ast.keyword: return validate_exp(exp.value) + elif etype == ast.comprehension and not exp.is_async: + return validate_exp(exp.target) and validate_exp(exp.iter) and \ + all(map(validate_exp, exp.ifs)) else: raise ValueError(exp) -def construct_variables(descriptions, data): - # subs - variables = [] +def construct_variables(descriptions, data, use_values=False): + variables: List[Variable] = [] source_vars = data.domain.variables + data.domain.metas for desc in descriptions: - desc, func = bind_variable(desc, source_vars, data) + desc, func = bind_variable(desc, source_vars, data, use_values) var = make_variable(desc, func) variables.append(var) - return variables + return tuple(variables) def sanitized_name(name): @@ -900,7 +1032,7 @@ def sanitized_name(name): return sanitized -def bind_variable(descriptor, env, data): +def bind_variable(descriptor, env, data, use_values): """ (descriptor, env) -> (descriptor, (instance -> value) | (table -> value list)) @@ -917,36 +1049,71 @@ def bind_variable(descriptor, env, data): values = {} cast = None - nan = float("nan") + dtype = object if isinstance(descriptor, StringDescriptor) else float if isinstance(descriptor, DiscreteDescriptor): if not descriptor.values: - str_func = FeatureFunc(descriptor.expression, source_vars) + str_func = FeatureFunc(descriptor.expression, source_vars, + use_values=use_values) values = sorted({str(x) for x in str_func(data)}) values = {name: i for i, name in enumerate(values)} descriptor = descriptor._replace(values=values) - - def cast(x): # pylint: disable=function-redefined - return values.get(x, nan) - + cast = MappingTransformCast(values) else: values = [sanitized_name(v) for v in descriptor.values] values = {name: i for i, name in enumerate(values)} if isinstance(descriptor, DateTimeDescriptor): - parse = Orange.data.TimeVariable("_").parse - - def cast(e): # pylint: disable=function-redefined - if isinstance(e, (int, float)): - return e - if e == "" or e is None: - return np.nan - return parse(e) + cast = DateTimeCast() - func = FeatureFunc(descriptor.expression, source_vars, values, cast) + func = FeatureFunc(descriptor.expression, source_vars, values, cast, + use_values=use_values, dtype=dtype) return descriptor, func +_parse_datetime = Orange.data.TimeVariable("_").parse +_cast_datetime_num_types = (int, float) + + +def cast_datetime(e): + if isinstance(e, _cast_datetime_num_types): + return e + if e == "" or e is None: + return np.nan + return _parse_datetime(e) + + +_cast_datetime = frompyfunc(cast_datetime, 1, 1, dtype=float) + + +class DateTimeCast: + def __call__(self, values): + return _cast_datetime(values) + + def __eq__(self, other): + return isinstance(other, DateTimeCast) + + def __hash__(self): + return hash(cast_datetime) + + +class MappingTransformCast: + def __init__(self, mapping: Mapping): + self.t = MappingTransform(None, mapping) + + def __reduce_ex__(self, protocol): + return type(self), (self.t.mapping, ) + + def __call__(self, values): + return self.t.transform(values) + + def __eq__(self, other): + return isinstance(other, MappingTransformCast) and self.t == other.t + + def __hash__(self): + return hash(self.t) + + def make_lambda(expression, args, env=None): # type: (ast.Expression, List[str], Dict[str, Any]) -> types.FunctionType """ @@ -1012,9 +1179,9 @@ def make_lambda(expression, args, env=None): "bin", "bool", "bytearray", "bytes", "chr", "complex", "dict", "divmod", "enumerate", "filter", "float", "format", "frozenset", "getattr", "hasattr", "hash", "hex", "id", "int", "iter", "len", - "list", "map", "memoryview", "next", "object", + "list", "map", "max", "memoryview", "min", "next", "object", "oct", "ord", "pow", "range", "repr", "reversed", "round", - "set", "slice", "sorted", "str", "tuple", "type", + "set", "slice", "sorted", "str", "sum", "tuple", "type", "zip" ] @@ -1048,9 +1215,6 @@ def make_lambda(expression, args, env=None): "nanargmin": lambda *args: np.nanargmin(args), "nanvar": lambda *args: np.nanvar(args), "mean": lambda *args: np.mean(args), - "min": lambda *args: np.min(args), - "max": lambda *args: np.max(args), - "sum": lambda *args: np.sum(args), "std": lambda *args: np.std(args), "median": lambda *args: np.median(args), "cumsum": lambda *args: np.cumsum(args), @@ -1078,7 +1242,10 @@ class FeatureFunc: A function for casting the expressions result to the appropriate type (e.g. string representation of date/time variables to floats) """ - def __init__(self, expression, args, extra_env=None, cast=None): + dtype: Optional['DType'] = None + + def __init__(self, expression, args, extra_env=None, cast=None, use_values=False, + dtype=None): self.expression = expression self.args = args self.extra_env = dict(extra_env or {}) @@ -1086,42 +1253,78 @@ def __init__(self, expression, args, extra_env=None, cast=None): [name for name, _ in args], self.extra_env) self.cast = cast self.mask_exceptions = True + self.use_values = use_values + self.dtype = dtype - def __call__(self, instance, *_): - if isinstance(instance, Orange.data.Table): - return [self(inst) for inst in instance] + def __call__(self, table, *_): + if isinstance(table, Table): + return self.__call_table(table) else: - try: - args = [str(instance[var]) - if instance.domain[var].is_string else instance[var] - for _, var in self.args] - y = self.func(*args) - # user's expression can contain arbitrary errors - # this also covers missing attributes - except: # pylint: disable=bare-except - if not self.mask_exceptions: - raise - return np.nan - if self.cast: - y = self.cast(y) - return y + return self.__call_instance(table) + + def __call_table(self, table): + try: + cols = [self.extract_column(table, var) for _, var in self.args] + except ValueError: + if self.mask_exceptions: + return np.full(len(table), np.nan) + else: + raise + + if not cols: + args = [()] * len(table) + else: + args = zip(*cols) + f = self.func + if self.mask_exceptions: + y = list(starmap(ftry(f, Exception, np.nan), args)) + else: + y = list(starmap(f, args)) + if self.cast is not None: + y = self.cast(y) + return np.asarray(y, dtype=self.dtype) + + def __call_instance(self, instance: Instance): + table = Table.from_numpy( + instance.domain, + np.array([instance.x]), + np.array([instance.y]), + np.array([instance.metas]), + ) + return self.__call_table(table)[0] + + def extract_column(self, table: Table, var: Variable): + data = table.get_column(var) + if var.is_string: + return data + elif var.is_discrete and not self.use_values: + values = np.array([*var.values, None], dtype=object) + idx = data.astype(int) + idx[~np.isfinite(data)] = len(values) - 1 + return values[idx].tolist() + elif var.is_time: # time always needs Values due to str(val) formatting + return Value._as_values(var, data.tolist()) # pylint: disable=protected-access + elif not self.use_values: + return data.tolist() + else: + return Value._as_values(var, data.tolist()) # pylint: disable=protected-access def __reduce__(self): return type(self), (self.expression, self.args, - self.extra_env, self.cast) + self.extra_env, self.cast, self.use_values, + self.dtype) def __repr__(self): return "{0.__name__}{1!r}".format(*self.__reduce__()) + def __hash__(self): + return hash((self.expression, tuple(self.args), + tuple(sorted(self.extra_env.items())), self.cast)) -def unique(seq): - seen = set() - unique_el = [] - for el in seq: - if el not in seen: - unique_el.append(el) - seen.add(el) - return unique_el + def __eq__(self, other): + return type(self) is type(other) \ + and self.expression == other.expression and self.args == other.args \ + and self.extra_env == other.extra_env and self.cast == other.cast if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/data/owfeaturestatistics.py b/Orange/widgets/data/owfeaturestatistics.py index 4e8df794059..66054a203e7 100644 --- a/Orange/widgets/data/owfeaturestatistics.py +++ b/Orange/widgets/data/owfeaturestatistics.py @@ -16,9 +16,10 @@ import scipy.sparse as sp from AnyQt.QtCore import Qt, QSize, QRectF, QModelIndex, pyqtSlot, \ QItemSelection, QItemSelectionRange, QItemSelectionModel -from AnyQt.QtGui import QPainter, QColor +from AnyQt.QtGui import QPainter, QColor, QPalette, QFontMetrics from AnyQt.QtWidgets import QStyledItemDelegate, QGraphicsScene, QTableView, \ - QHeaderView, QStyle, QStyleOptionViewItem + QHeaderView, QStyle, QStyleOptionViewItem, \ + QGraphicsView, QGraphicsItemGroup import Orange.statistics.util as ut from Orange.data import Table, StringVariable, DiscreteVariable, \ @@ -31,6 +32,8 @@ from Orange.widgets.utils.itemmodels import DomainModel, AbstractSortTableModel from Orange.widgets.utils.signals import Input, Output from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.utils import CanvasRectangle, CanvasText +from Orange.widgets.visualize.utils.plotutils import wrap_legend_items def _categorical_entropy(x): @@ -84,19 +87,19 @@ def format_time_diff(start, end, round_up_after=2): # Check which resolution is most appropriate if years >= round_up_after: - return '~%d years' % years + return f'~{years} years' elif months >= round_up_after: - return '~%d months' % months + return f'~{months} months' elif weeks >= round_up_after: - return '~%d weeks' % weeks + return f'~{weeks} weeks' elif days >= round_up_after: - return '~%d days' % days + return f'~{days} days' elif hours >= round_up_after: - return '~%d hours' % hours + return f'~{hours} hours' elif minutes >= round_up_after: - return '~%d minutes' % minutes + return f'~{minutes} minutes' else: - return '%d seconds' % seconds + return f'{seconds} seconds' class FeatureStatisticsTableModel(AbstractSortTableModel): @@ -110,15 +113,16 @@ class FeatureStatisticsTableModel(AbstractSortTableModel): HIDDEN_VAR_TYPES = (StringVariable,) class Columns(IntEnum): - ICON, NAME, DISTRIBUTION, CENTER, MEDIAN, DISPERSION, MIN, MAX, \ - MISSING = range(9) + ICON, NAME, DISTRIBUTION, CENTER, MODE, MEDIAN, DISPERSION, MIN, MAX, \ + MISSING = range(10) @property def name(self): return {self.ICON: '', - self.NAME: 'Name', + self.NAME: 'Column', self.DISTRIBUTION: 'Distribution', self.CENTER: 'Mean', + self.MODE: 'Mode', self.MEDIAN: 'Median', self.DISPERSION: 'Dispersion', self.MIN: 'Min.', @@ -159,7 +163,8 @@ def __init__(self, data=None, parent=None): no_data = np.array([]) self._variable_types = self._variable_names = no_data - self._min = self._max = self._center = self._median = no_data + self._min = self._max = no_data + self._center = self._median = self._mode = no_data self._dispersion = no_data self._missing = no_data # Clear model initially to set default values @@ -177,11 +182,12 @@ def set_data(self, data): self.domain = domain = data.domain self.target_var = None - self.__attributes = self.__filter_attributes(domain.attributes, self.table.X) - # We disable pylint warning because the `Y` property squeezes vectors, - # while we need a 2d array, which `_Y` provides - self.__class_vars = self.__filter_attributes(domain.class_vars, self.table._Y) # pylint: disable=protected-access - self.__metas = self.__filter_attributes(domain.metas, self.table.metas) + self.__attributes = self.__filter_attributes( + domain.attributes, self.table.X) + self.__class_vars = self.__filter_attributes( + domain.class_vars, self.table.Y.reshape((len(self.table.Y), -1))) + self.__metas = self.__filter_attributes( + domain.metas, self.table.metas) self.__attributes_set = set(self.__metas[0]) self.__class_vars_set = set(self.__class_vars[0]) self.__metas_set = set(self.__metas[0]) @@ -226,7 +232,7 @@ def _attr_indices(attrs): def __filter_attributes(self, attributes, matrix): """Filter out variables which shouldn't be visualized.""" - attributes, matrix = np.asarray(attributes), matrix + attributes = np.asarray(attributes) mask = [idx for idx, attr in enumerate(attributes) if not isinstance(attr, self.HIDDEN_VAR_TYPES)] return attributes[mask], matrix[:, mask] @@ -282,59 +288,54 @@ def __mode(x, *args, **kwargs): time_f=lambda x: ut.nanmean(x, axis=0), ) - self._median = self.__compute_stat( + self._mode = self.__compute_stat( matrices, discrete_f=lambda x: __mode(x, axis=0), + continuous_f=lambda x: __mode(x, axis=0), + time_f=lambda x: __mode(x, axis=0), + ) + + self._median = self.__compute_stat( + matrices, + discrete_f=None, continuous_f=lambda x: ut.nanmedian(x, axis=0), time_f=lambda x: ut.nanmedian(x, axis=0), ) - def get_statistics_matrix(self, variables=None, return_labels=False): - """Get the numeric computed statistics in a single matrix. Optionally, - we can specify for which variables we want the stats. Also, we can get - the string column names as labels if desired. - - Parameters - ---------- - variables : Iterable[Union[Variable, int, str]] - Return statistics for only the variables specified. Accepts all - formats supported by `domain.index` - return_labels : bool - In addition to the statistics matrix, also return string labels for - the columns of the matrix e.g. 'Mean' or 'Dispersion', as specified - in `Columns`. - - Returns - ------- - Union[Tuple[List[str], np.ndarray], np.ndarray] - - """ - if self.table is None: - return np.atleast_2d([]) + def get_statistics_table(self): + """Get the numeric computed statistics in a single matrix.""" + if self.table is None or not self.rowCount(): + return None - # If a list of variables is given, select only corresponding stats - # variables can be a list or array, pylint: disable=len-as-condition - if variables is not None and len(variables) != 0: - indices = [self.domain.index(var) for var in variables] + # don't match TimeVariable, pylint: disable=unidiomatic-typecheck + contivars = [type(var) is ContinuousVariable for var in self.variables] + if any(contivars): + def c(column): + return np.choose(contivars, [np.nan, column]) + + x = np.vstack(( + c(self._center), c(self._median), self._dispersion, + c(self._min), c(self._max), self._missing, + )).T + attrs = [ContinuousVariable(column.name) for column in ( + self.Columns.CENTER, self.Columns.MEDIAN, + self.Columns.DISPERSION, + self.Columns.MIN, self.Columns.MAX, self.Columns.MISSING)] else: - indices = ... + x = np.vstack((self._dispersion, self._missing)).T + attrs = [ContinuousVariable(name) + for name in ("Entropy", self.Columns.MISSING.name)] - matrix = np.vstack(( - self._center[indices], self._median[indices], - self._dispersion[indices], - self._min[indices], self._max[indices], self._missing[indices], - )).T + names = [var.name for var in self.variables] + modes = [var.str_val(val) + for var, val in zip(self.variables, self._mode)] + metas = np.vstack((names, modes)).T + meta_attrs = [StringVariable('Column'), StringVariable('Mode')] - # Return string labels for the returned matrix columns e.g. 'Mean', - # 'Dispersion' if requested - if return_labels: - labels = [self.Columns.CENTER.name, self.Columns.MEDIAN.name, - self.Columns.DISPERSION.name, - self.Columns.MIN.name, self.Columns.MAX.name, - self.Columns.MISSING.name] - return labels, matrix - - return matrix + domain = Domain(attributes=attrs, metas=meta_attrs) + statistics = Table.from_numpy(domain, x, metas=metas) + statistics.name = f'{self.table.name} (Column Statistics)' + return statistics def __compute_stat(self, matrices, discrete_f=None, continuous_f=None, time_f=None, string_f=None, default_val=np.nan): @@ -420,6 +421,13 @@ def sortColumnData(self, column): vals[disc_idx] = var_name_indices[disc_idx] vals[str_idx] = var_name_indices[str_idx] return np.vstack((var_types_indices, np.zeros_like(vals), vals)).T + # Sort by: (type, mode) + elif column == self.Columns.MODE: + # Sorting discrete or string values by mode makes no sense + vals = np.array(self._mode) + vals[disc_idx] = var_name_indices[disc_idx] + vals[str_idx] = var_name_indices[str_idx] + return np.vstack((var_types_indices, np.zeros_like(vals), vals)).T # Sort by: (type, median) elif column == self.Columns.MEDIAN: # Sorting discrete or string values by median makes no sense @@ -561,6 +569,7 @@ def render_value(value): variable=attribute, color_attribute=self.target_var, border=(0, 0, 2, 0), + bottom_padding=4, border_color='#ccc', ) scene.addItem(histogram) @@ -568,13 +577,15 @@ def render_value(value): return self.__distributions_cache[row] elif column == self.Columns.CENTER: return render_value(self._center[row]) + elif column == self.Columns.MODE: + return render_value(self._mode[row]) elif column == self.Columns.MEDIAN: return render_value(self._median[row]) elif column == self.Columns.DISPERSION: if isinstance(attribute, TimeVariable): return format_time_diff(self._min[row], self._max[row]) elif isinstance(attribute, DiscreteVariable): - return "%.3g" % self._dispersion[row] + return f"{self._dispersion[row]:.3g}" else: return render_value(self._dispersion[row]) elif column == self.Columns.MIN: @@ -584,10 +595,9 @@ def render_value(value): if not isinstance(attribute, DiscreteVariable): return render_value(self._max[row]) elif column == self.Columns.MISSING: - return '%d (%d%%)' % ( - self._missing[row], - 100 * self._missing[row] / self.n_instances - ) + missing = self._missing[row] + perc = int(round(100 * missing / self.n_instances)) + return f'{missing} ({perc} %)' return None roles = {Qt.BackgroundRole: background, @@ -622,7 +632,7 @@ def set_target_var(self, variable): class FeatureStatisticsTableView(QTableView): HISTOGRAM_ASPECT_RATIO = (7, 3) - MINIMUM_HISTOGRAM_HEIGHT = 50 + MINIMUM_HISTOGRAM_HEIGHT = 30 MAXIMUM_HISTOGRAM_HEIGHT = 80 def __init__(self, model, parent=None, **kwargs): @@ -649,15 +659,8 @@ def __init__(self, model, parent=None, **kwargs): # appears to work properly when the widget is actually shown. When the # widget is not shown, size `sizeHint` is called on every row. hheader.setResizeContentsPrecision(5) - # Set a nice default size so that headers have some space around titles - hheader.setDefaultSectionSize(100) - # Set individual column behaviour in `set_data` since the logical - # indices must be valid in the model, which requires data. - hheader.setSectionResizeMode(QHeaderView.Interactive) + self._resize_columns() - columns = model.Columns - hheader.setSectionResizeMode(columns.ICON.index, QHeaderView.ResizeToContents) - hheader.setSectionResizeMode(columns.DISTRIBUTION.index, QHeaderView.Stretch) vheader = self.verticalHeader() vheader.setVisible(False) @@ -672,6 +675,15 @@ def __init__(self, model, parent=None, **kwargs): DistributionDelegate(parent=self), ) + def _resize_columns(self): + hheader = self.horizontalHeader() + hheader.setSectionResizeMode(0, QHeaderView.ResizeToContents) + for i in range(1, hheader.count()): + hheader.setSectionResizeMode(i, QHeaderView.ResizeToContents) + width = max(100, hheader.sectionSize(i)) + hheader.setSectionResizeMode(i, QHeaderView.Interactive) + hheader.resizeSection(i, width) + def bind_histogram_aspect_ratio(self, logical_index, _, new_size): """Force the horizontal and vertical header to maintain the defined aspect ratio specified for the histogram.""" @@ -679,7 +691,7 @@ def bind_histogram_aspect_ratio(self, logical_index, _, new_size): if logical_index is not self.model().Columns.DISTRIBUTION.index: return ratio_width, ratio_height = self.HISTOGRAM_ASPECT_RATIO - unit_width = new_size / ratio_width + unit_width = new_size // ratio_width new_height = unit_width * ratio_height effective_height = max(new_height, self.MINIMUM_HISTOGRAM_HEIGHT) effective_height = min(effective_height, self.MAXIMUM_HISTOGRAM_HEIGHT) @@ -708,7 +720,7 @@ def paint(self, painter, option, index): super().paint(painter, option, index) -class DistributionDelegate(QStyledItemDelegate): +class DistributionDelegate(NoFocusRectDelegate): def paint(self, painter, option, index): # type: (QPainter, QStyleOptionViewItem, QModelIndex) -> None scene = index.data(Qt.DisplayRole) # type: Optional[QGraphicsScene] @@ -716,14 +728,6 @@ def paint(self, painter, option, index): return super().paint(painter, option, index) painter.setRenderHint(QPainter.Antialiasing) - - if option.state & QStyle.State_Selected: - background_color = option.palette.highlight() - else: - background_color = index.data(Qt.BackgroundRole) - if background_color is not None: - scene.setBackgroundBrush(background_color) - scene.render(painter, target=QRectF(option.rect), mode=Qt.IgnoreAspectRatio) # pylint complains about inconsistent return statements @@ -731,9 +735,10 @@ def paint(self, painter, option, index): class OWFeatureStatistics(widget.OWWidget): - name = 'Feature Statistics' - description = 'Show basic statistics for data features.' + name = 'Column Statistics' + description = 'Show basic statistics for columns.' icon = 'icons/FeatureStatistics.svg' + keywords = 'feature, variable' class Inputs: data = Input('Data', Table, default=True) @@ -759,13 +764,29 @@ def __init__(self): self.data = None # type: Optional[Table] - # Main area self.model = FeatureStatisticsTableModel(parent=self) self.table_view = FeatureStatisticsTableView(self.model, parent=self) self.table_view.selectionModel().selectionChanged.connect(self.on_select) self.table_view.horizontalHeader().sectionClicked.connect(self.on_header_click) - self.controlArea.layout().addWidget(self.table_view) + box = gui.vBox(self.controlArea) + box.setContentsMargins(0, 0, 0, 4) + pal = QPalette() + pal.setColor(QPalette.Window, + self.table_view.palette().color(QPalette.Base)) + box.setAutoFillBackground(True) + box.setPalette(pal) + + box.layout().addWidget(self.table_view) + + self.legend_items = [] + self.legend = QGraphicsScene() + self.legend_view = u = QGraphicsView(self.legend) + u.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing) + u.setFrameStyle(QGraphicsView.NoFrame) + u.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + u.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + box.layout().addWidget(u) self.color_var_model = DomainModel( valid_types=(ContinuousVariable, DiscreteVariable), @@ -782,9 +803,13 @@ def __init__(self): gui.auto_send(self.buttonsArea, self, "auto_commit") @staticmethod - def sizeHint(): + def sizeHint(): # pylint: disable=arguments-differ return QSize(1050, 500) + def resizeEvent(self, event): + super().resizeEvent(event) + self.update_legend() + @Inputs.data def set_data(self, data): # Clear outputs and reset widget state @@ -795,6 +820,8 @@ def set_data(self, data): self.Outputs.statistics.send(None) # Setup widget state for new data and restore settings + if not data: + data = None self.data = data if data is not None: @@ -812,7 +839,8 @@ def set_data(self, data): self.__restore_sorting() self.__color_var_changed() - self.commit() + self.commit_statistics() + self.commit.now() def __restore_selection(self): """Restore the selection on the table view from saved settings.""" @@ -846,33 +874,64 @@ def on_header_click(self, *_): def __color_var_changed(self, *_): if self.model is not None: self.model.set_target_var(self.color_var) + self.update_legend_items() + self.update_legend() + + def update_legend_items(self): + self.legend.clear() + if self.color_var is None or not self.color_var.is_discrete: + self.legend_items = [] + return + self.legend_items = [] + size = QFontMetrics(self.font()).height() + for name, color in zip(self.color_var.values, self.color_var.palette.qcolors): + item = QGraphicsItemGroup() + item.addToGroup( + CanvasRectangle(None, -size / 2, -size / 2, size, size, + Qt.gray, color)) + item.addToGroup( + CanvasText(None, name, size, 0, Qt.AlignVCenter)) + self.legend_items.append(item) + + def update_legend(self): + view = self.legend_view + + if not self.legend_items: + self.legend.clear() + view.hide() + return + + size = QFontMetrics(self.font()).height() + legend = wrap_legend_items( + self.legend_items, + self.width() - 30, size, size * 1.75) + self.legend.addItem(legend) + legend.setPos(15, 0) + view.setFixedHeight(int(legend.boundingRect().height()) + size) + view.show() def on_select(self): selection_indices = list(self.model.mapToSourceRows([ i.row() for i in self.table_view.selectionModel().selectedRows() ])) self.selected_vars = list(self.model.variables[selection_indices]) - self.commit() + self.commit.deferred() + @gui.deferred def commit(self): if not self.selected_vars: self.Outputs.reduced_data.send(None) + else: + # Send a table with only selected columns to output + self.Outputs.reduced_data.send(self.data[:, self.selected_vars]) + + def commit_statistics(self): + if not self.data: self.Outputs.statistics.send(None) return - # Send a table with only selected columns to output - variables = self.selected_vars - self.Outputs.reduced_data.send(self.data[:, variables]) - # Send the statistics of the selected variables to ouput - labels, data = self.model.get_statistics_matrix(variables, return_labels=True) - var_names = np.atleast_2d([var.name for var in variables]).T - domain = Domain( - attributes=[ContinuousVariable(name) for name in labels], - metas=[StringVariable('Feature')] - ) - statistics = Table(domain, data, metas=var_names) - statistics.name = '%s (Feature Statistics)' % self.data.name + statistics = self.model.get_statistics_table() self.Outputs.statistics.send(statistics) def send_report(self): diff --git a/Orange/widgets/data/owfile.py b/Orange/widgets/data/owfile.py index 86d650a4793..f6b18c8b086 100644 --- a/Orange/widgets/data/owfile.py +++ b/Orange/widgets/data/owfile.py @@ -2,32 +2,42 @@ import logging from itertools import chain from urllib.parse import urlparse -from typing import List +from typing import List, Dict, Any import numpy as np from AnyQt.QtWidgets import \ QStyle, QComboBox, QMessageBox, QGridLayout, QLabel, \ QLineEdit, QSizePolicy as Policy, QCompleter -from AnyQt.QtCore import Qt, QTimer, QSize +from AnyQt.QtCore import Qt, QTimer, QSize, QUrl + +from orangewidget.utils.filedialogs import format_filter +from orangewidget.workflow.drophandler import SingleUrlDropHandler from Orange.data.table import Table, get_sample_datasets_dir from Orange.data.io import FileFormat, UrlReader, class_from_qualified_name +from Orange.data.io_base import MissingReaderException from Orange.util import log_warnings from Orange.widgets import widget, gui +from Orange.widgets.utils.localization import pl from Orange.widgets.settings import Setting, ContextSetting, \ PerfectDomainContextHandler, SettingProvider from Orange.widgets.utils.domaineditor import DomainEditor from Orange.widgets.utils.itemmodels import PyListModel from Orange.widgets.utils.filedialogs import RecentPathsWComboMixin, \ - open_filename_dialog + open_filename_dialog, stored_recent_paths_prepend +from Orange.widgets.utils.filedialogs import OWUrlDropBase from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Output, Msg +from Orange.widgets.utils.combobox import TextEditCombo +from Orange.widgets.utils.state_summary import missing_values + # Backward compatibility: class RecentPath used to be defined in this module, # and it is used in saved (pickled) settings. It must be imported into the # module's namespace so that old saved settings still work from Orange.widgets.utils.filedialogs import RecentPath +DEFAULT_READER_TEXT = "Determine type from the file extension" log = logging.getLogger(__name__) @@ -72,7 +82,7 @@ def focusInEvent(self, event): QTimer.singleShot(0, self.selectAll) -class OWFile(widget.OWWidget, RecentPathsWComboMixin): +class OWFile(OWUrlDropBase, RecentPathsWComboMixin): name = "File" id = "orange.widgets.data.file" description = "Read data from an input file or network " \ @@ -80,7 +90,7 @@ class OWFile(widget.OWWidget, RecentPathsWComboMixin): icon = "icons/File.svg" priority = 10 category = "Data" - keywords = ["file", "load", "read", "open"] + keywords = "file, load, read, open" class Outputs: data = Output("Data", Table, @@ -120,6 +130,9 @@ class Outputs: domain_editor = SettingProvider(DomainEditor) + class Information(widget.OWWidget.Information): + no_file_selected = Msg("No file selected.") + class Warning(widget.OWWidget.Warning): file_too_big = Msg("The file is too large to load automatically." " Press Reload to load.") @@ -133,11 +146,11 @@ class Warning(widget.OWWidget.Warning): class Error(widget.OWWidget.Error): file_not_found = Msg("File not found.") missing_reader = Msg("Missing reader.") + select_file_type = Msg("Select file type.") sheet_error = Msg("Error listing available sheets.") unknown = Msg("Read error:\n{}") - - class NoFileSelected: - pass + unknown_select = Msg( + "Read error, possibly due to incorrect choice of file type:\n{}") UserAdviceMessages = [ widget.Message( @@ -159,6 +172,23 @@ def __init__(self): self.loaded_file = "" self.reader = None + readers = [f for f in FileFormat.formats + if getattr(f, 'read', None) + and getattr(f, "EXTENSIONS", None)] + + def group_readers_per_addon_key(w): + # readers from Orange.data.io should go first + def package(w): + package = w.qualified_name().split(".")[:-1] + package = package[:2] + if ".".join(package) == "Orange.data": + return ["0"] # force "Orange" to come first + return package + return package(w), w.DESCRIPTION + + self.available_readers = sorted(set(readers), + key=group_readers_per_addon_key) + layout = QGridLayout() layout.setSpacing(4) gui.widgetBox(self.controlArea, orientation=layout, box='Source') @@ -169,8 +199,9 @@ def __init__(self): layout.addWidget(rb_button, 0, 0, Qt.AlignVCenter) box = gui.hBox(None, addToLayout=False, margin=0) - box.setSizePolicy(Policy.MinimumExpanding, Policy.Fixed) - self.file_combo.setSizePolicy(Policy.MinimumExpanding, Policy.Fixed) + box.setSizePolicy(Policy.Expanding, Policy.Fixed) + self.file_combo.setSizePolicy(Policy.Expanding, Policy.Fixed) + self.file_combo.setMinimumSize(QSize(100, 1)) self.file_combo.activated[int].connect(self.select_file) box.layout().addWidget(self.file_combo) layout.addWidget(box, 0, 1) @@ -190,9 +221,9 @@ def __init__(self): self.sheet_box = gui.hBox(None, addToLayout=False, margin=0) self.sheet_combo = QComboBox() - self.sheet_combo.activated[str].connect(self.select_sheet) - self.sheet_combo.setSizePolicy( - Policy.MinimumExpanding, Policy.Fixed) + self.sheet_combo.textActivated.connect(self.select_sheet) + self.sheet_combo.setSizePolicy(Policy.Expanding, Policy.Fixed) + self.sheet_combo.setMinimumSize(QSize(50, 1)) self.sheet_label = QLabel() self.sheet_label.setText('Sheet') self.sheet_label.setSizePolicy( @@ -207,16 +238,16 @@ def __init__(self): rb_button = gui.appendRadioButton(vbox, "URL:", addToLayout=False) layout.addWidget(rb_button, 3, 0, Qt.AlignVCenter) - self.url_combo = url_combo = QComboBox() + self.url_combo = url_combo = TextEditCombo() url_model = NamedURLModel(self.sheet_names) url_model.wrap(self.recent_urls) url_combo.setLineEdit(LineEditSelectOnFocus()) url_combo.setModel(url_model) url_combo.setSizePolicy(Policy.Ignored, Policy.Fixed) - url_combo.setEditable(True) url_combo.setInsertPolicy(url_combo.InsertAtTop) url_edit = url_combo.lineEdit() - l, t, r, b = url_edit.getTextMargins() + margins = url_edit.textMargins() + l, t, r, b = margins.left(), margins.top(), margins.right(), margins.bottom() url_edit.setTextMargins(l + 5, t, r, b) layout.addWidget(url_combo, 3, 1, 1, 3) url_combo.activated.connect(self._url_set) @@ -226,9 +257,22 @@ def __init__(self): completer.setCaseSensitivity(Qt.CaseSensitive) url_combo.setCompleter(completer) + layout = QGridLayout() + layout.setSpacing(4) + gui.widgetBox(self.controlArea, orientation=layout, box='File Type') + + box = gui.hBox(None, addToLayout=False, margin=0) + box.setSizePolicy(Policy.Expanding, Policy.Fixed) + self.reader_combo = QComboBox(self) + self.reader_combo.setSizePolicy(Policy.Expanding, Policy.Fixed) + self.reader_combo.setMinimumSize(QSize(100, 1)) + self.reader_combo.activated[int].connect(self.on_reader_change) + + box.layout().addWidget(self.reader_combo) + layout.addWidget(box, 0, 1) + box = gui.vBox(self.controlArea, "Info") self.infolabel = gui.widgetLabel(box, 'No data loaded.') - self.warnings = gui.widgetLabel(box, '') box = gui.widgetBox(self.controlArea, "Columns (Double click to edit)") self.domain_editor = DomainEditor(self) @@ -270,8 +314,7 @@ def __init__(self): QTimer.singleShot(0, self.load_data) - @staticmethod - def sizeHint(): + def sizeHint(self): return QSize(600, 550) def select_file(self, n): @@ -283,18 +326,42 @@ def select_file(self, n): self.set_file_list() def select_sheet(self): + # pylint: disable=unsubscriptable-object self.recent_paths[0].sheet = self.sheet_combo.currentText() self.load_data() + def on_reader_change(self, n): + self.select_reader(n) + self.load_data() + + def select_reader(self, n): + if self.source != self.LOCAL_FILE: + return # ignore for URL's + + if self.recent_paths: + path = self.recent_paths[0] # pylint: disable=unsubscriptable-object + if n == 0: # default + path.file_format = None + elif n <= len(self.available_readers): + reader = self.available_readers[n - 1] + path.file_format = reader.qualified_name() + else: # the rest include just qualified names + path.file_format = self.reader_combo.itemText(n) + def _url_set(self): + index = self.url_combo.currentIndex() url = self.url_combo.currentText() - pos = self.recent_urls.index(url) url = url.strip() if not urlparse(url).scheme: url = 'http://' + url - self.url_combo.setItemText(pos, url) - self.recent_urls[pos] = url + self.url_combo.setItemText(index, url) + + if index != 0: + model = self.url_combo.model() + root = self.url_combo.rootModelIndex() + model.moveRow(root, index, root, 0) + assert self.url_combo.currentIndex() == 0 self.source = self.URL self.load_data() @@ -310,14 +377,14 @@ def browse_file(self, in_demos=False): else: start_file = self.last_path() or os.path.expanduser("~/") - readers = [f for f in FileFormat.formats - if getattr(f, 'read', None) - and getattr(f, "EXTENSIONS", None)] - filename, reader, _ = open_filename_dialog(start_file, None, readers) + filename, reader, _ = open_filename_dialog( + start_file, None, self.available_readers, + add_all="*") if not filename: return self.add_path(filename) if reader is not None: + # pylint: disable=unsubscriptable-object self.recent_paths[0].file_format = reader.qualified_name() self.source = self.LOCAL_FILE @@ -342,19 +409,33 @@ def load_data(self): self.infolabel.setText("No data.") def _try_load(self): + self._initialize_reader_combo() + # pylint: disable=broad-except - if self.last_path() and not os.path.exists(self.last_path()): - return self.Error.file_not_found + if self.source == self.LOCAL_FILE: + if self.last_path() is None: + return self.Information.no_file_selected + elif not os.path.exists(self.last_path()): + return self.Error.file_not_found + else: + url = self.url_combo.currentText().strip() + if not url: + return self.Information.no_file_selected try: - self.reader = self._get_reader() + self.reader = self._get_reader() # also sets current reader index assert self.reader is not None - except Exception: - return self.Error.missing_reader - - if self.reader is self.NoFileSelected: - self.Outputs.data.send(None) - return None + except MissingReaderException: + if self.reader_combo.currentIndex() > 0: + return self.Error.missing_reader + else: + return self.Error.select_file_type + except Exception as ex: + log.exception(ex) + if self.reader_combo.currentIndex() > 0: + return lambda x=ex: self.Error.unknown(str(x)) + else: + return lambda x=ex: self.Error.unknown_select(str(x)) try: self._update_sheet_combo() @@ -380,25 +461,62 @@ def _try_load(self): return None def _get_reader(self) -> FileFormat: + """ + Get the reader for the current file. + + For local files, this also observes the stored settings and the reader + combo, as follows: + + 1. If the file format is known (from stored settings), use it and set + the reader combo to the corresponding index (as in settings) + 2. Otherwise, detect it from the extension and set the combo to + Auto detect, overriding any previous user-set choice + 3. Otherwise, use the current combo state. + + Returns: + FileFormat: reader instance + """ if self.source == self.LOCAL_FILE: path = self.last_path() - if path is None: - return self.NoFileSelected + self.reader_combo.setEnabled(True) + + # pylint: disable=unsubscriptable-object if self.recent_paths and self.recent_paths[0].file_format: qname = self.recent_paths[0].file_format - reader_class = class_from_qualified_name(qname) + qname_index = {r.qualified_name(): i for i, r in enumerate(self.available_readers)} + if qname in qname_index: + self.reader_combo.setCurrentIndex(qname_index[qname] + 1) + else: + # reader may be accessible, but not in self.available_readers + # (perhaps its code was moved) + self.reader_combo.addItem(qname) + self.reader_combo.setCurrentIndex(len(self.reader_combo) - 1) + try: + reader_class = class_from_qualified_name(qname) + except Exception as ex: + raise MissingReaderException(f'Can not find reader "{qname}"') from ex reader = reader_class(path) + else: - reader = FileFormat.get_reader(path) + old_idx = self.reader_combo.currentIndex() + try: + self.reader_combo.setCurrentIndex(0) + reader = FileFormat.get_reader(path) + except MissingReaderException: + if old_idx == 0: + raise + # Set the path for the current file format, + # and repeat the call to return the corresponding reader + self.select_reader(old_idx) + return self._get_reader() + + # pylint: disable=unsubscriptable-object if self.recent_paths and self.recent_paths[0].sheet: reader.select_sheet(self.recent_paths[0].sheet) return reader else: url = self.url_combo.currentText().strip() - if url: - return UrlReader(url) - else: - return self.NoFileSelected + return UrlReader(url) def _update_sheet_combo(self): if len(self.reader.sheets) < 2: @@ -420,14 +538,25 @@ def _select_active_sheet(self): self.reader.select_sheet(None) self.sheet_combo.setCurrentIndex(0) + def _initialize_reader_combo(self): + # Reset to initial state without losing the current index or + # emitting any signals. + combo = self.reader_combo + if not combo.count(): + filters = [format_filter(f) for f in self.available_readers] + combo.addItems([DEFAULT_READER_TEXT] + filters) + combo.setCurrentIndex(0) + else: + # additional readers may be added in self._get_reader() + n = len(self.available_readers) + 1 + if combo.currentIndex() >= n: + combo.setCurrentIndex(0) + while combo.count() > n: + combo.removeItem(combo.count() - 1) + combo.setDisabled(True) + @staticmethod def _describe(table): - def missing_prop(prop): - if prop: - return f"({prop * 100:.1f}% missing values)" - else: - return "(no missing values)" - domain = table.domain text = "" @@ -439,25 +568,29 @@ def missing_prop(prop): if descs: text += f"

    {'
    '.join(descs)}

    " - text += f"

    {len(table)} instance(s)" + text += f"

    {len(table)} {pl(len(table), 'instance')}" - missing_in_attr = missing_prop(table.has_missing_attribute() - and table.get_nan_frequency_attribute()) - missing_in_class = missing_prop(table.has_missing_class() - and table.get_nan_frequency_class()) - text += f"
    {len(domain.attributes)} feature(s) {missing_in_attr}" + missing_in_attr = missing_in_class = "" + if table.X.size < OWFile.SIZE_LIMIT: + missing_in_attr = missing_values(table.get_nan_frequency_attribute()) + missing_in_class = missing_values(table.get_nan_frequency_class()) + nattrs = len(domain.attributes) + text += f"
    {nattrs} {pl(nattrs, 'feature')} {missing_in_attr}" if domain.has_continuous_class: text += f"
    Regression; numerical class {missing_in_class}" elif domain.has_discrete_class: + nvals = len(domain.class_var.values) text += "
    Classification; categorical class " \ - f"with {len(domain.class_var.values)} values {missing_in_class}" + f"with {nvals} {pl(nvals, 'value')} {missing_in_class}" elif table.domain.class_vars: + ntargets = len(table.domain.class_vars) text += "
    Multi-target; " \ - f"{len(table.domain.class_vars)} target variables " \ + f"{ntargets} target {pl(ntargets, 'variable')} " \ f"{missing_in_class}" else: text += "
    Data has no target variable." - text += f"
    {len(domain.metas)} meta attribute(s)" + nmetas = len(domain.metas) + text += f"
    {nmetas} {pl(nmetas, 'meta attribute')}" text += "

    " if 'Timestamp' in table.domain: @@ -467,10 +600,12 @@ def missing_prop(prop): return text def storeSpecificSettings(self): + # pylint: disable=unsubscriptable-object self.current_context.modified_variables = self.variables[:] def retrieveSpecificSettings(self): if hasattr(self.current_context, "modified_variables"): + # pylint: disable=unsubscriptable-object self.variables[:] = self.current_context.modified_variables def reset_domain_edit(self): @@ -543,24 +678,18 @@ def get_ext_name(filename): self.report_data("Data", self.data) - @staticmethod - def dragEnterEvent(event): - """Accept drops of valid file urls""" - urls = event.mimeData().urls() - if urls: - try: - FileFormat.get_reader(urls[0].toLocalFile()) - event.acceptProposedAction() - except IOError: - pass - - def dropEvent(self, event): - """Handle file drops""" - urls = event.mimeData().urls() - if urls: - self.add_path(urls[0].toLocalFile()) # add first file + def canDropUrl(self, url: QUrl) -> bool: + return OWFileDropHandler().canDropUrl(url) + + def handleDroppedUrl(self, url: QUrl) -> None: + if url.isLocalFile(): + self.add_path(url.toLocalFile()) # add first file self.source = self.LOCAL_FILE self.load_data() + else: + self.url_combo.insertItem(0, url.toString()) + self.url_combo.setCurrentIndex(0) + self._url_set() def workflowEnvChanged(self, key, value, oldvalue): """ @@ -571,5 +700,34 @@ def workflowEnvChanged(self, key, value, oldvalue): self.update_file_list(key, value, oldvalue) +class OWFileDropHandler(SingleUrlDropHandler): + WIDGET = OWFile + + def canDropUrl(self, url: QUrl) -> bool: + if url.isLocalFile(): + try: + FileFormat.get_reader(url.toLocalFile()) + return True + except Exception: # noqa # pylint:disable=broad-except + return False + else: + return url.scheme().lower() in ("http", "https", "ftp") + + def parametersFromUrl(self, url: QUrl) -> Dict[str, Any]: + if url.isLocalFile(): + path = url.toLocalFile() + r = RecentPath(os.path.abspath(path), None, None, + os.path.basename(path)) + return { + "recent_paths": stored_recent_paths_prepend(self.WIDGET, r), + "source": OWFile.LOCAL_FILE, + } + else: + return { + "recent_urls": [url.toString()], + "source": OWFile.URL, + } + + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWFile).run() diff --git a/Orange/widgets/data/owgroupby.py b/Orange/widgets/data/owgroupby.py new file mode 100644 index 00000000000..35a2ce18160 --- /dev/null +++ b/Orange/widgets/data/owgroupby.py @@ -0,0 +1,607 @@ +from contextlib import contextmanager +from dataclasses import dataclass +from functools import partial +from typing import \ + Any, Dict, List, Optional, Set, Union, NamedTuple, Callable, Type + +import pandas as pd +from numpy import nan +from AnyQt.QtCore import ( + QAbstractTableModel, + QEvent, + QItemSelectionModel, + QModelIndex, + Qt, +) +from AnyQt.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QGridLayout, + QHeaderView, + QTableView, +) +from orangewidget.settings import ContextSetting, Setting +from orangewidget.utils.listview import ListViewFilter +from orangewidget.utils.signals import Input, Output +from orangewidget.utils import enum_as_int +from orangewidget.widget import Msg +from pandas.core.dtypes.common import is_datetime64_any_dtype + +from Orange.data import ( + ContinuousVariable, + DiscreteVariable, + Domain, + StringVariable, + Table, + TimeVariable, + Variable, +) +from Orange.data.aggregate import OrangeTableGroupBy +from Orange.util import wrap_callback +from Orange.widgets import gui +from Orange.widgets.settings import DomainContextHandler +from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState +from Orange.widgets.utils.itemmodels import DomainModel +from Orange.widgets.widget import OWWidget + + +class Aggregation(NamedTuple): + function: Union[str, Callable] + types: Set[Type[Variable]] + # Gives the type of the result, + # or True to copy the original variable, + # or False to create a new variable of the same type as the input + result_type: Union[Type[Variable], bool] + + +def concatenate(x): + """ + Concatenate values of series if value is not missing (nan or empty string + for StringVariable) + """ + return " ".join(str(v) for v in x if not pd.isnull(v) and len(str(v)) > 0) + + +def std(s): + """ + Std that also handle time variable. Pandas's std return Timedelta object in + case of datetime columns - transform TimeDelta to seconds + """ + std_ = s.std() + if isinstance(std_, pd.Timedelta): + return std_.total_seconds() + # std returns NaT when cannot compute value - change it to nan to keep colum numeric + return nan if pd.isna(std_) else std_ + + +def var(s): + """ + Variance that also handle time variable. Pandas's variance function somehow + doesn't support DateTimeArray - this function fist converts datetime series + to UNIX epoch and then computes variance + """ + if is_datetime64_any_dtype(s): + initial_ts = pd.Timestamp("1970-01-01", tz=None if s.dt.tz is None else "UTC") + s = (s - initial_ts) / pd.Timedelta("1s") + var_ = s.var() + return var_.total_seconds() if isinstance(var_, pd.Timedelta) else var_ + + +def span(s): + """ + Span that also handle time variable. Time substitution return Timedelta + object in case of datetime columns - transform TimeDelta to seconds + """ + span_ = pd.Series.max(s) - pd.Series.min(s) + return span_.total_seconds() if isinstance(span_, pd.Timedelta) else span_ + + +AGGREGATIONS = { + "Mean": Aggregation( + "mean", + {ContinuousVariable, TimeVariable}, + False), + "Median": Aggregation( + "median", + {ContinuousVariable, TimeVariable}, + True), + "Q1": Aggregation( + lambda s: s.quantile(0.25), + {ContinuousVariable, TimeVariable}, + True), + "Q3": Aggregation( + lambda s: s.quantile(0.75), + {ContinuousVariable, TimeVariable}, + True), + "Min. value": Aggregation( + "min", + {ContinuousVariable, TimeVariable}, + True), + "Max. value": Aggregation( + "max", + {ContinuousVariable, TimeVariable}, + True), + "Mode": Aggregation( + lambda x: pd.Series.mode(x).get(0, nan), + {ContinuousVariable, DiscreteVariable, TimeVariable}, + True + ), + "Standard deviation": Aggregation( + std, + {ContinuousVariable, TimeVariable}, + ContinuousVariable + ), + "Variance": Aggregation( + var, + {ContinuousVariable, TimeVariable}, + ContinuousVariable + ), + "Sum": Aggregation( + "sum", + {ContinuousVariable}, + True), + "Concatenate": Aggregation( + concatenate, + {ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable}, + StringVariable + ), + "Span": Aggregation( + span, + {ContinuousVariable, TimeVariable}, + ContinuousVariable + ), + "First value": Aggregation( + "first", + {ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable}, + True + ), + "Last value": Aggregation( + "last", + {ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable}, + True + ), + "Random value": Aggregation( + lambda x: x.sample(1, random_state=0), + {ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable}, + True + ), + "Count defined": Aggregation( + "count", + {ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable}, + ContinuousVariable + ), + "Count": Aggregation( + "size", + {ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable}, + ContinuousVariable + ), + "Proportion defined": Aggregation( + lambda x: x.count() / x.size, + {ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable}, + ContinuousVariable + ), +} +# list of ordered aggregation names is required on several locations so we +# prepare it in advance +AGGREGATIONS_ORD = list(AGGREGATIONS) + +# use first aggregation suitable for each type as default +DEFAULT_AGGREGATIONS = { + var: {next(name for name, agg in AGGREGATIONS.items() if var in agg.types)} + for var in (ContinuousVariable, TimeVariable, DiscreteVariable, StringVariable) +} + + +@dataclass +class Result: + group_by: OrangeTableGroupBy = None + result_table: Optional[Table] = None + + +def _run( + data: Table, + group_by_attrs: List[Variable], + aggregations: Dict[Variable, Set[str]], + result: Result, + state: TaskState, +) -> Result: + def progress(part): + state.set_progress_value(part * 100) + if state.is_interruption_requested(): + raise Exception + + state.set_status("Aggregating") + # group table rows + if result.group_by is None: + result.group_by = data.groupby(group_by_attrs) + state.set_partial_result(result) + + aggregations = { + var: [ + (agg, AGGREGATIONS[agg].function, AGGREGATIONS[agg].result_type) + for agg in sorted(aggs, key=AGGREGATIONS_ORD.index) + ] + for var, aggs in aggregations.items() + } + result.result_table = result.group_by.aggregate( + aggregations, wrap_callback(progress, 0.2, 1) + ) + return result + + +class TabColumn: + attribute = 0 + aggregations = 1 + + +TABLE_COLUMN_NAMES = ["Attributes", "Aggregations"] + + +class VarTableModel(QAbstractTableModel): + def __init__(self, parent: "OWGroupBy", *args): + super().__init__(*args) + self.domain = None + self.parent = parent + + def set_domain(self, domain: Domain) -> None: + """ + Reset the table view to new domain + """ + self.domain = domain + self.modelReset.emit() + + def update_aggregation(self, attribute: str) -> None: + """ + Reset the aggregation values in the table for the attribute + """ + index = self.domain.index(attribute) + if index < 0: + # indices of metas are negative: first meta -1, second meta -2, ... + index = len(self.domain.variables) - 1 - index + index = self.index(index, 1) + self.dataChanged.emit(index, index) + + def rowCount(self, parent=None) -> int: + return ( + 0 + if self.domain is None or (parent is not None and parent.isValid()) + else len(self.domain.variables) + len(self.domain.metas) + ) + + @staticmethod + def columnCount(parent=None) -> int: + return 0 if parent is not None and parent.isValid() else len(TABLE_COLUMN_NAMES) + + def data(self, index, role=Qt.DisplayRole) -> Any: + row, col = index.row(), index.column() + val = (self.domain.variables + self.domain.metas)[row] + if role in (Qt.DisplayRole, Qt.EditRole): + if col == TabColumn.attribute: + return str(val) + else: # col == TabColumn.aggregations + # plot first two aggregations comma separated and write n more + # for others + aggs = sorted( + self.parent.aggregations.get(val, []), key=AGGREGATIONS_ORD.index + ) + n_more = "" if len(aggs) <= 3 else f" and {len(aggs) - 3} more" + return ", ".join(aggs[:3]) + n_more + elif role == Qt.DecorationRole and col == TabColumn.attribute: + return gui.attributeIconDict[val] + return None + + def headerData(self, i, orientation, role=Qt.DisplayRole) -> str: + if orientation == Qt.Horizontal and role == Qt.DisplayRole and i < 2: + return TABLE_COLUMN_NAMES[i] + return super().headerData(i, orientation, role) + + +class AggregateListViewSearch(ListViewFilter): + """ListViewSearch that disables unselecting all items in the list""" + + def selectionCommand( + self, index: QModelIndex, event: QEvent = None + ) -> QItemSelectionModel.SelectionFlags: + flags = super().selectionCommand(index, event) + selmodel = self.selectionModel() + if not index.isValid(): # Click on empty viewport; don't clear + return QItemSelectionModel.NoUpdate + if selmodel.isSelected(index): + currsel = selmodel.selectedIndexes() + if len(currsel) == 1 and index == currsel[0]: + # Is the last selected index; do not deselect it + return QItemSelectionModel.NoUpdate + if ( + event is not None + and event.type() == QEvent.MouseMove + and flags & QItemSelectionModel.ToggleCurrent + ): + # Disable ctrl drag 'toggle'; can be made to deselect the last + # index, would need to keep track of the current selection + # (selectionModel does this but does not expose it) + flags &= ~QItemSelectionModel.Toggle + flags |= QItemSelectionModel.Select + return flags + + +class CheckBox(QCheckBox): + def __init__(self, text, parent): + super().__init__(text) + self.parent: OWGroupBy = parent + + def nextCheckState(self) -> None: + """ + Custom behaviour for switching between steps. It is required since + sometimes user will select different types of attributes at the same + time. In this case we step between unchecked, partially checked and + checked or just between unchecked and checked - depending on situation + """ + if self.checkState() == Qt.Checked: + # if checked always uncheck + self.setCheckState(Qt.Unchecked) + else: + agg = self.text() + selected_attrs = self.parent.get_selected_attributes() + types = set(type(attr) for attr in selected_attrs) + can_be_applied_all = types <= AGGREGATIONS[agg].types + + # true if aggregation applied to all attributes that can be + # aggregated with selected aggregation + applied_all = all( + type(attr) not in AGGREGATIONS[agg].types + or agg in self.parent.aggregations[attr] + for attr in selected_attrs + ) + if self.checkState() == Qt.PartiallyChecked: + # if partially check: 1) check if agg can be applied to all + # 2) else uncheck if agg already applied to all + # 3) else leve partially checked to apply to all that can be aggregated + if can_be_applied_all: + self.setCheckState(Qt.Checked) + elif applied_all: + self.setCheckState(Qt.Unchecked) + else: + self.setCheckState(Qt.PartiallyChecked) + # since checkbox state stay same signal is not emitted + # automatically but we need a callback call so we emit it + self.stateChanged.emit(enum_as_int(Qt.PartiallyChecked)) + else: # self.checkState() == Qt.Unchecked + # if unchecked: check if all can be checked else partially check + self.setCheckState( + Qt.Checked if can_be_applied_all else Qt.PartiallyChecked + ) + + +@contextmanager +def block_signals(widget): + widget.blockSignals(True) + try: + yield + finally: + widget.blockSignals(False) + + +class OWGroupBy(OWWidget, ConcurrentWidgetMixin): + name = "Group by" + description = "" + category = "Transform" + icon = "icons/GroupBy.svg" + keywords = "aggregate, group by" + priority = 1210 + + class Inputs: + data = Input("Data", Table, doc="Input data table") + + class Outputs: + data = Output("Data", Table, doc="Aggregated data") + + class Error(OWWidget.Error): + unexpected_error = Msg("{}") + + settingsHandler = DomainContextHandler() + + gb_attrs: List[Variable] = ContextSetting([]) + aggregations: Dict[Variable, Set[str]] = ContextSetting({}) + auto_commit: bool = Setting(True) + + def __init__(self): + super().__init__() + ConcurrentWidgetMixin.__init__(self) + + self.data = None + self.result = None + + self.gb_attrs_model = DomainModel( + separators=False, + ) + self.agg_table_model = VarTableModel(self) + self.agg_checkboxes = {} + + self.__init_control_area() + self.__init_main_area() + + def __init_control_area(self) -> None: + """Init all controls in the control area""" + gui.listView( + self.controlArea, + self, + "gb_attrs", + box="Group by", + model=self.gb_attrs_model, + viewType=AggregateListViewSearch, + callback=self.__gb_changed, + selectionMode=ListViewFilter.ExtendedSelection, + ) + + gui.auto_send(self.buttonsArea, self, "auto_commit") + + def __init_main_area(self) -> None: + """Init all controls in the main area""" + # aggregation table + self.agg_table_view = tableview = QTableView() + tableview.setModel(self.agg_table_model) + tableview.setSelectionBehavior(QAbstractItemView.SelectRows) + tableview.selectionModel().selectionChanged.connect(self.__rows_selected) + tableview.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + + vbox = gui.vBox(self.mainArea, " ") + vbox.layout().addWidget(tableview) + + # aggregations checkboxes + grid_layout = QGridLayout() + gui.widgetBox(self.mainArea, orientation=grid_layout, box="Aggregations") + + col = 0 + row = 0 + break_rows = (6, 6, 99) + for agg in AGGREGATIONS: + self.agg_checkboxes[agg] = cb = CheckBox(agg, self) + cb.setDisabled(True) + cb.stateChanged.connect(partial(self.__aggregation_changed, agg)) + grid_layout.addWidget(cb, row, col) + row += 1 + if row == break_rows[col]: + row = 0 + col += 1 + + ############ + # Callbacks + + def __rows_selected(self) -> None: + """Callback for table selection change; update checkboxes""" + selected_attrs = self.get_selected_attributes() + + types = {type(attr) for attr in selected_attrs} + active_aggregations = [self.aggregations[attr] for attr in selected_attrs] + for agg, cb in self.agg_checkboxes.items(): + cb.setDisabled(not types & AGGREGATIONS[agg].types) + + activated = {agg in a for a in active_aggregations} + with block_signals(cb): + # check if aggregation active for all selected attributes, + # partially check if active for some else uncheck + cb.setCheckState( + Qt.Checked + if activated == {True} + else (Qt.Unchecked if activated == {False} else Qt.PartiallyChecked) + ) + + def __gb_changed(self) -> None: + """Callback for Group-by attributes selection change""" + self.result = Result() + self.commit.deferred() + + def __aggregation_changed(self, agg: str) -> None: + """ + Callback for aggregation change; update aggregations dictionary and call + commit + """ + selected_attrs = self.get_selected_attributes() + for attr in selected_attrs: + if self.agg_checkboxes[agg].isChecked() and self.__aggregation_compatible( + agg, attr + ): + self.aggregations[attr].add(agg) + else: + self.aggregations[attr].discard(agg) + self.agg_table_model.update_aggregation(attr) + self.commit.deferred() + + @Inputs.data + def set_data(self, data: Table) -> None: + self.closeContext() + self.data = data + + # reset states + self.cancel() + self.result = Result() + self.Outputs.data.send(None) + self.gb_attrs_model.set_domain(data.domain if data else None) + self.gb_attrs = self.gb_attrs_model[:1] if self.gb_attrs_model else [] + self.aggregations = ( + { + attr: DEFAULT_AGGREGATIONS[type(attr)].copy() + for attr in data.domain.variables + data.domain.metas + } + if data + else {} + ) + default_aggregations = self.aggregations.copy() + + self.openContext(self.data) + + # restore aggregations + self.aggregations.update({k: v for k, v in default_aggregations.items() + if k not in self.aggregations}) + + # update selections in widgets and re-plot + self.agg_table_model.set_domain(data.domain if data else None) + self._set_gb_selection() + + self.commit.now() + + ######################### + # Task connected methods + + @gui.deferred + def commit(self) -> None: + self.Error.clear() + self.Warning.clear() + if self.data: + self.start(_run, self.data, self.gb_attrs, self.aggregations, self.result) + + def on_done(self, result: Result) -> None: + self.result = result + self.Outputs.data.send(result.result_table) + + def on_partial_result(self, result: Result) -> None: + # store result in case the task is canceled and on_done is not called + self.result = result + + def on_exception(self, ex: Exception): + self.Error.unexpected_error(str(ex)) + + ################### + # Helper methods + + def get_selected_attributes(self): + """Get select attributes in the table""" + selection_model = self.agg_table_view.selectionModel() + sel_rows = selection_model.selectedRows() + vars_ = self.data.domain.variables + self.data.domain.metas + return [vars_[index.row()] for index in sel_rows] + + def _set_gb_selection(self) -> None: + """ + Update selected attributes. When context includes variable hidden in + data, it will match and gb_attrs may include hidden attribute. Remove it + since otherwise widget groups by attribute that is not present in view. + """ + values = self.gb_attrs_model[:] + self.gb_attrs = [var_ for var_ in self.gb_attrs if var_ in values] + if not self.gb_attrs and self.gb_attrs_model: + # if gb_attrs empty select first + self.gb_attrs = self.gb_attrs_model[:1] + + @staticmethod + def __aggregation_compatible(agg, attr): + """Check a compatibility of aggregation with the variable""" + return type(attr) in AGGREGATIONS[agg].types + + @classmethod + def migrate_context(cls, context, _): + """ + Before widget allowed using Sum on Time variable, now it is forbidden. + This function removes Sum from the context for TimeVariables (104) + """ + for var_, v in context.values["aggregations"][0].items(): + if len(var_) == 2: + if var_[1] == 104: + v.discard("Sum") + + +if __name__ == "__main__": + # pylint: disable=ungrouped-imports + from orangewidget.utils.widgetpreview import WidgetPreview + + WidgetPreview(OWGroupBy).run(Table("iris")) diff --git a/Orange/widgets/data/owimpute.py b/Orange/widgets/data/owimpute.py index c5cc1750709..f9974b0a36e 100644 --- a/Orange/widgets/data/owimpute.py +++ b/Orange/widgets/data/owimpute.py @@ -134,8 +134,9 @@ class OWImpute(OWWidget): name = "Impute" description = "Impute missing values in the data table." icon = "icons/Impute.svg" - priority = 2130 - keywords = ["substitute", "missing"] + priority = 2110 + keywords = "impute, substitute, missing" + category = "Transform" class Inputs: data = Input("Data", Orange.data.Table) @@ -183,7 +184,7 @@ def __init__(self): box.layout().addLayout(box_layout) button_group = QButtonGroup() - button_group.buttonClicked[int].connect(self.set_default_method) + button_group.idClicked.connect(self.set_default_method) for i, (method, _) in enumerate(list(METHODS.items())[1:-1]): imputer = self.create_imputer(method) @@ -256,10 +257,11 @@ def set_default_time(datetime): self.selection = self.varview.selectionModel() box.layout().addWidget(self.varview) - vertical_layout = QVBoxLayout(margin=0) + vertical_layout = QVBoxLayout() self.methods_container = QWidget(enabled=False) - method_layout = QVBoxLayout(margin=0) + method_layout = QVBoxLayout() + method_layout.setContentsMargins(0, 0, 0, 0) self.methods_container.setLayout(method_layout) button_group = QButtonGroup() @@ -283,7 +285,7 @@ def set_default_time(datetime): value_stack.addWidget(self.value_double) method_layout.addWidget(value_stack) - button_group.buttonClicked[int].connect( + button_group.idClicked.connect( self.set_method_for_current_selection ) @@ -369,7 +371,7 @@ def set_data(self, data): self.reset_button.setEnabled(len(self.varmodel) > 0) self.update_varview() - self.unconditional_commit() + self.commit.now() @Inputs.learner def set_learner(self, learner): @@ -386,7 +388,7 @@ def set_learner(self, learner): self.default_method_index = Method.Model self.update_varview() - self.commit() + self.commit.deferred() def get_method_for_column(self, column_index): # type: (int) -> impute.BaseImputeMethod @@ -404,8 +406,9 @@ def _invalidate(self): self.modified = True if self.__task is not None: self.cancel() - self.commit() + self.commit.deferred() + @gui.deferred def commit(self): self.cancel() self.warning() diff --git a/Orange/widgets/data/owmelt.py b/Orange/widgets/data/owmelt.py index 75b8441e678..290b6a1685d 100644 --- a/Orange/widgets/data/owmelt.py +++ b/Orange/widgets/data/owmelt.py @@ -60,19 +60,24 @@ def decode_setting(self, setting, value, potential_ids): class OWMelt(widget.OWWidget): name = "Melt" description = "Convert wide data to narrow data, a list of item-value pairs" + category = "Transform" icon = "icons/Melt.svg" - keywords = ["shopping list", "wide", "narrow"] + keywords = "melt, shopping list, wide, narrow" + priority = 2230 class Inputs: data = widget.Input("Data", Table) class Outputs: - data = widget.Output("Data", Table) + data = widget.Output("Data", Table, dynamic=False) + + class Error(widget.OWWidget.Error): + nothing_to_melt = Msg("No features to melt") class Information(widget.OWWidget.Information): no_suitable_features = Msg( "No columns with unique values\n" - "Only columns with unique valules are useful for row identifiers.") + "Only columns with unique values are useful for row identifiers.") want_main_area = False resizing_enabled = False @@ -148,10 +153,10 @@ def set_data(self, data): self.idvar = self.idvar_model[1] self.openContext(self.idvar_model[1:]) - self.commit() + self.commit.now() def _is_unique(self, var): - col = self.data.get_column_view(var)[0] + col = self.data.get_column(var) col = col[self._notnan_mask(col)] return len(col) == len(set(col)) @@ -160,17 +165,21 @@ def _notnan_mask(col): return np.isfinite(col) if col.dtype == float else col != "" def _invalidate(self): - self.commit() + self.commit.deferred() + @gui.deferred def commit(self): self.Error.clear() if self.data: output = self._reshape_to_long() - self.Outputs.data.send(output) - self._store_output_desc(output) - else: - self.Outputs.data.send(None) - self._output_desc = None + if output is not None: + self.Outputs.data.send(output) + self._store_output_desc(output) + return + else: + self.Error.nothing_to_melt() + self.Outputs.data.send(None) + self._output_desc = None def send_report(self): self.report_items("Settings", ( @@ -191,13 +200,15 @@ def _store_output_desc(self, output): def _reshape_to_long(self): # Get a mask with columns used for data useful_vars = self._get_useful_vars() + if not np.any(useful_vars): + return None item_names = self._get_item_names(useful_vars) n_useful = len(item_names) # Get identifiers, remove rows with missing id data id_names = () if self.idvar: - idvalues, _ = self.data.get_column_view(self.idvar) + idvalues = self.data.get_column(self.idvar) idmask = self._notnan_mask(idvalues) x = self.data.X[idmask] idvalues = idvalues[idmask] diff --git a/Orange/widgets/data/owmergedata.py b/Orange/widgets/data/owmergedata.py index d704ff0d5bd..62edcb065fe 100644 --- a/Orange/widgets/data/owmergedata.py +++ b/Orange/widgets/data/owmergedata.py @@ -5,8 +5,9 @@ from AnyQt.QtCore import Qt, QModelIndex, pyqtSignal as Signal from AnyQt.QtWidgets import ( - QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout + QWidget, QLabel, QVBoxLayout, QHBoxLayout, QSizePolicy ) + from orangewidget.utils.combobox import ComboBoxSearch import Orange @@ -29,8 +30,7 @@ class ConditionBox(QWidget): RowItems = namedtuple( "RowItems", - ("pre_label", "left_combo", "in_label", "right_combo", - "remove_button", "add_button")) + ("pre_label", "left_combo", "in_label", "right_combo", "remove_button")) def __init__(self, parent, model_left, model_right, pre_label, in_label): super().__init__(parent) @@ -43,6 +43,12 @@ def __init__(self, parent, model_left, model_right, pre_label, in_label): self.layout().setSpacing(0) self.setMouseTracking(True) + def get_button(self, label, callback): + return gui.button( + None, self, label, callback=callback, + addToLayout=False, autoDefault=False, width=34, + sizePolicy=(QSizePolicy.Maximum, QSizePolicy.Maximum)) + def add_row(self): def sync_combos(): combo = self.sender() @@ -78,31 +84,29 @@ def get_combo(model): combo.activated.connect(sync_combos) return combo - def get_button(label, callback): - button = QPushButton(label, self) - button.setFlat(True) - button.setFixedWidth(12) - button.clicked.connect(callback) - return button - row = self.layout().count() row_items = self.RowItems( QLabel("and" if row else self.pre_label), get_combo(self.model_left), QLabel(self.in_label), get_combo(self.model_right), - get_button("×", self.on_remove_row), - get_button("+", self.on_add_row) + self.get_button("×", self.on_remove_row) ) layout = QHBoxLayout() layout.setSpacing(10) - self.layout().addLayout(layout) + self.layout().insertLayout(self.layout().count() - 1, layout) layout.addStretch(10) for item in row_items: layout.addWidget(item) self.rows.append(row_items) self._reset_buttons() + def add_plus_row(self): + layout = QHBoxLayout() + self.layout().addLayout(layout) + layout.addStretch(1) + layout.addWidget(self.get_button("+", self.on_add_row)) + def remove_row(self, row): self.layout().takeAt(self.rows.index(row)) self.rows.remove(row) @@ -125,16 +129,8 @@ def on_remove_row(self): self.emit_list() def _reset_buttons(self): - def endis(button, enable, text): - button.setEnabled(enable) - button.setText(text if enable else "") - self.rows[0].pre_label.setText(self.pre_label) - single_row = len(self.rows) == 1 - endis(self.rows[0].remove_button, not single_row, "×") - endis(self.rows[-1].add_button, True, "+") - if not single_row: - endis(self.rows[-2].add_button, False, "") + self.rows[0].remove_button.setDisabled(len(self.rows) == 1) def current_state(self): def get_var(model, combo): @@ -248,9 +244,10 @@ def matches(part, variables): class OWMergeData(widget.OWWidget): name = "Merge Data" description = "Merge datasets based on the values of selected features." + category = "Transform" icon = "icons/MergeData.svg" priority = 1110 - keywords = ["join"] + keywords = "merge data, join" class Inputs: data = Input("Data", Orange.data.Table, default=True, replaces=["Data A"]) @@ -259,7 +256,8 @@ class Inputs: class Outputs: data = Output("Data", Orange.data.Table, - replaces=["Merged Data A+B", "Merged Data B+A", "Merged Data"]) + replaces=["Merged Data A+B", "Merged Data B+A", "Merged Data"], + dynamic=False) LeftJoin, InnerJoin, OuterJoin = range(3) OptionNames = ("Append columns from Extra data", @@ -297,21 +295,30 @@ class Outputs: class Warning(widget.OWWidget.Warning): renamed_vars = Msg("Some variables have been renamed " "to avoid duplicates.\n{}") + nonunique_left = Msg( + "Some (unused) combinations of values in Data appear in " + "multiple rows.") + nonunique_right = Msg( + "Some (unused) combinations of values in Extra Data appear in " + "multiple rows.") class Error(widget.OWWidget.Error): matching_numeric_with_nonnum = Msg( "Numeric and non-numeric columns ({} and {}) cannot be matched.") matching_index_with_sth = Msg("Row index cannot be matched with {}.") matching_id_with_sth = Msg("Instance cannot be matched with {}.") + nonunique_left_matched = Msg( + "Some combinations of values in Data appear in multiple rows." + "\nEvery matched combination may appear at most once.") nonunique_left = Msg( - "Some combinations of values on the left appear in multiple rows.\n" - "For this type of merging, every possible combination of values " - "on the left should appear at most once.") + "Some combinations of values in Data appear in multiple rows." + "\nEvery combination may appear at most once.") + nonunique_right_matched = Msg( + "Some combinations of values in Extra Data appear in multiple rows." + "\nEvery matched combination may appear at most once.") nonunique_right = Msg( - "Some combinations of values on the right appear in multiple rows." - "\n" - "Every possible combination of values on the right should appear " - "at most once.") + "Some combinations of values in Extra Data appear in multiple rows." + "\nEvery combination may appear at most once.") def __init__(self): super().__init__() @@ -333,17 +340,18 @@ def __init__(self): self.attr_boxes = ConditionBox( self, self.model, self.extra_model, "", "matches") self.attr_boxes.add_row() + self.attr_boxes.add_plus_row() box = gui.vBox(self.controlArea, box="Row matching") box.layout().addWidget(self.attr_boxes) gui.auto_apply(self.buttonsArea, self) - # connect after wrapping self.commit with gui.auto_commit! - self.attr_boxes.vars_changed.connect(self.commit) + + self.attr_boxes.vars_changed.connect(self.commit.deferred) self.attr_boxes.vars_changed.connect(self.store_combo_state) self.settingsAboutToBePacked.connect(self.store_combo_state) def change_merging(self): - self.commit() + self.commit.deferred() @Inputs.data @check_sql_input @@ -366,7 +374,7 @@ def handleNewSignals(self): self.openContext(self.data and self.data.domain, self.extra_data and self.extra_data.domain) self.attr_boxes.set_state(self.attr_pairs) - self.unconditional_commit() + self.commit.now() def _find_best_match(self): def get_unique_str_metas_names(model_): @@ -386,6 +394,7 @@ def get_unique_str_metas_names(model_): n_max_intersect, attr, extra_attr = n_inter, m_a, m_b return attr, extra_attr + @gui.deferred def commit(self): self.clear_messages() merged = self.merge() if self.data and self.extra_data else None @@ -442,16 +451,47 @@ def _get_col_name(obj): return f"'{obj.name}'" if isinstance(obj, Variable) else obj.lower() def _check_uniqueness(self, left, left_mask, right, right_mask): - ok = True + # Right table is always checked masked_right = right[right_mask] - if len(set(map(tuple, masked_right))) != len(masked_right): - self.Error.nonunique_right() - ok = False - if self.merging != self.LeftJoin: + right_set = set(map(tuple, masked_right)) + right_duplicates = len(right_set) != len(masked_right) + + # Left table is checked on non-left join; on left join it is needed + # only to check whether right duplicates are critical + left_duplicates = None + if self.merging != self.LeftJoin or right_duplicates: masked_left = left[left_mask] - if len(set(map(tuple, masked_left))) != len(masked_left): - self.Error.nonunique_left() + left_set = set(map(tuple, masked_left)) + left_duplicates = len(left_set) != len(masked_left) + + # Handle outer join and exit + if self.merging == self.OuterJoin: + self.Error.nonunique_left(shown=left_duplicates) + self.Error.nonunique_right(shown=right_duplicates) + return not (left_duplicates or right_duplicates) + + # Intersection is needed to check whether duplicates are critical; + if left_duplicates or right_duplicates: + n_inter = len(left_set & right_set) + + ok = True + + if right_duplicates: + # `sum` counts the number of times that masked_right items are used. + # If this equals the intersection, each is used just once. + if sum(tuple(mr) in left_set for mr in masked_right) == n_inter: + self.Warning.nonunique_right() + else: + self.Error.nonunique_right_matched() ok = False + + if self.merging == self.InnerJoin and left_duplicates: + if sum(tuple(ml) in right_set for ml in masked_left) == n_inter: + self.Warning.nonunique_left() + else: + self.Error.nonunique_left_matched() + ok = False + return ok def _compute_reduced_extra_data(self, @@ -469,10 +509,8 @@ def var_needed(var): if var not in domain: return True both_defined = (lefti != -1) * (righti != -1) - left_col = \ - self.data.get_column_view(var)[0][lefti[both_defined]] - right_col = \ - self.extra_data.get_column_view(var)[0][righti[both_defined]] + left_col = self.data.get_column(var)[lefti[both_defined]] + right_col = self.extra_data.get_column(var)[righti[both_defined]] if var.is_primitive(): left_col = left_col.astype(float) right_col = right_col.astype(float) @@ -496,10 +534,9 @@ def _values(data, var, mask): return np.arange(len(data)) if var == INSTANCEID: return np.fromiter( - (inst.id for inst in data), count=len(data), dtype=np.int) - col = data.get_column_view(var)[0] + (inst.id for inst in data), count=len(data), dtype=int) + col = data.get_column(var) if var.is_primitive(): - col = col.astype(float, copy=False) nans = np.isnan(col) mask *= ~nans if var.is_discrete: diff --git a/Orange/widgets/data/owneighbors.py b/Orange/widgets/data/owneighbors.py index e449af029c0..da1e81efab8 100644 --- a/Orange/widgets/data/owneighbors.py +++ b/Orange/widgets/data/owneighbors.py @@ -29,8 +29,9 @@ class OWNeighbors(OWWidget): name = "Neighbors" description = "Compute nearest neighbors in data according to reference." icon = "icons/Neighbors.svg" - + category = "Unsupervised" replaces = ["orangecontrib.prototypes.widgets.owneighbours.OWNeighbours"] + keywords = "knn, nearest neighbors, distance, similarity" class Inputs: data = Input("Data", Table) @@ -42,7 +43,7 @@ class Outputs: class Info(OWWidget.Warning): removed_references = \ Msg("Input data includes reference instance(s).\n" - "Reference instances are excluded from the output.") + "Reference instances are not considered as neighbours.") class Warning(OWWidget.Warning): all_data_as_reference = \ @@ -57,6 +58,7 @@ class Error(OWWidget.Error): n_neighbors = Setting(10) limit_neighbors = Setting(True) distance_index = Setting(0) + include_reference = Setting(False) auto_apply = Setting(True) want_main_area = False @@ -78,10 +80,14 @@ def __init__(self): box, self, "n_neighbors", label="Limit number of neighbors to:", step=1, spinType=int, minv=0, maxv=100, checked='limit_neighbors', # call apply by gui.auto_commit, pylint: disable=unnecessary-lambda - checkCallback=lambda: self.apply(), - callback=lambda: self.apply()) + checkCallback=self.commit.deferred, + callback=self.commit.deferred) + gui.checkBox( + box, self, "include_reference", label="Include reference example", + callback=self.commit.deferred + ) - self.apply_button = gui.auto_apply(self.buttonsArea, self, commit=self.apply) + self.apply_button = gui.auto_apply(self.buttonsArea, self) @Inputs.data def set_data(self, data): @@ -94,11 +100,11 @@ def set_ref(self, refs): def handleNewSignals(self): self.compute_distances() - self.unconditional_apply() + self.commit.now() def recompute(self): self.compute_distances() - self.apply() + self.commit.deferred() def compute_distances(self): self.Error.diff_domains.clear() @@ -124,13 +130,15 @@ def compute_distances(self): pp_reference, pp_data = pp_all_data[:n_ref], pp_all_data[n_ref:] self.distances = metric(pp_data, pp_reference).min(axis=1) - def apply(self): + @gui.deferred + def commit(self): indices = self._compute_indices() if indices is None: neighbors = None else: neighbors = self._data_with_similarity(indices) + neighbors.name = self.data.name + " (neighbors)" self.Outputs.data.send(neighbors) def _compute_indices(self): @@ -152,20 +160,31 @@ def _compute_indices(self): up_to = len(dist) - np.sum(inrefs) if self.limit_neighbors and self.n_neighbors < up_to: up_to = self.n_neighbors - return np.argpartition(dist, up_to - 1)[:up_to] + # get indexes of N neighbours in unsorted order - faster than argsort + idx = np.argpartition(dist, up_to - 1)[:up_to] + # sort selected N neighbours according to distances + sorted_subset_idx = np.argsort(dist[idx]) + # map sorted indexes back to original index space + return idx[sorted_subset_idx] def _data_with_similarity(self, indices): - data = self.data - varname = get_unique_names(data.domain, "distance") - metas = data.domain.metas + (ContinuousVariable(varname), ) - domain = Domain(data.domain.attributes, data.domain.class_vars, metas) - data_metas = self.distances[indices].reshape((-1, 1)) - if data.domain.metas: - data_metas = np.hstack((data.metas[indices], data_metas)) - neighbors = Table(domain, data.X[indices], data.Y[indices], data_metas) - neighbors.ids = data.ids[indices] - neighbors.attributes = self.data.attributes - return neighbors + domain = self.data.domain + dist_var = ContinuousVariable(get_unique_names(domain, "distance")) + metas = domain.metas + (dist_var, ) + domain = Domain(domain.attributes, domain.class_vars, metas) + neighbours = self.data.from_table(domain, self.data, row_indices=indices) + distances = self.distances[indices] + if self.include_reference: + neighbours = Table.concatenate( + [neighbours, + self.reference.transform(neighbours.domain)]) + distances = np.hstack( + (distances, + [np.nan] * len(self.reference))) + with neighbours.unlocked(neighbours.metas): + if distances.size > 0: + neighbours.set_column(dist_var, distances) + return neighbours if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/data/owoutliers.py b/Orange/widgets/data/owoutliers.py index c419a91d9cf..f9806374074 100644 --- a/Orange/widgets/data/owoutliers.py +++ b/Orange/widgets/data/owoutliers.py @@ -6,6 +6,7 @@ from AnyQt.QtCore import Signal, Qt from AnyQt.QtWidgets import QWidget, QVBoxLayout +from orangewidget.report import bool_str from orangewidget.settings import SettingProvider from Orange.base import Learner @@ -43,7 +44,7 @@ def callback(i: float, status=""): model = learner(data, wrap_callback(callback, end=0.6)) pred = model(data, wrap_callback(callback, start=0.6, end=0.99)) - col = pred.get_column_view(model.outlier_var)[0] + col = pred.get_column(model.outlier_var) inliers_ind = np.where(col == 1)[0] outliers_ind = np.where(col == 0)[0] @@ -95,6 +96,11 @@ def get_parameters(self): return {"nu": self.nu / 100, "gamma": self.gamma} + def get_report_parameters(self): + return {"Detection method": "One class SVM with non-linear kernel (RBF)", + "Regularization (nu)": f"{self.nu/100:.0%}", + "Kernel coefficient": self.gamma} + class CovarianceEditor(ParametersEditor): cont = Setting(10) @@ -120,10 +126,17 @@ def get_parameters(self): return {"contamination": self.cont / 100, "support_fraction": fraction} + def get_report_parameters(self): + fraction = self.support_fraction if self.empirical_covariance else None + return {"Detection method": "Covariance estimator", + "Contamination": f"{self.cont/100:.0%}", + "Support fraction": fraction} + class LocalOutlierFactorEditor(ParametersEditor): METRICS = ("euclidean", "manhattan", "cosine", "jaccard", "hamming", "minkowski") + METRICS_NAMES = ["Euclidean", "Manhattan", "Cosine", "Jaccard", "Hamming", "Minkowski"] n_neighbors = Setting(20) cont = Setting(10) @@ -140,15 +153,23 @@ def __init__(self, parent): minv=1, maxv=100000, callback=self.parameter_changed) gui.comboBox(self.param_box, self, "metric_index", label="Metric:", orientation=Qt.Horizontal, - items=[m.capitalize() for m in self.METRICS], + items=self.METRICS_NAMES, callback=self.parameter_changed) def get_parameters(self): return {"n_neighbors": self.n_neighbors, "contamination": self.cont / 100, "algorithm": "brute", # works faster for big datasets + # pylint: disable=invalid-sequence-index "metric": self.METRICS[self.metric_index]} + def get_report_parameters(self): + return {"Detection method": "Local Outlier Factor", + "Contamination": f"{self.cont/100:.0%}", + "Number of neighbors": self.n_neighbors, + # pylint: disable=invalid-sequence-index + "Metric": self.METRICS_NAMES[self.metric_index]} + class IsolationForestEditor(ParametersEditor): cont = Setting(10) @@ -168,14 +189,18 @@ def get_parameters(self): return {"contamination": self.cont / 100, "random_state": 42 if self.replicable else None} + def get_report_parameters(self): + return {"Detection method": "Isolation Forest", + "Contamination": f"{self.cont/100:.0%}", + "Replicable training": bool_str(self.replicable)} class OWOutliers(OWWidget, ConcurrentWidgetMixin): name = "Outliers" description = "Detect outliers." icon = "icons/Outliers.svg" priority = 3000 - category = "Data" - keywords = ["inlier"] + category = "Unsupervised" + keywords = "outliers, inlier" class Inputs: data = Input("Data", Table) @@ -240,7 +265,7 @@ def _init_editors(self): self.editors = (self.svm_editor, self.cov_editor, self.lof_editor, self.isf_editor) for editor in self.editors: - editor.param_changed.connect(lambda: self.commit()) + editor.param_changed.connect(self.commit.deferred) box.layout().addWidget(editor) editor.hide() @@ -248,7 +273,7 @@ def _init_editors(self): def __method_changed(self): self.set_current_editor() - self.commit() + self.commit.deferred() def set_current_editor(self): if self.current_editor: @@ -263,7 +288,7 @@ def set_data(self, data): self.clear_messages() self.data = data self.enable_controls() - self.unconditional_commit() + self.commit.now() def enable_controls(self): self.method_combo.model().item(self.Covariance).setEnabled(True) @@ -273,6 +298,7 @@ def enable_controls(self): self.method_combo.model().item(self.Covariance).setEnabled(False) self.Warning.disabled_cov() + @gui.deferred def commit(self): self.Error.singular_cov.clear() self.Error.memory_error.clear() @@ -309,41 +335,15 @@ def onDeleteWidget(self): super().onDeleteWidget() def send_report(self): - if self.n_outliers is None or self.n_inliers is None: - return - self.report_items("Data", - (("Input instances", len(self.data)), - ("Inliers", self.n_inliers), - ("Outliers", self.n_outliers))) - - params = self.current_editor.get_parameters() - if self.outlier_method == self.OneClassSVM: - self.report_items( - "Detection", - (("Detection method", - "One class SVM with non-linear kernel (RBF)"), - ("Regularization (nu)", params["nu"]), - ("Kernel coefficient", params["gamma"]))) - elif self.outlier_method == self.Covariance: - self.report_items( - "Detection", - (("Detection method", "Covariance estimator"), - ("Contamination", params["contamination"]), - ("Support fraction", params["support_fraction"]))) - elif self.outlier_method == self.LOF: - self.report_items( - "Detection", - (("Detection method", "Local Outlier Factor"), - ("Contamination", params["contamination"]), - ("Number of neighbors", params["n_neighbors"]), - ("Metric", params["metric"]))) - elif self.outlier_method == self.IsolationForest: - self.report_items( - "Detection", - (("Detection method", "Isolation Forest"), - ("Contamination", params["contamination"]))) - else: - raise NotImplementedError + if self.data is not None: + if self.n_outliers is None or self.n_inliers is None: + return + self.report_items("Data", + (("Input instances", len(self.data)), + ("Inliers", self.n_inliers), + ("Outliers", self.n_outliers))) + self.report_items("Detection", + self.current_editor.get_report_parameters()) @classmethod def migrate_settings(cls, settings: Dict, version: int): diff --git a/Orange/widgets/data/owpaintdata.py b/Orange/widgets/data/owpaintdata.py index f78fd7e7550..cf92fe931ab 100644 --- a/Orange/widgets/data/owpaintdata.py +++ b/Orange/widgets/data/owpaintdata.py @@ -1,4 +1,4 @@ - +from __future__ import annotations import os import unicodedata import itertools @@ -30,6 +30,7 @@ from Orange.util import scale, namegen from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.utils.plotutils import PlotWidget from Orange.widgets.widget import OWWidget, Msg, Input, Output @@ -89,6 +90,7 @@ def stack_on_condition(a, b, condition): Magnet = namedtuple("Magnet", ["pos", "radius", "density"]) SelectRegion = namedtuple("SelectRegion", ["region"]) DeleteSelection = namedtuple("DeleteSelection", []) +DeleteAll = namedtuple("DeleteAll", []) MoveSelection = namedtuple("MoveSelection", ["delta"]) @@ -111,7 +113,7 @@ def transform(command, data): def append(command, data): np.clip(command.points[:, :2], 0, 1, out=command.points[:, :2]) return (np.vstack([data, command.points]), - DeleteIndices(slice(len(data), + DeleteIndices(range(len(data), len(data) + len(command.points)))) @@ -124,11 +126,11 @@ def insert(command, data): @transform.register(DeleteIndices) def delete(command, data, ): - if isinstance(command.indices, slice): + if isinstance(command.indices, range): condition = indices_to_mask(command.indices, len(data)) else: indices = np.asarray(command.indices) - if indices.dtype == np.bool: + if indices.dtype == bool: condition = indices else: condition = indices_to_mask(indices, len(data)) @@ -138,7 +140,11 @@ def delete(command, data, ): @transform.register(Move) def move(command, data): - data[command.indices] += command.delta + if isinstance(command.indices, tuple): + idx = np.ix_(*command.indices) + else: + idx = command.indices + data[idx] += command.delta return data, Move(command.indices, -command.delta) @@ -512,7 +518,8 @@ def mouseReleaseEvent(self, event): def activate(self): if self._item is None: - self._item = _RectROI((0, 0), (0, 0), pen=(25, 25, 25)) + pen = self._plot.palette().color(QPalette.Text) + self._item = _RectROI((0, 0), (0, 0), pen=pen) self._item.setAcceptedMouseButtons(Qt.LeftButton) self._item.setVisible(False) self._item.setCursor(Qt.OpenHandCursor) @@ -563,8 +570,7 @@ class ClearTool(DataTool): def activate(self): self.editingStarted.emit() - self.issueCommand.emit(SelectRegion(self._plot.rect())) - self.issueCommand.emit(DeleteSelection()) + self.issueCommand.emit(DeleteAll()) self.editingFinished.emit() @@ -630,18 +636,23 @@ def indices_eq(ind1, ind2): if len(ind1) != len(ind2): return False return all(indices_eq(i1, i2) for i1, i2 in zip(ind1, ind2)) - elif isinstance(ind1, slice) and isinstance(ind2, slice): + elif isinstance(ind1, range) and isinstance(ind2, range): return ind1 == ind2 - elif ind1 is ... and ind2 is ...: - return True - ind1, ind1 = np.array(ind1), np.array(ind2) + ind1, ind2 = np.array(ind1), np.array(ind2) if ind1.shape != ind2.shape or ind1.dtype != ind2.dtype: return False return (ind1 == ind2).all() +def merged_range(r1: range, r2: range) -> range | None: + r1, r2 = sorted([r1, r2], key=lambda r: r.start) + if r1.stop == r2.start and r1.step == r2.step: + return range(r1.start, r2.stop, r1.step) + return None + + def merge_cmd(composit): f = composit.f g = composit.g @@ -655,11 +666,11 @@ def merge_cmd(composit): if indices_eq(f.indices, g.indices): return Move(f.indices, f.delta + g.delta) else: - # TODO: union of indices, ... return composit -# elif isinstance(f, DeleteIndices) and isinstance(g, DeleteIndices): -# indices = np.array(g.indices) -# return DeleteIndices(indices) + elif isinstance(f, DeleteIndices) and isinstance(g, DeleteIndices) \ + and isinstance(f.indices, range) and isinstance(g.indices, range) \ + and (r := merged_range(f.indices, g.indices)) is not None: + return DeleteIndices(r) else: return composit @@ -741,13 +752,13 @@ class OWPaintData(OWWidget): description = "Create data by painting data points on a plane." icon = "icons/PaintData.svg" priority = 60 - keywords = ["create", "draw"] + keywords = "paint data, create, draw" class Inputs: data = Input("Data", Table) class Outputs: - data = Output("Data", Table) + data = Output("Data", Table, dynamic=False) autocommit = Setting(True) table_name = Setting("Painted data") @@ -764,7 +775,7 @@ class Outputs: labels = Setting(["C1", "C2"], schema_only=True) buttons_area_orientation = Qt.Vertical - graph_name = "plot" + graph_name = "plot" # pg.GraphicsItem (pg.PlotItem) class Warning(OWWidget.Warning): no_input_variables = Msg("Input data has no variables") @@ -818,7 +829,7 @@ def __init__(self): self.tools_cache = {} self._init_ui() - self.commit() + self.commit.now() def _init_ui(self): namesBox = gui.vBox(self.controlArea, "Names") @@ -888,7 +899,7 @@ def _init_ui(self): button.setDefaultAction(action) self.toolButtons.append((button, tool)) - toolsBox.layout().addWidget(button, i / 3, i % 3) + toolsBox.layout().addWidget(button, i // 3, i % 3) self.toolActions.addAction(action) for column in range(3): @@ -939,8 +950,7 @@ def _init_ui(self): # main area GUI viewbox = PaintViewBox(enableMouse=False) - self.plotview = pg.PlotWidget(background="w", viewBox=viewbox) - self.plotview.sizeHint = lambda: QSize(200, 100) # Minimum size for 1-d painting + self.plotview = PlotWidget(viewBox=viewbox) self.plot = self.plotview.getPlotItem() axis_color = self.palette().color(QPalette.Text) @@ -980,7 +990,7 @@ def set_dimensions(self): if self.hasAttr2: self.plot.setYRange(0, 1, padding=0.01) self.plot.showAxis('left') - self.plotview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + self.plotview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) else: self.plot.setYRange(-.5, .5, padding=0.01) self.plot.hideAxis('left') @@ -1032,7 +1042,7 @@ def _check_and_set_data(data): else: self.input_data = np.column_stack((X, y)) self.reset_to_input() - self.unconditional_commit() + self.commit.now() def reset_to_input(self): """Reset the painting to input data if present.""" @@ -1062,21 +1072,16 @@ def reset_to_input(self): else: # set_dimensions already calls _replot, no need to call it again self._replot() - self.commit() - - def add_new_class_label(self, undoable=True): + self.commit.deferred() + def add_new_class_label(self): newlabel = next(label for label in namegen('C', 1) if label not in self.class_model) - command = SimpleUndoCommand( lambda: self.class_model.append(newlabel), lambda: self.class_model.__delitem__(-1) ) - if undoable: - self.undo_stack.push(command) - else: - command.redo() + self.undo_stack.push(command) def remove_selected_class_label(self): index = self.selected_class_label() @@ -1090,7 +1095,7 @@ def remove_selected_class_label(self): self.undo_stack.beginMacro("Delete class label") self.undo_stack.push(UndoCommand(DeleteIndices(mask), self)) - self.undo_stack.push(UndoCommand(Move((move_mask, 2), -1), self)) + self.undo_stack.push(UndoCommand(Move((move_mask, range(2, 3)), -1), self)) self.undo_stack.push( SimpleUndoCommand(lambda: self.class_model.__delitem__(index), lambda: self.class_model.insert(index, label))) @@ -1158,7 +1163,7 @@ def _on_editing_finished(self): self.undo_stack.endMacro() def execute(self, command): - assert isinstance(command, (Append, DeleteIndices, Insert, Move)), \ + assert isinstance(command, (Append, DeleteIndices, DeleteAll, Insert, Move)), \ "Non normalized command" if isinstance(command, (DeleteIndices, Insert)): self._selected_indices = None @@ -1197,12 +1202,17 @@ def _add_command(self, cmd): self.undo_stack.push( UndoCommand(DeleteIndices(indices), self, text="Delete") ) + elif isinstance(cmd, DeleteAll): + indices = range(0, len(self.__buffer)) + self.undo_stack.push( + UndoCommand(DeleteIndices(indices), self, text="Clear All") + ) elif isinstance(cmd, MoveSelection): indices = self._selected_indices if indices is not None and indices.size: self.undo_stack.push( UndoCommand( - Move((self._selected_indices, slice(0, 2)), + Move((self._selected_indices, range(0, 2)), np.array([cmd.delta.x(), cmd.delta.y()])), self, text="Move") ) @@ -1214,17 +1224,20 @@ def _add_command(self, cmd): data = create_data(cmd.pos.x(), cmd.pos.y(), self.brushRadius / 1000, int(1 + self.density / 20), cmd.rstate) - self._add_command(Append([QPointF(*p) for p in zip(*data.T)])) + data = data[(np.min(data, axis=1) >= 0) + & (np.max(data, axis=1) <= 1), :] + if data.size: + self._add_command(Append([QPointF(*p) for p in zip(*data.T)])) elif isinstance(cmd, Jitter): point = np.array([cmd.pos.x(), cmd.pos.y()]) delta = - apply_jitter(self.__buffer[:, :2], point, self.density / 100.0, 0, cmd.rstate) - self._add_command(Move((..., slice(0, 2)), delta)) + self._add_command(Move((range(0, len(self.__buffer)), range(0, 2)), delta)) elif isinstance(cmd, Magnet): point = np.array([cmd.pos.x(), cmd.pos.y()]) delta = - apply_attractor(self.__buffer[:, :2], point, self.density / 100.0, 0) - self._add_command(Move((..., slice(0, 2)), delta)) + self._add_command(Move((range(0, len(self.__buffer)), range(0, 2)), delta)) else: assert False, "unreachable" @@ -1243,16 +1256,17 @@ def pen(color): y = self.__buffer[:, 1].copy() else: y = np.zeros(self.__buffer.shape[0]) - - colors = self.colors[self.__buffer[:, 2]] - pens = [pen(c) for c in colors] - brushes = [QBrush(c) for c in colors] - + color_table, colors_index = prepare_color_table_and_index( + self.colors, self.__buffer[:, 2] + ) + pen_table = np.array([pen(c) for c in color_table], dtype=object) + brush_table = np.array([QBrush(c) for c in color_table], dtype=object) + pens = pen_table[colors_index] + brushes = brush_table[colors_index] self._scatter_item = pg.ScatterPlotItem( - x, y, symbol="+", brush=brushes, pen=pens + x, y, symbol="+", brush=brushes, pen=pens, size=self.symbol_size ) self.plot.addItem(self._scatter_item) - self.set_symbol_size() def _attr_name_changed(self): self.plot.getAxis("bottom").setLabel(self.attr1) @@ -1261,8 +1275,9 @@ def _attr_name_changed(self): def invalidate(self): self.data = self.__buffer.tolist() - self.commit() + self.commit.deferred() + @gui.deferred def commit(self): self.Warning.renamed_vars.clear() @@ -1318,5 +1333,17 @@ def send_report(self): self.report_plot() +def prepare_color_table_and_index( + palette: colorpalettes.IndexedPalette, data: np.ndarray[float] +) -> tuple[np.ndarray[object], np.ndarray[np.intp]]: + # to index array and map nan to -1 + index = np.full(data.shape, -1, np.intp) + mask = np.isnan(data) + np.copyto(index, data, where=~mask, casting="unsafe") + color_table = np.array([c for c in palette] + [palette[np.nan]], dtype=object) + return color_table, index + + + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWPaintData).run() diff --git a/Orange/widgets/data/owpivot.py b/Orange/widgets/data/owpivot.py index 8ac3530290e..98a3526e762 100644 --- a/Orange/widgets/data/owpivot.py +++ b/Orange/widgets/data/owpivot.py @@ -1,5 +1,5 @@ # pylint: disable=missing-docstring -from typing import Iterable, Set +from typing import Iterable, Set, NamedTuple, Callable from collections import defaultdict from itertools import product, chain @@ -34,40 +34,25 @@ BorderColorRole = next(gui.OrangeUserRole) -class AggregationFunctionsEnum(Enum): - (Count, Count_defined, Sum, Mean, Min, Max, - Mode, Median, Var, Majority) = range(10) - - def __init__(self, *_, **__): - super().__init__() - self.func = None - - @property - def value(self): - return self._value_ - - def __call__(self, *args): - return self.func(args) # pylint: disable=not-callable +class Function(NamedTuple): + value: int + name: str + func: Callable[[np.ndarray], np.ndarray] - def __str__(self): - return self._name_.replace("_", " ") + def __call__(self, x): + return self.func(x) def __gt__(self, other): return self._value_ > other.value + def __str__(self): + return self.name -class Pivot: - Functions = AggregationFunctionsEnum - (Count, Count_defined, Sum, Mean, Min, Max, - Mode, Median, Var, Majority) = Functions + def __int__(self): + return self.value - AutonomousFunctions = (Count,) - AnyVarFunctions = (Count_defined,) - ContVarFunctions = (Sum, Mean, Min, Max, Mode, Median, Var) - DiscVarFunctions = (Majority,) - TimeVarFunctions = (Mean, Min, Max, Mode, Median) - FloatFunctions = (Count, Count_defined, Sum, Var) +class Pivot: class Tables: table = None # type: Table total_h = None # type: Table @@ -77,7 +62,7 @@ class Tables: def __call__(self): return self.table, self.total_h, self.total_v, self.total - def __init__(self, table: Table, agg_funs: Iterable[Functions], + def __init__(self, table: Table, agg_funs: Iterable[Function], row_var: Variable, col_var: Variable = None, val_var: Variable = None): self._group_tables = self.Tables() @@ -95,8 +80,8 @@ def __init__(self, table: Table, agg_funs: Iterable[Functions], if self._col_var and not self._col_var.is_discrete: raise TypeError("Column variable should be DiscreteVariable") - self._row_var_col = table.get_column_view(row_var)[0].astype(np.float) - self._col_var_col = table.get_column_view(self._col_var)[0].astype(np.float) + self._row_var_col = table.get_column(row_var) + self._col_var_col = table.get_column(self._col_var) self._row_var_groups = nanunique(self._row_var_col) self._col_var_groups = nanunique(self._col_var_col) @@ -146,7 +131,7 @@ def pivot_tables(self) -> Table: def single_var_grouping(self) -> bool: return self._row_var is self._col_var - def update_group_table(self, agg_funs: Iterable[Functions], + def update_group_table(self, agg_funs: Iterable[Function], val_var: Variable = None): if not self._group_tables: return @@ -260,7 +245,7 @@ def __get_group_table(self, var, var_indep_funs, var_dep_funs, attrs): if fun in self._depen_agg_done: X[:, k] = group_tab.X[:, self._depen_agg_done[fun][v] - offset] else: - X[i, k] = fun(sub_table.get_column_view(v)[0]) + X[i, k] = fun(sub_table.get_column(v)) #rename leading vars (seems the easiest) if needed current = [var.name for var in attrs] @@ -422,7 +407,6 @@ def __check_continuous(self, val_var, column, fun, is_float_type): return column_.reshape(shape) return column - @staticmethod def count_defined(x): if x.shape[0] == 0: return 0 @@ -437,31 +421,44 @@ def count_defined(x): if x.size else np.zeros(x.shape[1]) return x.shape[0] - nans - @staticmethod def stat(x, f): - return f(x.astype(np.float), axis=0) if x.shape[0] > 0 else np.nan + return f(x.astype(float), axis=0) if x.shape[0] > 0 else np.nan - @staticmethod def mode(x): return Pivot.stat(x, nanmode).mode if x.shape[0] > 0 else np.nan - @staticmethod def majority(x): if x.shape[0] == 0: return np.nan counts = bincount(x)[0] return np.argmax(counts) if counts.shape[0] else np.nan - Count.func = lambda x: len(x[0]) - Count_defined.func = lambda x: Pivot.count_defined(x[0]) - Sum.func = lambda x: nansum(x[0], axis=0) if x[0].shape[0] > 0 else 0 - Mean.func = lambda x: Pivot.stat(x[0], nanmean) - Min.func = lambda x: Pivot.stat(x[0], nanmin) - Max.func = lambda x: Pivot.stat(x[0], nanmax) - Median.func = lambda x: Pivot.stat(x[0], nanmedian) - Mode.func = lambda x: Pivot.mode(x[0]) - Var.func = lambda x: Pivot.stat(x[0], nanvar) - Majority.func = lambda x: Pivot.majority(x[0]) + def wrapstat(f): + return lambda x: Pivot.stat(x, f) + + Count, Count_defined, Sum, Mean, Min, Max, Mode, Median, Var, Majority = \ + Functions = [ + Function(i, *fdef) for i, fdef in enumerate(( + ("Count", len), + ("Count defined", count_defined), + ("Sum", lambda x: nansum(x, axis=0) if x.shape[0] > 0 else 0), + ("Mean", wrapstat(nanmean)), + ("Min", wrapstat(nanmin)), + ("Max", wrapstat(nanmax)), + ("Mode", mode), + ("Median", wrapstat(nanmedian)), + ("Var", wrapstat(nanvar)), + ("Majority", majority) + ))] + + AutonomousFunctions = (Count,) + AnyVarFunctions = (Count_defined,) + ContVarFunctions = (Sum, Mean, Min, Max, Mode, Median, Var) + DiscVarFunctions = (Majority,) + TimeVarFunctions = (Mean, Min, Max, Mode, Median) + FloatFunctions = (Count, Count_defined, Sum, Var) + + func_by_key = {func.value: func for func in Functions} class BorderedItemDelegate(QStyledItemDelegate): @@ -725,17 +722,18 @@ def clear(self): class OWPivot(OWWidget): name = "Pivot Table" description = "Reshape data table based on column values." + category = "Transform" icon = "icons/Pivot.svg" - priority = 1000 - keywords = ["pivot", "group", "aggregate"] + priority = 1220 + keywords = "pivot table, pivot, group, aggregate" class Inputs: data = Input("Data", Table, default=True) class Outputs: - pivot_table = Output("Pivot Table", Table, default=True) + pivot_table = Output("Pivot Table", Table, default=True, dynamic=False) filtered_data = Output("Filtered Data", Table) - grouped_data = Output("Grouped Data", Table) + grouped_data = Output("Grouped Data", Table, dynamic=False) class Warning(OWWidget.Warning): # TODO - inconsistent for different variable types @@ -744,12 +742,14 @@ class Warning(OWWidget.Warning): renamed_vars = Msg("Some variables have been renamed in some tables" "to avoid duplicates.\n{}") too_many_values = Msg("Selected variable has too many values.") + no_variables = Msg("At least one variable is required.") settingsHandler = DomainContextHandler() + settings_version = 2 row_feature = ContextSetting(None) col_feature = ContextSetting(None) val_feature = ContextSetting(None) - sel_agg_functions = Setting(set([Pivot.Count])) + sel_agg_functions = Setting({Pivot.Count.value}) selection = Setting(set(), schema_only=True) auto_commit = Setting(True) @@ -833,7 +833,7 @@ def new_inbox(): row = 0 continue check_box = QCheckBox(str(agg), inbox) - check_box.setChecked(agg in self.sel_agg_functions) + check_box.setChecked(agg.value in self.sel_agg_functions) check_box.clicked.connect(lambda *args, a=agg: self.__aggregation_cb_clicked(a, args[0])) inbox.layout().addWidget(check_box, row, col) @@ -854,38 +854,50 @@ def no_col_feature(self): def skipped_aggs(self): def add(fun): data, var = self.data, self.val_feature + primitive_funcs = Pivot.ContVarFunctions + Pivot.DiscVarFunctions return data and not var and fun not in Pivot.AutonomousFunctions \ or var and var.is_discrete and fun in Pivot.ContVarFunctions \ - or var and var.is_continuous and fun in Pivot.DiscVarFunctions - skipped = [str(fun) for fun in self.sel_agg_functions if add(fun)] + or var and var.is_continuous and fun in Pivot.DiscVarFunctions \ + or var and not var.is_primitive() and fun in primitive_funcs + skipped = [str(fun) for fun in self._sel_agg_func() if add(fun)] return ", ".join(sorted(skipped)) + @property + def data_has_primitives(self): + if not self.data: + return False + domain = self.data.domain + return any(v.is_primitive() for v in domain.variables + domain.metas) + + def _sel_agg_func(self): + return {Pivot.func_by_key[val] for val in self.sel_agg_functions} + def __feature_changed(self): self.selection = set() self.pivot = None - self.commit() + self.commit.deferred() def __val_feature_changed(self): self.selection = set() - if self.no_col_feature: + if self.no_col_feature or not self.pivot: return self.pivot.update_pivot_table(self.val_feature) - self.commit() + self.commit.deferred() - def __aggregation_cb_clicked(self, agg_fun: Pivot.Functions, checked: bool): + def __aggregation_cb_clicked(self, agg_fun: Function, checked: bool): self.selection = set() if checked: - self.sel_agg_functions.add(agg_fun) + self.sel_agg_functions.add(agg_fun.value) else: - self.sel_agg_functions.remove(agg_fun) + self.sel_agg_functions.remove(agg_fun.value) if self.no_col_feature or not self.pivot or not self.data: return - self.pivot.update_group_table(self.sel_agg_functions, self.val_feature) - self.commit() + self.pivot.update_group_table(self._sel_agg_func(), self.val_feature) + self.commit.deferred() def __invalidate_filtered(self): self.selection = self.table_view.get_selection() - self.commit() + self.commit.deferred() @Inputs.data @check_sql_input @@ -896,8 +908,9 @@ def set_data(self, data): self.pivot = None self.check_data() self.init_attr_values() - self.openContext(self.data) - self.unconditional_commit() + if self.data_has_primitives: + self.openContext(self.data) + self.commit.now() def check_data(self): self.clear_messages() @@ -912,9 +925,10 @@ def init_attr_values(self): self.row_feature = model[0] model = self.controls.val_feature.model() if model and len(model) > 2: - self.val_feature = domain.variables[0] \ - if domain.variables[0] in model else model[2] + allvars = domain.variables + domain.metas + self.val_feature = allvars[0] if allvars[0] in model else model[2] + @gui.deferred def commit(self): def send_outputs(pivot_table, filtered_data, grouped_data): if self.data: @@ -933,23 +947,30 @@ def send_outputs(pivot_table, filtered_data, grouped_data): self.Warning.cannot_aggregate.clear() self.Warning.no_col_feature.clear() + self.table_view.clear() + if self.pivot is None: + if self.data: + if not self.data_has_primitives: + self.Warning.no_variables() + send_outputs(None, None, None) + return + if self.no_col_feature: - self.table_view.clear() self.Warning.no_col_feature() send_outputs(None, None, None) return if self.data: col_var = self.col_feature or self.row_feature - col = self.data.get_column_view(col_var)[0].astype(np.float) + col = self.data.get_column(col_var) if len(nanunique(col)) >= self.MAX_VALUES: self.table_view.clear() self.Warning.too_many_values() send_outputs(None, None, None) return - self.pivot = Pivot(self.data, self.sel_agg_functions, + self.pivot = Pivot(self.data, self._sel_agg_func(), self.row_feature, self.col_feature, self.val_feature) @@ -965,7 +986,6 @@ def send_outputs(pivot_table, filtered_data, grouped_data): self.Warning.renamed_vars(self.pivot.renamed) def _update_graph(self): - self.table_view.clear() if self.pivot.pivot_table: col_feature = self.col_feature or self.row_feature self.table_view.update_table(col_feature.name, @@ -1007,6 +1027,18 @@ def send_report(self): self.report_items((("Group by", self.row_feature),)) self.report_table(self.table_view) + @classmethod + def migrate_settings(cls, settings, version): + if version < 2: + settings["sel_agg_functions"] = { + func.value for func in settings["sel_agg_functions"]} + + +# Backwards compatibility; this is needed for unpickling older settings +class AggregationFunctionsEnum(Enum): + (Count, Count_defined, Sum, Mean, Min, Max, + Mode, Median, Var, Majority) = range(10) + if __name__ == "__main__": WidgetPreview(OWPivot).run(set_data=Table("heart_disease")) diff --git a/Orange/widgets/data/owpreprocess.py b/Orange/widgets/data/owpreprocess.py index d43c0af37d3..d771b11436f 100644 --- a/Orange/widgets/data/owpreprocess.py +++ b/Orange/widgets/data/owpreprocess.py @@ -1,5 +1,5 @@ from collections import OrderedDict -import pkg_resources +import importlib.resources import numpy @@ -20,11 +20,14 @@ from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot +from orangewidget.gui import Slider + import Orange.data from Orange import preprocess from Orange.preprocess import Continuize, ProjectPCA, RemoveNaNRows, \ ProjectCUR, Scale as _Scale, Randomize as _Randomize, RemoveSparse from Orange.widgets import widget, gui +from Orange.widgets.utils.localization import pl from Orange.widgets.settings import Setting from Orange.widgets.utils.overlay import OverlayWidget from Orange.widgets.utils.sql import check_sql_input @@ -36,6 +39,7 @@ ParametersRole, Controller, SequenceFlow ) + class _NoneDisc(preprocess.discretize.Discretization): """Discretize all variables into None. @@ -95,7 +99,7 @@ def __init__(self, parent=None, **kwargs): flat=True ) slbox.setLayout(QHBoxLayout()) - self.__slider = slider = QSlider( + self.__slider = slider = Slider( orientation=Qt.Horizontal, minimum=2, maximum=10, value=self.__nintervals, enabled=self.__method in [self.EqualFreq, self.EqualWidth], @@ -250,9 +254,9 @@ def createinstance(params): def __repr__(self): return self.Continuizers[self.__treatment] -class RemoveSparseEditor(BaseEditor): - options = ["missing", "zeros"] +class RemoveSparseEditor(BaseEditor): + options = ["missing values", "zeros"] def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) @@ -263,12 +267,10 @@ def __init__(self, parent=None, **kwargs): self.setLayout(QVBoxLayout()) self.layout().addWidget(QLabel("Remove features with too many")) - options = ["missing values", - "zeros"] self.filter_buttons = QButtonGroup(exclusive=True) self.filter_buttons.buttonClicked.connect(self.filterByClicked) - for idx, option, in enumerate(options): - btn = QRadioButton(self, text=option, checked=idx == 0) + for idx, option, in enumerate(self.options): + btn = QRadioButton(self, text=option, checked=idx == 1) self.filter_buttons.addButton(btn, id=idx) self.layout().addWidget(btn) @@ -309,6 +311,7 @@ def filterByClicked(self): def setFilter0(self, id_): if self.filter0 != id_: self.filter0 = id_ + self.filter_buttons.button(id_).setChecked(True) self.edited.emit() def setFixedThresh(self, thresh): @@ -344,13 +347,22 @@ def setParameters(self, params): def createinstance(params): params = dict(params) filter0 = params.pop('filter0', True) - useFixedThreshold = params.pop('useFixedThreshold', True) + useFixedThreshold = params.pop('useFixedThreshold', False) if useFixedThreshold: threshold = params.pop('fixedThresh', 50) else: threshold = params.pop('percThresh', 5) / 100 return RemoveSparse(threshold, filter0) + def __repr__(self): + desc = f"remove features with too many {self.options[self.filter0]}, threshold: " + if self.useFixedThreshold: + desc += f"{self.fixedThresh} {pl(self.fixedThresh, 'instance')}" + else: + desc += f"{self.percThresh} %" + return desc + + class ImputeEditor(BaseEditor): (NoImputation, Constant, Average, Model, Random, DropRows, DropColumns) = 0, 1, 2, 3, 4, 5, 6 @@ -724,6 +736,15 @@ def createinstance(params): # further implementations raise NotImplementedError + def __repr__(self): + if self.__strategy == self.Fixed: + # private attributes may not appear translated strings + num = self.__k + return f"select {num} {pl(num,'feature')}" + else: + perc = self.__p + return f"select {perc} % features" + def index_to_enum(enum, i): """Enums, by default, are not int-comparable, so use an ad-hoc mapping of @@ -975,7 +996,8 @@ def __init__(self, name, qualname, category, description, viewclass): def icon_path(basename): - return pkg_resources.resource_filename(__name__, "icons/" + basename) + path = importlib.resources.files(__package__).joinpath("icons/" + basename) + return str(path) PREPROCESS_ACTIONS = [ @@ -1065,9 +1087,10 @@ def icon_path(basename): class OWPreprocess(widget.OWWidget, openclass=True): name = "Preprocess" description = "Construct a data preprocessing pipeline." + category = "Transform" icon = "icons/Preprocess.svg" - priority = 2105 - keywords = ["process"] + priority = 2100 + keywords = "preprocess, process" settings_version = 2 @@ -1159,6 +1182,10 @@ def sizeHint(self): gui.auto_apply(self.buttonsArea, self, "autocommit") + self.__update_size_constraint_timer = QTimer( + self, singleShot=True, interval=0, + ) + self.__update_size_constraint_timer.timeout.connect(self.__update_size_constraint) self._initialize() def _initialize(self): @@ -1279,7 +1306,7 @@ def __update_overlay(self): def __on_modelchanged(self): self.__update_overlay() - self.commit() + self.commit.deferred() @Inputs.data @check_sql_input @@ -1335,6 +1362,7 @@ def apply(self): self.Outputs.preprocessor.send(preprocessor) self.Outputs.preprocessed_data.send(data) + @gui.deferred def commit(self): if not self._invalidated: self._invalidated = True @@ -1347,8 +1375,7 @@ def customEvent(self, event): def eventFilter(self, receiver, event): if receiver is self.flow_view and event.type() == QEvent.LayoutRequest: - QTimer.singleShot(0, self.__update_size_constraint) - + self.__update_size_constraint_timer.start() return super().eventFilter(receiver, event) def storeSpecificSettings(self): diff --git a/Orange/widgets/data/owpurgedomain.py b/Orange/widgets/data/owpurgedomain.py index c0d6c4b225f..ca1888b149f 100644 --- a/Orange/widgets/data/owpurgedomain.py +++ b/Orange/widgets/data/owpurgedomain.py @@ -14,8 +14,9 @@ class OWPurgeDomain(widget.OWWidget): description = "Remove redundant values and features from the dataset. " \ "Sort values." icon = "icons/PurgeDomain.svg" - category = "Data" - keywords = ["remove", "delete", "unused"] + category = "Transform" + keywords = "remove, delete, unused" + priority = 2210 class Inputs: data = Input("Data", Table) @@ -78,7 +79,7 @@ def add_line(parent): boxAt = gui.vBox(self.controlArea, "Features") for value, label in self.feature_options: gui.checkBox(boxAt, self, value, label, - callback=self.optionsChanged) + callback=self.commit.deferred) add_line(boxAt) gui.label(boxAt, self, "Sorted: %(resortedAttrs)s, " @@ -87,7 +88,7 @@ def add_line(parent): boxAt = gui.vBox(self.controlArea, "Classes") for value, label in self.class_options: gui.checkBox(boxAt, self, value, label, - callback=self.optionsChanged) + callback=self.commit.deferred) add_line(boxAt) gui.label(boxAt, self, "Sorted: %(resortedClasses)s," @@ -96,7 +97,7 @@ def add_line(parent): boxAt = gui.vBox(self.controlArea, "Meta attributes") for value, label in self.meta_options: gui.checkBox(boxAt, self, value, label, - callback=self.optionsChanged) + callback=self.commit.deferred) add_line(boxAt) gui.label(boxAt, self, "Reduced: %(reducedMetas)s, removed: %(removedMetas)s") @@ -108,7 +109,7 @@ def add_line(parent): def setData(self, dataset): if dataset is not None: self.data = dataset - self.unconditional_commit() + self.commit.now() else: self.removedAttrs = "-" self.reducedAttrs = "-" @@ -121,9 +122,7 @@ def setData(self, dataset): self.Outputs.data.send(None) self.data = None - def optionsChanged(self): - self.commit() - + @gui.deferred def commit(self): if self.data is None: return diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py index 8b402599207..2534748ccc5 100644 --- a/Orange/widgets/data/owpythonscript.py +++ b/Orange/widgets/data/owpythonscript.py @@ -1,36 +1,42 @@ import sys import os import code -import keyword import itertools +import tokenize import unicodedata -import weakref -from functools import reduce from unittest.mock import patch -from typing import Optional, List, TYPE_CHECKING +from typing import Optional, List, Dict, Any, TYPE_CHECKING + +import pygments.style +from pygments.token import Comment, Keyword, Number, String, Punctuation, Operator, Error, Name +from qtconsole.pygments_highlighter import PygmentsHighlighter from AnyQt.QtWidgets import ( QPlainTextEdit, QListView, QSizePolicy, QMenu, QSplitter, QLineEdit, QAction, QToolButton, QFileDialog, QStyledItemDelegate, - QStyleOptionViewItem, QPlainTextDocumentLayout -) + QStyleOptionViewItem, QPlainTextDocumentLayout, + QLabel, QWidget, QHBoxLayout, QApplication) from AnyQt.QtGui import ( - QColor, QBrush, QPalette, QFont, QTextDocument, - QSyntaxHighlighter, QTextCharFormat, QTextCursor, QKeySequence, + QColor, QBrush, QPalette, QFont, QTextDocument, QTextCharFormat, + QTextCursor, QKeySequence, QFontMetrics, QPainter ) from AnyQt.QtCore import ( - Qt, QRegularExpression, QByteArray, QItemSelectionModel, QSize + Qt, QByteArray, QItemSelectionModel, QSize, QRectF, QMimeDatabase, ) +from orangewidget.workflow.drophandler import SingleFileDropHandler + from Orange.data import Table from Orange.base import Learner, Model from Orange.util import interleave from Orange.widgets import gui +from Orange.widgets.data.utils.pythoneditor.editor import PythonEditor from Orange.widgets.utils import itemmodels from Orange.widgets.settings import Setting +from Orange.widgets.utils.pathutils import samepath from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import OWWidget, Input, Output +from Orange.widgets.widget import OWWidget, MultiInput, Output if TYPE_CHECKING: from typing_extensions import TypedDict @@ -66,130 +72,229 @@ def read_file_content(filename, limit=None): return None -class PythonSyntaxHighlighter(QSyntaxHighlighter): - def __init__(self, parent=None): +# pylint: disable=pointless-string-statement +""" +Adapted from jupyter notebook, which was adapted from GitHub. - self.keywordFormat = text_format(Qt.blue, QFont.Bold) - self.stringFormat = text_format(Qt.darkGreen) - self.defFormat = text_format(Qt.black, QFont.Bold) - self.commentFormat = text_format(Qt.lightGray) - self.decoratorFormat = text_format(Qt.darkGray) +Highlighting styles are applied with pygments. - self.keywords = list(keyword.kwlist) +pygments does not support partial highlighting; on every character +typed, it performs a full pass of the code. If performance is ever +an issue, revert to prior commit, which uses Qutepart's syntax +highlighting implementation. +""" +SYNTAX_HIGHLIGHTING_STYLES = { + 'Light': { + Punctuation: "#000", + Error: '#f00', - self.rules = [(QRegularExpression(r"\b%s\b" % kwd), self.keywordFormat) - for kwd in self.keywords] + \ - [(QRegularExpression(r"\bdef\s+([A-Za-z_]+[A-Za-z0-9_]+)\s*\("), - self.defFormat), - (QRegularExpression(r"\bclass\s+([A-Za-z_]+[A-Za-z0-9_]+)\s*\("), - self.defFormat), - (QRegularExpression(r"'.*'"), self.stringFormat), - (QRegularExpression(r'".*"'), self.stringFormat), - (QRegularExpression(r"#.*"), self.commentFormat), - (QRegularExpression(r"@[A-Za-z_]+[A-Za-z0-9_]+"), - self.decoratorFormat)] + Keyword: 'bold #008000', - self.multilineStart = QRegularExpression(r"(''')|" + r'(""")') - self.multilineEnd = QRegularExpression(r"(''')|" + r'(""")') + Name: '#212121', + Name.Function: '#00f', + Name.Variable: '#05a', + Name.Decorator: '#aa22ff', + Name.Builtin: '#008000', + Name.Builtin.Pseudo: '#05a', - super().__init__(parent) + String: '#ba2121', - def highlightBlock(self, text): - for pattern, fmt in self.rules: - exp = QRegularExpression(pattern) - match = exp.match(text) - index = match.capturedStart() - while index >= 0: - if match.capturedStart(1) > 0: - self.setFormat(match.capturedStart(1), - match.capturedLength(1), fmt) - else: - self.setFormat(match.capturedStart(0), - match.capturedLength(0), fmt) - match = exp.match(text, index + match.capturedLength()) - index = match.capturedStart() - - # Multi line strings - start = self.multilineStart - end = self.multilineEnd - - self.setCurrentBlockState(0) - startIndex, skip = 0, 0 - if self.previousBlockState() != 1: - startIndex, skip = start.match(text).capturedStart(), 3 - while startIndex >= 0: - endIndex = end.match(text, startIndex + skip).capturedStart() - if endIndex == -1: - self.setCurrentBlockState(1) - commentLen = len(text) - startIndex - else: - commentLen = endIndex - startIndex + 3 - self.setFormat(startIndex, commentLen, self.stringFormat) - startIndex, skip = ( - start.match(text, startIndex + commentLen + 3).capturedStart(), - 3 - ) + Number: '#080', + Operator: 'bold #aa22ff', + Operator.Word: 'bold #008000', -class PythonScriptEditor(QPlainTextEdit): - INDENT = 4 + Comment: 'italic #408080', + }, + 'Dark': { + Punctuation: "#fff", + Error: '#f00', - def __init__(self, widget): - super().__init__() - self.widget = widget + Keyword: 'bold #4caf50', - def lastLine(self): - text = str(self.toPlainText()) - pos = self.textCursor().position() - index = text.rfind("\n", 0, pos) - text = text[index: pos].lstrip("\n") - return text + Name: '#e0e0e0', + Name.Function: '#1e88e5', + Name.Variable: '#42a5f5', + Name.Decorator: '#aa22ff', + Name.Builtin: '#43a047', + Name.Builtin.Pseudo: '#42a5f5', - def keyPressEvent(self, event): - if event.key() == Qt.Key_Return: - if event.modifiers() & ( - Qt.ShiftModifier | Qt.ControlModifier | Qt.MetaModifier): - self.widget.commit() - return - text = self.lastLine() - indent = len(text) - len(text.lstrip()) - if text.strip() == "pass" or text.strip().startswith("return "): - indent = max(0, indent - self.INDENT) - elif text.strip().endswith(":"): - indent += self.INDENT - super().keyPressEvent(event) - self.insertPlainText(" " * indent) - elif event.key() == Qt.Key_Tab: - self.insertPlainText(" " * self.INDENT) - elif event.key() == Qt.Key_Backspace: - text = self.lastLine() - if text and not text.strip(): - cursor = self.textCursor() - for _ in range(min(self.INDENT, len(text))): - cursor.deletePreviousChar() - else: - super().keyPressEvent(event) + String: '#ff7070', - else: - super().keyPressEvent(event) + Number: '#66bb6a', - def insertFromMimeData(self, source): - """ - Reimplemented from QPlainTextEdit.insertFromMimeData. - """ - urls = source.urls() - if urls: - self.pasteFile(urls[0]) - else: - super().insertFromMimeData(source) + Operator: 'bold #aa22ff', + Operator.Word: 'bold #4caf50', + + Comment: 'italic #408080', + } +} + + +def make_pygments_style(scheme_name): + """ + Dynamically create a PygmentsStyle class, + given the name of one of the above highlighting schemes. + """ + return type( + 'PygmentsStyle', + (pygments.style.Style,), + {'styles': SYNTAX_HIGHLIGHTING_STYLES[scheme_name]} + ) - def pasteFile(self, url): - new = read_file_content(url.toLocalFile()) - if new: - # inserting text like this allows undo - cursor = QTextCursor(self.document()) - cursor.select(QTextCursor.Document) - cursor.insertText(new) + +class FakeSignatureMixin: + def __init__(self, parent, highlighting_scheme, font): + super().__init__(parent) + self.highlighting_scheme = highlighting_scheme + self.setFont(font) + self.bold_font = QFont(font) + self.bold_font.setBold(True) + + self.indentation_level = 0 + + self._char_4_width = QFontMetrics(font).horizontalAdvance('4444') + + def setIndent(self, margins_width): + self.setContentsMargins(max(0, + round(margins_width) + + ((self.indentation_level - 1) * self._char_4_width)), + 0, 0, 0) + + +class FunctionSignature(FakeSignatureMixin, QLabel): + def __init__(self, parent, highlighting_scheme, font, function_name="python_script"): + super().__init__(parent, highlighting_scheme, font) + self.signal_prefix = 'in_' + + # `def python_script(` + self.prefix = ('def ' + '' + function_name + '' + '(') + + # `):` + self.affix = ('):') + + self.update_signal_text({}) + + def update_signal_text(self, signal_values_lengths): + if not self.signal_prefix: + return + lbl_text = self.prefix + if len(signal_values_lengths) > 0: + for name, value in signal_values_lengths.items(): + if value == 1: + lbl_text += self.signal_prefix + name + ', ' + elif value > 1: + lbl_text += self.signal_prefix + name + 's, ' + lbl_text = lbl_text[:-2] # shave off the trailing ', ' + lbl_text += self.affix + if self.text() != lbl_text: + self.setText(lbl_text) + self.update() + + +class ReturnStatement(FakeSignatureMixin, QWidget): + def __init__(self, parent, highlighting_scheme, font): + super().__init__(parent, highlighting_scheme, font) + + self.indentation_level = 1 + self.signal_labels = {} + self._prefix = None + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # `return ` + ret_lbl = QLabel('return ', self) + ret_lbl.setFont(self.font()) + ret_lbl.setContentsMargins(0, 0, 0, 0) + layout.addWidget(ret_lbl) + + # `out_data[, ]` * 4 + self.make_signal_labels('out_') + + layout.addStretch() + self.setLayout(layout) + + def make_signal_labels(self, prefix): + self._prefix = prefix + # `in_data[, ]` + for i, signal in enumerate(OWPythonScript.signal_names): + # adding an empty b tag like this adjusts the + # line height to match the rest of the labels + signal_display_name = signal + signal_lbl = QLabel('' + prefix + signal_display_name, self) + signal_lbl.setFont(self.font()) + signal_lbl.setContentsMargins(0, 0, 0, 0) + self.layout().addWidget(signal_lbl) + + self.signal_labels[signal] = signal_lbl + + if i >= len(OWPythonScript.signal_names) - 1: + break + + comma_lbl = QLabel(', ') + comma_lbl.setFont(self.font()) + comma_lbl.setContentsMargins(0, 0, 0, 0) + comma_lbl.setStyleSheet('.QLabel { color: ' + + self.highlighting_scheme[Punctuation].split(' ')[-1] + + '; }') + self.layout().addWidget(comma_lbl) + + def update_signal_text(self, signal_name, values_length): + if not self._prefix: + return + lbl = self.signal_labels[signal_name] + if values_length == 0: + text = '' + self._prefix + signal_name + else: # if values_length == 1: + text = '' + self._prefix + signal_name + '' + if lbl.text() != text: + lbl.setText(text) + lbl.update() + + +class VimIndicator(QWidget): + def __init__(self, parent): + super().__init__(parent) + self.indicator_color = QColor('#33cc33') + self.indicator_text = 'normal' + + def paintEvent(self, event): + super().paintEvent(event) + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setBrush(self.indicator_color) + + p.save() + p.setPen(Qt.NoPen) + fm = QFontMetrics(self.font()) + width = self.rect().width() + height = fm.height() + 6 + rect = QRectF(0, 0, width, height) + p.drawRoundedRect(rect, 5, 5) + p.restore() + + textstart = (width - fm.horizontalAdvance(self.indicator_text)) // 2 + p.drawText(textstart, height // 2 + 5, self.indicator_text) + + def minimumSizeHint(self): + fm = QFontMetrics(self.font()) + width = int(round(fm.horizontalAdvance(self.indicator_text)) + 10) + height = fm.height() + 6 + return QSize(width, height) class PythonConsole(QPlainTextEdit, code.InteractiveConsole): @@ -417,25 +522,30 @@ def select_row(view, row): class OWPythonScript(OWWidget): name = "Python Script" description = "Write a Python script and run it on input data or models." + category = "Transform" icon = "icons/PythonScript.svg" priority = 3150 - keywords = ["file", "program", "function"] + keywords = "program, function" class Inputs: - data = Input("Data", Table, replaces=["in_data"], - default=True, multiple=True) - learner = Input("Learner", Learner, replaces=["in_learner"], - default=True, multiple=True) - classifier = Input("Classifier", Model, replaces=["in_classifier"], - default=True, multiple=True) - object = Input("Object", object, replaces=["in_object"], - default=False, multiple=True) + data = MultiInput( + "Data", Table, replaces=["in_data"], default=True + ) + learner = MultiInput( + "Learner", Learner, replaces=["in_learner"], default=True + ) + classifier = MultiInput( + "Classifier", Model, replaces=["in_classifier"], default=True + ) + object = MultiInput( + "Object", object, replaces=["in_object"], default=False, auto_summary=False + ) class Outputs: data = Output("Data", Table, replaces=["out_data"]) learner = Output("Learner", Learner, replaces=["out_learner"]) classifier = Output("Classifier", Model, replaces=["out_classifier"]) - object = Output("Object", object, replaces=["out_object"]) + object = Output("Object", object, replaces=["out_object"], auto_summary=False) signal_names = ("data", "learner", "classifier", "object") @@ -449,36 +559,123 @@ class Outputs: scriptText: Optional[str] = Setting(None, schema_only=True) splitterState: Optional[bytes] = Setting(None) - # Widgets in the same schema share namespace through a dictionary whose - # key is self.signalManager. ales-erjavec expressed concern (and I fully - # agree!) about widget being aware of the outside world. I am leaving this - # anyway. If this causes any problems in the future, replace this with - # shared_namespaces = {} and thus use a common namespace for all instances - # of # PythonScript even if they are in different schemata. - shared_namespaces = weakref.WeakKeyDictionary() + vimModeEnabled = Setting(False) class Error(OWWidget.Error): pass def __init__(self): super().__init__() - self.libraryListSource = [] for name in self.signal_names: - setattr(self, name, {}) + setattr(self, name, []) - self._cachedDocuments = {} + self.splitCanvas = QSplitter(Qt.Vertical, self.mainArea) + self.mainArea.layout().addWidget(self.splitCanvas) + + # Styling + + self.defaultFont = defaultFont = ( + 'Menlo' if sys.platform == 'darwin' else + 'Courier' if sys.platform in ['win32', 'cygwin'] else + 'DejaVu Sans Mono' + ) + self.defaultFontSize = defaultFontSize = 13 + + self.editorBox = gui.vBox(self, box="Editor", spacing=4) + self.splitCanvas.addWidget(self.editorBox) - self.infoBox = gui.vBox(self.controlArea, 'Info') - gui.label( - self.infoBox, self, - "

    Execute python script.

    Input variables:

    • " + - "
    • ".join(map("in_{0}, in_{0}s".format, self.signal_names)) + - "

    Output variables:

    • " + - "
    • ".join(map("out_{0}".format, self.signal_names)) + - "

    " + darkMode = QApplication.instance().property('darkMode') + scheme_name = 'Dark' if darkMode else 'Light' + syntax_highlighting_scheme = SYNTAX_HIGHLIGHTING_STYLES[scheme_name] + self.pygments_style_class = make_pygments_style(scheme_name) + + eFont = QFont(defaultFont) + eFont.setPointSize(defaultFontSize) + + # Fake Signature + + self.func_sig = func_sig = FunctionSignature( + self.editorBox, + syntax_highlighting_scheme, + eFont ) + # Editor + + editor = PythonEditor(self) + editor.setFont(eFont) + editor.setup_completer_appearance((300, 180), eFont) + + # Fake return + + return_stmt = ReturnStatement( + self.editorBox, + syntax_highlighting_scheme, + eFont + ) + self.return_stmt = return_stmt + + # Match indentation + + textEditBox = QWidget(self.editorBox) + textEditBox.setLayout(QHBoxLayout()) + char_4_width = QFontMetrics(eFont).horizontalAdvance('0000') + + @editor.viewport_margins_updated.connect + def _(width): + func_sig.setIndent(width) + textEditMargin = max(0, round(char_4_width - width)) + return_stmt.setIndent(textEditMargin + width) + textEditBox.layout().setContentsMargins( + textEditMargin, 0, 0, 0 + ) + + self.text = editor + textEditBox.layout().addWidget(editor) + self.editorBox.layout().addWidget(func_sig) + self.editorBox.layout().addWidget(textEditBox) + self.editorBox.layout().addWidget(return_stmt) + + self.editorBox.setAlignment(Qt.AlignVCenter) + + self.text.modificationChanged[bool].connect(self.onModificationChanged) + + # Controls + + self.editor_controls = gui.vBox(self.controlArea, box='Preferences') + + self.vim_box = gui.hBox(self.editor_controls, spacing=20) + self.vim_indicator = VimIndicator(self.vim_box) + + vim_sp = QSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Fixed + ) + vim_sp.setRetainSizeWhenHidden(True) + self.vim_indicator.setSizePolicy(vim_sp) + + def enable_vim_mode(): + editor.vimModeEnabled = self.vimModeEnabled + self.vim_indicator.setVisible(self.vimModeEnabled) + enable_vim_mode() + + gui.checkBox( + self.vim_box, self, 'vimModeEnabled', 'Vim mode', + tooltip="Only for the coolest.", + callback=enable_vim_mode + ) + self.vim_box.layout().addWidget(self.vim_indicator) + @editor.vimModeIndicationChanged.connect + def _(color, text): + self.vim_indicator.indicator_color = color + self.vim_indicator.indicator_text = text + self.vim_indicator.update() + + # Library + + self.libraryListSource = [] + self._cachedDocuments = {} + self.libraryList = itemmodels.PyListModel( [], self, flags=Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable) @@ -489,8 +686,7 @@ def __init__(self): self.controlBox.layout().setSpacing(1) self.libraryView = QListView( - editTriggers=QListView.DoubleClicked | - QListView.EditKeyPressed, + editTriggers=QListView.DoubleClicked | QListView.EditKeyPressed, sizePolicy=QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) ) @@ -545,23 +741,9 @@ def __init__(self): self.execute_button = gui.button(self.buttonsArea, self, 'Run', callback=self.commit) - run = QAction("Run script", self, triggered=self.commit, - shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_R)) - self.addAction(run) - - self.splitCanvas = QSplitter(Qt.Vertical, self.mainArea) - self.mainArea.layout().addWidget(self.splitCanvas) - - self.defaultFont = defaultFont = \ - "Monaco" if sys.platform == "darwin" else "Courier" - - self.textBox = gui.vBox(self.splitCanvas, 'Python Script') - self.text = PythonScriptEditor(self) - self.textBox.layout().addWidget(self.text) - - self.textBox.setAlignment(Qt.AlignVCenter) - - self.text.modificationChanged[bool].connect(self.onModificationChanged) + self.run_action = QAction("Run script", self, triggered=self.commit, + shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_R)) + self.addAction(self.run_action) self.saveAction = action = QAction("&Save", self.text) action.setToolTip("Save script to file") @@ -575,7 +757,6 @@ def __init__(self): self.console.document().setDefaultFont(QFont(defaultFont)) self.consoleBox.setAlignment(Qt.AlignBottom) self.splitCanvas.setSizes([2, 1]) - self.setAcceptDrops(True) self.controlArea.layout().addStretch(10) self._restoreState() @@ -603,31 +784,72 @@ def _saveState(self): self.scriptText = self.text.toPlainText() self.splitterState = bytes(self.splitCanvas.saveState()) - def handle_input(self, obj, sig_id, signal): + def set_input(self, index, obj, signal): dic = getattr(self, signal) - if obj is None: - if sig_id in dic.keys(): - del dic[sig_id] - else: - dic[sig_id] = obj + dic[index] = obj + + def insert_input(self, index, obj, signal): + dic = getattr(self, signal) + dic.insert(index, obj) + + def remove_input(self, index, signal): + dic = getattr(self, signal) + dic.pop(index) @Inputs.data - def set_data(self, data, sig_id): - self.handle_input(data, sig_id, "data") + def set_data(self, index, data): + self.set_input(index, data, "data") + + @Inputs.data.insert + def insert_data(self, index, data): + self.insert_input(index, data, "data") + + @Inputs.data.remove + def remove_data(self, index): + self.remove_input(index, "data") @Inputs.learner - def set_learner(self, data, sig_id): - self.handle_input(data, sig_id, "learner") + def set_learner(self, index, learner): + self.set_input(index, learner, "learner") + + @Inputs.learner.insert + def insert_learner(self, index, learner): + self.insert_input(index, learner, "learner") + + @Inputs.learner.remove + def remove_learner(self, index): + self.remove_input(index, "learner") @Inputs.classifier - def set_classifier(self, data, sig_id): - self.handle_input(data, sig_id, "classifier") + def set_classifier(self, index, classifier): + self.set_input(index, classifier, "classifier") + + @Inputs.classifier.insert + def insert_classifier(self, index, classifier): + self.insert_input(index, classifier, "classifier") + + @Inputs.classifier.remove + def remove_classifier(self, index): + self.remove_input(index, "classifier") @Inputs.object - def set_object(self, data, sig_id): - self.handle_input(data, sig_id, "object") + def set_object(self, index, object): + self.set_input(index, object, "object") + + @Inputs.object.insert + def insert_object(self, index, object): + self.insert_input(index, object, "object") + + @Inputs.object.remove + def remove_object(self, index): + self.remove_input(index, "object") def handleNewSignals(self): + # update fake signature labels + self.func_sig.update_signal_text({ + n: len(getattr(self, n)) for n in self.signal_names + }) + self.commit() def selectedScriptIndex(self): @@ -652,8 +874,7 @@ def onAddScriptFromFile(self, *_): ) if filename: name = os.path.basename(filename) - # TODO: use `tokenize.detect_encoding` - with open(filename, encoding="utf-8") as f: + with tokenize.open(filename) as f: contents = f.read() self.libraryList.append(Script(name, contents, 0, filename)) self.setSelectedScript(len(self.libraryList) - 1) @@ -688,7 +909,9 @@ def documentForScript(self, script=0): doc.setDocumentLayout(QPlainTextDocumentLayout(doc)) doc.setPlainText(script.script) doc.setDefaultFont(QFont(self.defaultFont)) - doc.highlighter = PythonSyntaxHighlighter(doc) + doc.highlighter = PygmentsHighlighter(doc) + doc.highlighter.set_style(self.pygments_style_class) + doc.setDefaultFont(QFont(self.defaultFont, pointSize=self.defaultFontSize)) doc.modificationChanged[bool].connect(self.onModificationChanged) doc.setModified(False) self._cachedDocuments[script] = doc @@ -740,23 +963,15 @@ def saveScript(self): f.close() def initial_locals_state(self): - d = self.shared_namespaces.setdefault(self.signalManager, {}).copy() + d = {} for name in self.signal_names: value = getattr(self, name) - all_values = list(value.values()) + all_values = list(value) one_value = all_values[0] if len(all_values) == 1 else None d["in_" + name + "s"] = all_values d["in_" + name] = one_value return d - def update_namespace(self, namespace): - not_saved = reduce(set.union, - ({f"in_{name}s", f"in_{name}", f"out_{name}"} - for name in self.signal_names)) - self.shared_namespaces.setdefault(self.signalManager, {}).update( - {name: value for name, value in namespace.items() - if name not in not_saved}) - def commit(self): self.Error.clear() lcls = self.initial_locals_state() @@ -765,7 +980,6 @@ def commit(self): self.console.write("\nRunning script:\n") self.console.push("exec(_script)") self.console.new_prompt(sys.ps1) - self.update_namespace(self.console.locals) for signal in self.signal_names: out_var = self.console.locals.get("out_" + signal) signal_type = getattr(self.Outputs, signal).type @@ -777,6 +991,14 @@ def commit(self): out_var = None getattr(self.Outputs, signal).send(out_var) + def keyPressEvent(self, e): + if e.matches(QKeySequence.InsertLineSeparator): + # run on Shift+Enter, Ctrl+Enter + self.run_action.trigger() + e.accept() + else: + super().keyPressEvent(e) + def dragEnterEvent(self, event): # pylint: disable=no-self-use urls = event.mimeData().urls() if urls: @@ -785,12 +1007,6 @@ def dragEnterEvent(self, event): # pylint: disable=no-self-use if c is not None: event.acceptProposedAction() - def dropEvent(self, event): - """Handle file drops""" - urls = event.mimeData().urls() - if urls: - self.text.pasteFile(urls[0]) - @classmethod def migrate_settings(cls, settings, version): if version is not None and version < 2: @@ -799,6 +1015,44 @@ def migrate_settings(cls, settings, version): for s in scripts] # type: List[_ScriptData] settings["scriptLibrary"] = library + def onDeleteWidget(self): + self.text.terminate() + super().onDeleteWidget() + + +class OWPythonScriptDropHandler(SingleFileDropHandler): + WIDGET = OWPythonScript + + def canDropFile(self, path: str) -> bool: + md = QMimeDatabase() + mt = md.mimeTypeForFile(path) + return mt.inherits("text/x-python") + + def parametersFromFile(self, path: str) -> Dict[str, Any]: + with open(path, "rt") as f: + content = f.read() + + item: '_ScriptData' = { + "name": os.path.basename(path), + "script": content, + "filename": path, + } + defaults: List['_ScriptData'] = \ + OWPythonScript.settingsHandler.defaults.get("scriptLibrary", []) + + def is_same(item: '_ScriptData'): + """Is item same file as the dropped path.""" + return item["filename"] is not None \ + and samepath(item["filename"], path) + + defaults = [it for it in defaults if not is_same(it)] + params = { + "__version__": OWPythonScript.settings_version, + "scriptLibrary": [item] + defaults, + "scriptText": content + } + return params + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWPythonScript).run() diff --git a/Orange/widgets/data/owrandomize.py b/Orange/widgets/data/owrandomize.py index 7556d0567e1..f415dd483fc 100644 --- a/Orange/widgets/data/owrandomize.py +++ b/Orange/widgets/data/owrandomize.py @@ -14,9 +14,10 @@ class OWRandomize(OWWidget): name = "Randomize" description = "Randomize features, class and/or metas in data table." + category = "Transform" icon = "icons/Random.svg" - priority = 2100 - keywords = [] + priority = 2200 + keywords = "randomize, random" class Inputs: data = Input("Data", Table) @@ -66,18 +67,18 @@ def __init__(self): box, self, "random_seed", "Replicable shuffling", callback=self._shuffle_check_changed) - gui.auto_apply(self.buttonsArea, self, commit=self.apply) + gui.auto_apply(self.buttonsArea, self) @property def parts(self): return [self.shuffle_class, self.shuffle_attrs, self.shuffle_metas] def _shuffle_check_changed(self): - self.apply() + self.commit.deferred() def _scope_slider_changed(self): self._set_scope_label() - self.apply() + self.commit.deferred() def _set_scope_label(self): self.scope_label.setText("{}%".format(self.scope_prop)) @@ -85,9 +86,10 @@ def _set_scope_label(self): @Inputs.data def set_data(self, data): self.data = data - self.unconditional_apply() + self.commit.now() - def apply(self): + @gui.deferred + def commit(self): data = None if self.data: rand_seed = self.random_seed or None @@ -97,8 +99,9 @@ def apply(self): type_ = sum(t for t, p in zip(Randomize.Type, self.parts) if p) randomized = Randomize(type_, rand_seed)(self.data[indices]) data = self.data.copy() - for i, instance in zip(indices, randomized): - data[i] = instance + with data.unlocked(): + for i, instance in zip(indices, randomized): + data[i] = instance self.Outputs.data.send(data) def send_report(self): diff --git a/Orange/widgets/data/owrank.py b/Orange/widgets/data/owrank.py index 4b746473989..d8c16a5bf8a 100644 --- a/Orange/widgets/data/owrank.py +++ b/Orange/widgets/data/owrank.py @@ -1,39 +1,41 @@ import logging -import warnings -from collections import OrderedDict, namedtuple +from collections import namedtuple from functools import partial from itertools import chain from types import SimpleNamespace from typing import Any, Callable, List, Tuple import numpy as np +from scipy.sparse import issparse + from AnyQt.QtCore import ( QItemSelection, QItemSelectionModel, QItemSelectionRange, Qt, pyqtSignal as Signal ) -from AnyQt.QtGui import QFontMetrics from AnyQt.QtWidgets import ( QButtonGroup, QCheckBox, QGridLayout, QHeaderView, QItemDelegate, QRadioButton, QStackedWidget, QTableView ) -from orangewidget.settings import IncompatibleContext -from scipy.sparse import issparse +from orangewidget.settings import IncompatibleContext from Orange.data import ( ContinuousVariable, DiscreteVariable, Domain, StringVariable, Table ) from Orange.data.util import get_unique_names_duplicates from Orange.preprocess import score from Orange.widgets import gui, report +from Orange.widgets.gui import BarRatioTableModel from Orange.widgets.settings import ( ContextSetting, DomainContextHandler, Setting ) from Orange.widgets.unsupervised.owdistances import InterruptException +from Orange.widgets.utils import enum2int from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState -from Orange.widgets.utils.itemmodels import PyTableModel from Orange.widgets.utils.sql import check_sql_input +from Orange.widgets.utils.itemmodels import VariableListModel from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import AttributeList, Input, Msg, Output, OWWidget +from Orange.widgets.widget import AttributeList, Input, MultiInput, Output, Msg, \ + OWWidget log = logging.getLogger(__name__) @@ -74,6 +76,31 @@ def from_variable(cls, variable): ] SCORES = CLS_SCORES + REG_SCORES +VARNAME_COL, NVAL_COL = range(2) + + +class RankTableModel(BarRatioTableModel): + """ + BarRatioTableModel that passes the first column (variables) into + VariableListModel to get the tooltips and decoration + """ + def __init__(self, *args, **kwargs): + self._variable_model = VariableListModel() + super().__init__(*args, **kwargs) + + def wrap(self, table): + super().wrap(table) + self._variable_model[:] = [var for var, *_ in table] + + def data(self, index, role=Qt.DisplayRole): + column = index.column() + if column == 0: + row = self.mapToSourceRows(index.row()) + index = self._variable_model.index(row, column) + return self._variable_model.data(index, role) + else: + return super().data(index, role) + class TableView(QTableView): manualSelection = Signal() @@ -87,14 +114,12 @@ def __init__(self, parent=None, **kwargs): cornerButtonEnabled=False, alternatingRowColors=False, **kwargs) - self.setItemDelegate(gui.ColoredBarItemDelegate(self)) - self.setItemDelegateForColumn(0, QItemDelegate()) - - header = self.verticalHeader() - header.setSectionResizeMode(header.Fixed) - header.setFixedWidth(50) - header.setDefaultSectionSize(22) - header.setTextElideMode(Qt.ElideMiddle) # Note: https://bugreports.qt.io/browse/QTBUG-62091 + # setItemDelegate(ForColumn) doesn't take ownership of delegates + self._bar_delegate = gui.ColoredBarItemDelegate(self) + self._del0, self._del1 = QItemDelegate(), QItemDelegate() + self.setItemDelegate(self._bar_delegate) + self.setItemDelegateForColumn(VARNAME_COL, self._del0) + self.setItemDelegateForColumn(NVAL_COL, self._del1) header = self.horizontalHeader() header.setSectionResizeMode(header.Fixed) @@ -102,77 +127,11 @@ def __init__(self, parent=None, **kwargs): header.setDefaultSectionSize(80) header.setTextElideMode(Qt.ElideMiddle) - def setVHeaderFixedWidthFromLabel(self, max_label): - header = self.verticalHeader() - width = QFontMetrics(header.font()).horizontalAdvance(max_label) - header.setFixedWidth(min(width + 40, 400)) - def mousePressEvent(self, event): super().mousePressEvent(event) self.manualSelection.emit() -class TableModel(PyTableModel): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._extremes = {} - - def data(self, index, role=Qt.DisplayRole): - if role == gui.BarRatioRole and index.isValid(): - value = super().data(index, Qt.EditRole) - if not isinstance(value, float): - return None - vmin, vmax = self._extremes.get(index.column(), (-np.inf, np.inf)) - value = (value - vmin) / ((vmax - vmin) or 1) - return value - - if role == Qt.DisplayRole: - role = Qt.EditRole - - value = super().data(index, role) - - # Display nothing for non-existent attr value counts in the first column - if role == Qt.EditRole and index.column() == 0 and np.isnan(value): - return '' - - return value - - def headerData(self, section, orientation, role=Qt.DisplayRole): - if role == Qt.InitialSortOrderRole: - return Qt.DescendingOrder - return super().headerData(section, orientation, role) - - def setExtremesFrom(self, column, values): - """Set extremes for columnn's ratio bars from values""" - try: - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", ".*All-NaN slice encountered.*", RuntimeWarning) - vmin = np.nanmin(values) - if np.isnan(vmin): - raise TypeError - except TypeError: - vmin, vmax = -np.inf, np.inf - else: - vmax = np.nanmax(values) - self._extremes[column] = (vmin, vmax) - - def resetSorting(self, yes_reset=False): - # pylint: disable=arguments-differ - """We don't want to invalidate our sort proxy model everytime we - wrap a new list. Our proxymodel only invalidates explicitly - (i.e. when new data is set)""" - if yes_reset: - super().resetSorting() - - def _argsortData(self, data, order): - """Always sort NaNs last""" - indices = np.argsort(data, kind='mergesort') - if order == Qt.DescendingOrder: - return np.roll(indices[::-1], -np.isnan(data).sum()) - return indices - - class Results(SimpleNamespace): method_scores: Tuple[ScoreMeta, np.ndarray] = None scorer_scores: Tuple[ScoreMeta, Tuple[np.ndarray, List[str]]] = None @@ -250,17 +209,17 @@ class OWRank(OWWidget, ConcurrentWidgetMixin): description = "Rank and filter data features by their relevance." icon = "icons/Rank.svg" priority = 1102 - keywords = [] + keywords = "rank, filter" buttons_area_orientation = Qt.Vertical class Inputs: data = Input("Data", Table) - scorer = Input("Scorer", score.Scorer, multiple=True) + scorer = MultiInput("Scorer", score.Scorer, filter_none=True) class Outputs: reduced_data = Output("Reduced Data", Table, default=True) - scores = Output("Scores", Table) + scores = Output("Scores", Table, dynamic=False) features = Output("Features", AttributeList, dynamic=False) SelectNone, SelectAll, SelectManual, SelectNBest = range(4) @@ -268,10 +227,10 @@ class Outputs: nSelected = ContextSetting(5) auto_apply = Setting(True) - sorting = Setting((0, Qt.DescendingOrder)) + sorting = Setting((0, enum2int(Qt.DescendingOrder))) selected_methods = Setting(set()) - settings_version = 3 + settings_version = 4 settingsHandler = DomainContextHandler() selected_attrs = ContextSetting([], schema_only=True) selectionMethod = ContextSetting(SelectNBest) @@ -292,7 +251,7 @@ class Warning(OWWidget.Warning): def __init__(self): OWWidget.__init__(self) ConcurrentWidgetMixin.__init__(self) - self.scorers = OrderedDict() + self.scorers: List[ScoreMeta] = [] self.out_domain_desc = None self.data = None self.problem_type_mode = ProblemType.CLASSIFICATION @@ -306,11 +265,12 @@ def __init__(self): if method.is_default} # GUI - self.ranksModel = model = TableModel(parent=self) # type: TableModel + self.ranksModel = model = RankTableModel(parent=self) # type: + # BarRatioTableModel self.ranksView = view = TableView(self) # type: TableView self.mainArea.layout().addWidget(view) view.setModel(model) - view.setColumnWidth(0, 30) + view.setColumnWidth(1, 30) view.selectionModel().selectionChanged.connect(self.on_select) def _set_select_manual(): @@ -346,7 +306,7 @@ def _set_select_manual(): grid.setContentsMargins(0, 0, 0, 0) grid.setSpacing(6) self.selectButtons = QButtonGroup() - self.selectButtons.buttonClicked[int].connect(self.setSelectionMethod) + self.selectButtons.idClicked.connect(self.setSelectionMethod) def button(text, buttonid, toolTip=None): b = QRadioButton(text) @@ -424,10 +384,6 @@ def set_data(self, data): if problem_type is not None: self.switchProblemType(problem_type) - self.ranksModel.setVerticalHeaderLabels(domain.attributes) - self.ranksView.setVHeaderFixedWidthFromLabel( - max((a.name for a in domain.attributes), key=len)) - self.selectionMethod = OWRank.SelectNBest self.openContext(data) @@ -440,18 +396,27 @@ def handleNewSignals(self): self.on_select() @Inputs.scorer - def set_learner(self, scorer, id): # pylint: disable=redefined-builtin - if scorer is None: - self.scorers.pop(id, None) - else: - # Avoid caching a (possibly stale) previous instance of the same - # Scorer passed via the same signal - if id in self.scorers: - self.scorers_results = {} + def set_learner(self, index, scorer): + self.scorers[index] = ScoreMeta( + scorer.name, scorer.name, scorer, + ProblemType.from_variable(scorer.class_type), + False + ) + self.scorers_results = {} - self.scorers[id] = ScoreMeta(scorer.name, scorer.name, scorer, - ProblemType.from_variable(scorer.class_type), - False) + @Inputs.scorer.insert + def insert_learner(self, index: int, scorer): + self.scorers.insert(index, ScoreMeta( + scorer.name, scorer.name, scorer, + ProblemType.from_variable(scorer.class_type), + False + )) + self.scorers_results = {} + + @Inputs.scorer.remove + def remove_learner(self, index): + self.scorers.pop(index) + self.scorers_results = {} def _get_methods(self): return [ @@ -469,7 +434,7 @@ def _get_methods(self): def _get_scorers(self): scorers = [] - for scorer in self.scorers.values(): + for scorer in self.scorers: if scorer.problem_type in ( self.problem_type_mode, ProblemType.UNSUPERVISED, @@ -510,28 +475,33 @@ def on_done(self, result: Results) -> None: labels = method_labels + tuple(chain.from_iterable(scorer_labels)) model_array = np.column_stack( - ( - [len(a.values) if a.is_discrete else np.nan + (list(self.data.domain.attributes), ) + + ( + [float(len(a.values)) if a.is_discrete else np.nan for a in self.data.domain.attributes], ) + method_scores + scorer_scores ) - for column, values in enumerate(model_array.T): + for column, values in enumerate(model_array.T[2:].astype(float), + start=2): self.ranksModel.setExtremesFrom(column, values) self.ranksModel.wrap(model_array.tolist()) - self.ranksModel.setHorizontalHeaderLabels(('#',) + labels) - self.ranksView.setColumnWidth(0, 40) + self.ranksModel.setHorizontalHeaderLabels(('', '#',) + labels) + self.ranksView.setColumnWidth(1, 40) + self.ranksView.resizeColumnToContents(0) # Re-apply sort try: sort_column, sort_order = self.sorting if sort_column < len(labels): - # adds 1 for '#' (discrete count) column - self.ranksModel.sort(sort_column + 1, sort_order) + # adds 2 to skip the first two columns + # Qt.SortOrder is Enum in PyQt6 and int-like object in PyQt5 + # in both cases Qt.SortOrder transforms int sort_order to required type + self.ranksModel.sort(sort_column + 2, Qt.SortOrder(sort_order)) self.ranksView.horizontalHeader().setSortIndicator( - sort_column + 1, sort_order + sort_column + 2, Qt.SortOrder(sort_order) ) except ValueError: pass @@ -551,7 +521,7 @@ def on_select(self): row_indices = [i.row() for i in selected_rows] attr_indices = self.ranksModel.mapToSourceRows(row_indices) self.selected_attrs = [self.data.domain[idx] for idx in attr_indices] - self.commit() + self.commit.deferred() def setSelectionMethod(self, method): self.selectionMethod = method @@ -589,16 +559,17 @@ def autoSelection(self): selModel.select(selection, QItemSelectionModel.ClearAndSelect) def headerClick(self, index): - if index >= 1 and self.selectionMethod == OWRank.SelectNBest: + if index >= 2 and self.selectionMethod == OWRank.SelectNBest: # Reselect the top ranked attributes self.autoSelection() # Store the header states - sort_order = self.ranksModel.sortOrder() - sort_column = self.ranksModel.sortColumn() - 1 # -1 for '#' (discrete count) column + sort_order = enum2int(self.ranksModel.sortOrder()) + sort_column = self.ranksModel.sortColumn() - 2 # -2 for name and '#' columns self.sorting = (sort_column, sort_order) - def methodSelectionChanged(self, state, method_name): + def methodSelectionChanged(self, state: int, method_name): + state = Qt.CheckState(state) if state == Qt.Checked: self.selected_methods.add(method_name) elif method_name in self.selected_methods: @@ -614,6 +585,7 @@ def send_report(self): if self.out_domain_desc is not None: self.report_items("Output", self.out_domain_desc) + @gui.deferred def commit(self): if not self.selected_attrs: self.Outputs.reduced_data.send(None) @@ -631,7 +603,7 @@ def commit(self): def create_scores_table(self, labels): self.Warning.renamed_variables.clear() model_list = self.ranksModel.tolist() - if not model_list or len(model_list[0]) == 1: # Empty or just n_values column + if not model_list or len(model_list[0]) == 2: # Empty or just first two columns return None unique, renamed = get_unique_names_duplicates(labels + ('Feature',), return_duplicated=True) @@ -643,7 +615,7 @@ def create_scores_table(self, labels): # Prevent np.inf scores finfo = np.finfo(np.float64) - scores = np.clip(np.array(model_list)[:, 1:], finfo.min, finfo.max) + scores = np.clip(np.array(model_list)[:, 2:], finfo.min, finfo.max) feature_names = np.array([a.name for a in self.data.domain.attributes]) # Reshape to 2d array as Table does not like 1d arrays @@ -671,6 +643,12 @@ def migrate_settings(cls, settings, version): column, order = hview.sortIndicatorSection() - 1, hview.sortIndicatorOrder() settings["sorting"] = (column, order) + # before we saved sort order as Qt.SortOrder object, now it is integer + # help users with SortOrder as setting migrate to int setting + if "sorting" in settings: + column, order = settings["sorting"] + settings["sorting"] = (column, enum2int(order)) + @classmethod def migrate_context(cls, context, version): if version is None or version < 3: diff --git a/Orange/widgets/data/owsave.py b/Orange/widgets/data/owsave.py index eee2d338afd..be2c202a946 100644 --- a/Orange/widgets/data/owsave.py +++ b/Orange/widgets/data/owsave.py @@ -18,9 +18,9 @@ class OWSave(OWSaveBase): description = "Save data to an output file." icon = "icons/Save.svg" category = "Data" - keywords = ["export"] + keywords = "save data, export" - settings_version = 2 + settings_version = 3 class Inputs: data = Input("Data", Table) @@ -67,6 +67,9 @@ def dataset(self, data): self.on_new_input() def do_save(self): + if self.writer is None: + super().do_save() # This will do nothing but indicate an error + return if self.data.is_sparse() and not self.writer.SUPPORT_SPARSE_DATA: return self.writer.write(self.filename, self.data, self.add_type_annotations) @@ -75,7 +78,8 @@ def update_messages(self): super().update_messages() self.Error.unsupported_sparse( shown=self.data is not None and self.data.is_sparse() - and self.filename and not self.writer.SUPPORT_SPARSE_DATA) + and self.filename + and self.writer is not None and not self.writer.SUPPORT_SPARSE_DATA) def send_report(self): self.report_data_brief(self.data) @@ -83,9 +87,9 @@ def send_report(self): noyes = ["No", "Yes"] self.report_items(( ("File name", self.filename or "not set"), - ("Format", writer.DESCRIPTION), + ("Format", writer and writer.DESCRIPTION), ("Type annotations", - writer.OPTIONAL_TYPE_ANNOTATIONS + writer and writer.OPTIONAL_TYPE_ANNOTATIONS and noyes[self.add_type_annotations]) )) @@ -116,13 +120,21 @@ def migrate_to_version_2(): if version < 2: migrate_to_version_2() + if version < 3: + if settings.get("add_type_annotations") and \ + settings.get("stored_name") and \ + os.path.splitext(settings["stored_name"])[1] == ".xlsx": + settings["add_type_annotations"] = False + def initial_start_dir(self): if self.filename and os.path.exists(os.path.split(self.filename)[0]): - return self.filename + return os.path.splitext(self.filename)[0] else: data_name = getattr(self.data, 'name', '') if data_name: - data_name += self.writer.EXTENSIONS[0] + if self.writer is None: + self.filter = self.default_filter() + assert self.writer is not None return os.path.join(self.last_dir or _userhome, data_name) def valid_filters(self): diff --git a/Orange/widgets/data/owselectbydataindex.py b/Orange/widgets/data/owselectbydataindex.py index 03a5cd8e33a..d4cf00f6194 100644 --- a/Orange/widgets/data/owselectbydataindex.py +++ b/Orange/widgets/data/owselectbydataindex.py @@ -3,6 +3,7 @@ from Orange.data import Table from Orange.widgets import widget, gui from Orange.widgets.utils import itemmodels +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output @@ -12,8 +13,10 @@ class OWSelectByDataIndex(widget.OWWidget): name = "Select by Data Index" description = "Match instances by index from data subset." + category = "Transform" icon = "icons/SelectByDataIndex.svg" priority = 1112 + keywords="_keywords" class Inputs: data = Input("Data", Table) @@ -44,49 +47,43 @@ def __init__(self): self.extra_model_unique = itemmodels.VariableListModel() self.extra_model_unique_with_id = itemmodels.VariableListModel() - box = gui.hBox(self.controlArea, box=None) - self.infoBoxData = gui.label( - box, self, self.data_info_text(None), box="Data") - self.infoBoxExtraData = gui.label( - box, self, self.data_info_text(None), box="Data Subset") + box = gui.widgetBox(self.controlArea, True) + gui.label( + box, self, """ +Data rows keep their identity even when some or all original variables +are replaced by variables computed from the original ones. + +This widget gets two data tables ("Data" and "Data Subset") that +can be traced back to the same source. It selects all rows from Data +that appear in Data Subset, based on row identity and not actual data. +""".strip(), box=True) @Inputs.data @check_sql_input def set_data(self, data): self.data = data - self.infoBoxData.setText(self.data_info_text(data)) @Inputs.data_subset @check_sql_input def set_data_subset(self, data): self.data_subset = data - self.infoBoxExtraData.setText(self.data_info_text(data)) def handleNewSignals(self): self._invalidate() - @staticmethod - def data_info_text(data): - if data is None: - return "No data." - else: - return "{}\n{} instances\n{} variables".format( - data.name, len(data), len(data.domain.variables) + len(data.domain.metas)) - def commit(self): self.Warning.instances_not_matching.clear() - subset_ids = [] - if self.data_subset: - subset_ids = self.data_subset.ids if not self.data: matching_output = None non_matching_output = None annotated_output = None else: - if self.data_subset and \ - not np.intersect1d(subset_ids, self.data.ids).size: - self.Warning.instances_not_matching() - row_sel = np.in1d(self.data.ids, subset_ids) + subset_ids = [] + if self.data_subset is not None: + subset_ids = self.data_subset.ids + if not np.intersect1d(subset_ids, self.data.ids).size: + self.Warning.instances_not_matching() + row_sel = np.isin(self.data.ids, subset_ids) matching_output = self.data[row_sel] non_matching_output = self.data[~row_sel] annotated_output = create_annotated_table(self.data, row_sel) @@ -99,9 +96,17 @@ def _invalidate(self): self.commit() def send_report(self): - d_text = self.data_info_text(self.data).replace("\n", ", ") - ds_text = self.data_info_text(self.data_subset).replace("\n", ", ") - self.report_items("", [("Data", d_text), ("Data Subset", ds_text)]) + def data_info_text(data): + if data is None: + return "No data." + nvars = len(data.domain.variables) + len(data.domain.metas) + return f"{data.name}, " \ + f"{len(data)} {pl(len(data), 'instance')}, " \ + f"{nvars} {pl(nvars, 'variable')}" + + self.report_items("", + [("Data", data_info_text(self.data)), + ("Data Subset", data_info_text(self.data_subset))]) if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/data/owselectcolumns.py b/Orange/widgets/data/owselectcolumns.py index 86f99be760c..ed028a3c5e0 100644 --- a/Orange/widgets/data/owselectcolumns.py +++ b/Orange/widgets/data/owselectcolumns.py @@ -1,12 +1,12 @@ from functools import partial from typing import Optional, Dict, Tuple -from AnyQt.QtWidgets import QWidget, QGridLayout -from AnyQt.QtWidgets import QListView from AnyQt.QtCore import ( Qt, QTimer, QSortFilterProxyModel, QItemSelection, QItemSelectionModel, QMimeData, QAbstractItemModel ) +from AnyQt.QtGui import QDrag, QDropEvent +from AnyQt.QtWidgets import QWidget, QGridLayout, QListView from Orange.data import Domain, Variable from Orange.widgets import gui, widget @@ -50,6 +50,10 @@ class VariablesListItemModel(VariableListModel): """ MIME_TYPE = "application/x-Orange-VariableListModelData" + def __init__(self, *args, primitive=False, **kwargs): + super().__init__(*args, **kwargs) + self.primitive = primitive + def flags(self, index): flags = super().flags(index) if index.isValid(): @@ -59,7 +63,7 @@ def flags(self, index): return flags @staticmethod - def supportedDropActions(): + def supportedDropActions(): # pylint: disable=arguments-differ return Qt.MoveAction # pragma: no cover @staticmethod @@ -88,20 +92,95 @@ def dropMimeData(self, mime, action, row, column, parent): Reimplemented. """ if action == Qt.IgnoreAction: - return True # pragma: no cover + return True if not mime.hasFormat(self.MIME_TYPE): - return False # pragma: no cover + return False variables = mime.property("_items") if variables is None: - return False # pragma: no cover + return False if row < 0: row = self.rowCount() + if self.primitive and not all(var.is_primitive() for var in variables): + variables = [var for var in variables if var.is_primitive()] + self[row:row] = variables + mime.setProperty("_moved", variables) + return bool(variables) + self[row:row] = variables + mime.setProperty("_moved", True) + return True + + +class SelectedVarsView(VariablesListItemView): + """ + VariableListItemView that supports partially accepted drags. + + Upon finish, the mime data contains a list of variables accepted by the + destination, and removes only those variables from the model. + """ + def startDrag(self, supported_actions): + indexes = self.selectedIndexes() + if len(indexes) == 0: + return + data = self.model().mimeData(indexes) + if not data: + return + drag = QDrag(self) + drag.setMimeData(data) + res = drag.exec(supported_actions, Qt.DropAction.MoveAction) + + moved = data.property("_moved") + if moved is None: + return + + if moved is True: + # A quicker path if everything is moved. + # When removing rows, private method QAbstractItemView::clearOrRemove + # iterates over ranges and removes them, apparently assuming their + # reverse order. I haven't found any guarantee for this order in + # documentation (nor, actually, in the code that maintains their + # order, so let's sort them. + to_remove = sorted( + ((index.top(), index.bottom() + 1) + for index in self.selectionModel().selection()), + reverse=True) + else: + moved = set(moved) + to_remove = reversed(list(slices( + index.row() for index in self.selectionModel().selectedIndexes() + if index.data(gui.TableVariable) in moved))) + + for start, end in to_remove: + self.model().removeRows(start, end - start) + + self.dragDropActionDidComplete.emit(res) + + +class PrimitivesView(SelectedVarsView): + """ + A SelectedVarsView that accepts drops events if it contains *any* + primitive variables. This overrides the inherited behaviour that accepts + the event only if *all* variables are primitive. + """ + def acceptsDropEvent(self, event: QDropEvent) -> bool: + if event.source() is not None and \ + event.source().window() is not self.window(): + return False # pragma: nocover + + mime = event.mimeData() + items = mime.property('_items') + if items is None or not any(var.is_primitive() for var in items): + return False + + event.accept() return True +# It is, what it is (and should be), pylint: disable=invalid-name class SelectAttributesDomainContextHandler(DomainContextHandler): + # Context handler's methods have variable arguments, + # pylint: disable=arguments-differ,keyword-arg-before-vararg def encode_setting(self, context, setting, value): if setting.name == 'domain_role_hints': value = {(var.name, vartype(var)): role_i @@ -134,7 +213,8 @@ def match(self, context, domain, attrs, metas): def filter_value(self, setting, data, domain, attrs, metas): if setting.name != "domain_role_hints": - return super().filter_value(setting, data, domain, attrs, metas) + super().filter_value(setting, data, domain, attrs, metas) + return all_vars = attrs.copy() all_vars.update(metas) @@ -149,9 +229,10 @@ class OWSelectAttributes(widget.OWWidget): name = "Select Columns" description = "Select columns from the data table and assign them to " \ "data features, classes or meta variables." + category = "Transform" icon = "icons/SelectColumns.svg" priority = 100 - keywords = ["filter", "attributes", "target", "variable"] + keywords = "select columns, filter, attributes, target, variable" class Inputs: data = Input("Data", Table, default=True) @@ -185,6 +266,12 @@ def __init__(self): self.__interface_update_timer = QTimer(self, interval=0, singleShot=True) self.__interface_update_timer.timeout.connect( self.__update_interface_state) + # If __update_var_counts were connected directly to textChanged of + # view box's edit, it could be called before the related proxy is + # updated + self.__var_counts_update_timer = QTimer(self, interval=0, singleShot=True) + self.__var_counts_update_timer.timeout.connect( + self.update_var_counts) # The last view that has the selection for move operation's source self.__last_active_view = None # type: Optional[QListView] @@ -198,20 +285,27 @@ def update_on_change(view): self.controlArea = new_control_area # init grid + self.view_boxes = [] layout = QGridLayout() self.controlArea.setLayout(layout) layout.setContentsMargins(0, 0, 0, 0) - box = gui.vBox(self.controlArea, "Ignored", + + name = "Ignored" + box = gui.vBox(self.controlArea, name, addToLayout=False) self.available_attrs = VariablesListItemModel() filter_edit, self.available_attrs_view = variables_filter( - parent=self, model=self.available_attrs) + parent=self, model=self.available_attrs, + view_type=SelectedVarsView + ) box.layout().addWidget(filter_edit) + self.view_boxes.append((name, box, self.available_attrs_view)) + filter_edit.textChanged.connect(self.__var_counts_update_timer.start) def dropcompleted(action): if action == Qt.MoveAction: - self.commit() + self.commit.deferred() self.available_attrs_view.selectionModel().selectionChanged.connect( partial(update_on_change, self.available_attrs_view)) @@ -221,12 +315,14 @@ def dropcompleted(action): layout.addWidget(box, 0, 0, 3, 1) # 3rd column - box = gui.vBox(self.controlArea, "Features", addToLayout=False) - self.used_attrs = VariablesListItemModel() + name = "Features" + box = gui.vBox(self.controlArea, name, addToLayout=False) + self.used_attrs = VariablesListItemModel(primitive=True) filter_edit, self.used_attrs_view = variables_filter( parent=self, model=self.used_attrs, accepted_type=(Orange.data.DiscreteVariable, - Orange.data.ContinuousVariable)) + Orange.data.ContinuousVariable), + view_type=PrimitivesView) self.used_attrs.rowsInserted.connect(self.__used_attrs_changed) self.used_attrs.rowsRemoved.connect(self.__used_attrs_changed) self.used_attrs_view.selectionModel().selectionChanged.connect( @@ -243,12 +339,15 @@ def dropcompleted(action): box.layout().addWidget(filter_edit) box.layout().addWidget(self.used_attrs_view) layout.addWidget(box, 0, 2, 1, 1) + self.view_boxes.append((name, box, self.used_attrs_view)) + filter_edit.textChanged.connect(self.__var_counts_update_timer.start) - box = gui.vBox(self.controlArea, "Target", addToLayout=False) - self.class_attrs = VariablesListItemModel() - self.class_attrs_view = VariablesListItemView( + name = "Target" + box = gui.vBox(self.controlArea, name, addToLayout=False) + self.class_attrs = VariablesListItemModel(primitive=True) + self.class_attrs_view = PrimitivesView( acceptedType=(Orange.data.DiscreteVariable, - Orange.data.ContinuousVariable) + Orange.data.ContinuousVariable), ) self.class_attrs_view.setModel(self.class_attrs) self.class_attrs_view.selectionModel().selectionChanged.connect( @@ -257,10 +356,12 @@ def dropcompleted(action): box.layout().addWidget(self.class_attrs_view) layout.addWidget(box, 1, 2, 1, 1) + self.view_boxes.append((name, box, self.class_attrs_view)) - box = gui.vBox(self.controlArea, "Metas", addToLayout=False) + name = "Metas" + box = gui.vBox(self.controlArea, name, addToLayout=False) self.meta_attrs = VariablesListItemModel() - self.meta_attrs_view = VariablesListItemView( + self.meta_attrs_view = SelectedVarsView( acceptedType=Orange.data.Variable) self.meta_attrs_view.setModel(self.meta_attrs) self.meta_attrs_view.selectionModel().selectionChanged.connect( @@ -268,13 +369,14 @@ def dropcompleted(action): self.meta_attrs_view.dragDropActionDidComplete.connect(dropcompleted) box.layout().addWidget(self.meta_attrs_view) layout.addWidget(box, 2, 2, 1, 1) + self.view_boxes.append((name, box, self.meta_attrs_view)) # 2nd column bbox = gui.vBox(self.controlArea, addToLayout=False, margin=0) self.move_attr_button = gui.button( bbox, self, ">", callback=partial(self.move_selected, - self.used_attrs_view) + self.used_attrs_view, primitive=True) ) layout.addWidget(bbox, 0, 1, 1, 1) @@ -282,7 +384,7 @@ def dropcompleted(action): self.move_class_button = gui.button( bbox, self, ">", callback=partial(self.move_selected, - self.class_attrs_view) + self.class_attrs_view, primitive=True) ) layout.addWidget(bbox, 1, 1, 1, 1) @@ -345,6 +447,7 @@ def __use_features_changed(self): # Use input features check box if not self.use_input_features: self.enable_use_features_box() + @gui.deferred def __use_features_clicked(self): # Use input features button self.use_features() @@ -444,7 +547,7 @@ def handleNewSignals(self): if self.use_input_features and self.features_from_data_attributes: self.enable_used_attrs(False) self.use_features() - self.unconditional_commit() + self.commit.now() def check_data(self): self.Warning.mismatching_domain.clear() @@ -469,7 +572,7 @@ def use_features(self): self.available_attrs[:] = [attr for attr in used + available if attr not in attributes] self.used_attrs[:] = attributes - self.commit() + self.commit.deferred() @staticmethod def selected_rows(view): @@ -503,7 +606,7 @@ def itemData(index): view.selectionModel().select( selection, QItemSelectionModel.ClearAndSelect) - self.commit() + self.commit.deferred() def move_up(self, view: QListView): self.move_rows(view, -1) @@ -511,14 +614,19 @@ def move_up(self, view: QListView): def move_down(self, view: QListView): self.move_rows(view, 1) - def move_selected(self, view): + def move_selected(self, view, *, primitive=False): if self.selected_rows(view): self.move_selected_from_to(view, self.available_attrs_view) elif self.selected_rows(self.available_attrs_view): - self.move_selected_from_to(self.available_attrs_view, view) + self.move_selected_from_to(self.available_attrs_view, view, + primitive) - def move_selected_from_to(self, src, dst): - self.move_from_to(src, dst, self.selected_rows(src)) + def move_selected_from_to(self, src, dst, primitive=False): + rows = self.selected_rows(src) + if primitive: + model = src.model().sourceModel() + rows = [row for row in rows if model[row].is_primitive()] + self.move_from_to(src, dst, rows) def move_from_to(self, src, dst, rows): src_model = source_model(src) @@ -531,16 +639,29 @@ def move_from_to(self, src, dst, rows): dst_model.extend(attrs) - self.commit() + self.commit.deferred() def __update_interface_state(self): last_view = self.__last_active_view if last_view is not None: self.update_interface_state(last_view) + def update_var_counts(self): + for name, box, view in self.view_boxes: + model = view.model() + source = source_model(view) # may be the same as model + nall = source.rowCount() + nvars = view.model().rowCount() + if source is not model and model.filter_string(): + box.setTitle(f"{name} ({nvars}/{nall})") + elif nall: + box.setTitle(f"{name} ({nvars})") + else: + box.setTitle(name) + def update_interface_state(self, focus=None): - for view in [self.available_attrs_view, self.used_attrs_view, - self.class_attrs_view, self.meta_attrs_view]: + self.update_var_counts() + for *_, view in self.view_boxes: if view is not focus and not view.hasFocus() \ and view.selectionModel().hasSelection(): view.selectionModel().clear() @@ -555,18 +676,18 @@ def selected_vars(view): meta_selected = selected_vars(self.meta_attrs_view) available_types = set(map(type, available_selected)) - all_primitive = all(var.is_primitive() + any_primitive = any(var.is_primitive() for var in available_types) move_attr_enabled = \ - ((available_selected and all_primitive) or attrs_selected) and \ + ((available_selected and any_primitive) or attrs_selected) and \ self.used_attrs_view.isEnabled() self.move_attr_button.setEnabled(bool(move_attr_enabled)) if move_attr_enabled: self.move_attr_button.setText(">" if available_selected else "<") - move_class_enabled = bool(all_primitive and available_selected) or class_selected + move_class_enabled = bool(any_primitive and available_selected) or class_selected self.move_class_button.setEnabled(bool(move_class_enabled)) if move_class_enabled: @@ -588,6 +709,7 @@ def selected_vars(view): self.__last_active_view = None self.__interface_update_timer.stop() + @gui.deferred def commit(self): self.update_domain_role_hints() self.Warning.multiple_targets.clear() @@ -616,7 +738,7 @@ def reset(self): self.class_attrs[:] = self.data.domain.class_vars self.meta_attrs[:] = self.data.domain.metas self.update_domain_role_hints() - self.commit() + self.commit.now() def send_report(self): if not self.data or not self.output_data: @@ -631,7 +753,7 @@ def send_report(self): diff = list(set(in_domain.variables + in_domain.metas) - set(out_domain.variables + out_domain.metas)) if diff: - text = "%i (%s)" % (len(diff), ", ".join(x.name for x in diff)) + text = f'{len(diff)} ({", ".join(x.name for x in diff)})' self.report_items((("Removed", text),)) diff --git a/Orange/widgets/data/owselectrows.py b/Orange/widgets/data/owselectrows.py index 43ed2f9d094..e673fff033e 100644 --- a/Orange/widgets/data/owselectrows.py +++ b/Orange/widgets/data/owselectrows.py @@ -7,7 +7,7 @@ from AnyQt.QtWidgets import ( QWidget, QTableWidget, QHeaderView, QComboBox, QLineEdit, QToolButton, QMessageBox, QMenu, QListView, QGridLayout, QPushButton, QSizePolicy, - QLabel, QHBoxLayout, QDateTimeEdit) + QLabel, QDateTimeEdit) from AnyQt.QtGui import (QDoubleValidator, QStandardItemModel, QStandardItem, QFontMetrics, QPalette) from AnyQt.QtCore import Qt, QPoint, QPersistentModelIndex, QLocale, \ @@ -24,6 +24,7 @@ from Orange.widgets import widget, gui from Orange.widgets.settings import Setting, ContextSetting, DomainContextHandler from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.utils.localization import pl from Orange.widgets.widget import Input, Output from Orange.widgets.utils import vartype from Orange.widgets import report @@ -74,31 +75,19 @@ def decode_setting(self, setting, value, domain=None, *_args): value = super().decode_setting(setting, value, domain) if setting.name == 'conditions': CONTINUOUS = vartype(ContinuousVariable("x")) - # Use this after 2022/2/2: - # for i, (attr, tpe, op, values) in enumerate(value): - # if tpe is not None: - for i, (attr, *tpe, op, values) in enumerate(value): - if tpe != [None] \ - or not tpe and attr not in OWSelectRows.AllTypes: + for i, (attr, tpe, op, values) in enumerate(value): + if tpe is not None: attr = domain[attr] # check for exact match, pylint: disable=unidiomatic-typecheck if type(attr) is ContinuousVariable \ or OWSelectRows.AllTypes.get(attr) == CONTINUOUS: values = [QLocale().toString(float(i), 'f') for i in values] elif isinstance(attr, DiscreteVariable): - # After 2022/2/2, use just the expression in else clause - if values and isinstance(values[0], int): - # Backwards compatibility. Reset setting if we detect - # that the number of values decreased. Still broken if - # they're reordered or we don't detect the decrease. - # - # indices start with 1, thus >, not >= - if max(values) > len(attr.values): - values = (0, ) - else: - values = tuple(attr.to_val(val) + 1 if val else 0 - for val in values if val in attr.values) \ - or (0, ) + values = tuple( + attr.to_val(val) + 1 if val else 0 + for val in values + if val in attr.values + ) or (0,) value[i] = (attr, op, values) return value @@ -109,10 +98,10 @@ def match(self, context, domain, attrs, metas): conditions = context.values["conditions"] all_vars = attrs.copy() all_vars.update(metas) - matched = [all_vars.get(name) == tpe # also matches "all (...)" strings - # After 2022/2/2 remove this line: - if len(rest) == 2 else name in all_vars - for name, tpe, *rest in conditions] + matched = [ + all_vars.get(name) == tpe # also matches "all (...)" strings + for name, tpe, *rest in conditions + ] if any(matched): return 0.5 * sum(matched) / len(matched) return self.NO_MATCH @@ -125,15 +114,11 @@ def filter_value(self, setting, data, domain, attrs, metas): all_vars = attrs.copy() all_vars.update(metas) conditions = data["conditions"] - # Use this after 2022/2/2: if any(all_vars.get(name) == tpe: - # conditions[:] = [(name, tpe, *rest) for name, tpe, *rest in conditions - # if all_vars.get(name) == tpe] conditions[:] = [ - (name, tpe, *rest) for name, tpe, *rest in conditions - # all_vars.get(name) == tpe also matches "all (...)" which are - # encoded with type `None` - if (all_vars.get(name) == tpe if len(rest) == 2 - else name in all_vars)] + (name, tpe, *rest) + for name, tpe, *rest in conditions + if all_vars.get(name) == tpe + ] class FilterDiscreteType(enum.Enum): @@ -144,21 +129,13 @@ class FilterDiscreteType(enum.Enum): IsDefined = "IsDefined" -def _plural(s): - s = s.replace("is ", "are ") - for word in ("equals", "contains", "begins", "ends"): - s = s.replace(word, word[:-1]) - return s - - class OWSelectRows(widget.OWWidget): name = "Select Rows" - id = "Orange.widgets.data.file" description = "Select rows from the data based on values of variables." icon = "icons/SelectRows.svg" priority = 100 - category = "Data" - keywords = ["filter"] + category = "Transform" + keywords = "select rows, filter" class Inputs: data = Input("Data", Table) @@ -181,15 +158,15 @@ class Outputs: Operators = { ContinuousVariable: [ - (FilterContinuous.Equal, "equals"), - (FilterContinuous.NotEqual, "is not"), - (FilterContinuous.Less, "is below"), - (FilterContinuous.LessEqual, "is at most"), - (FilterContinuous.Greater, "is greater than"), - (FilterContinuous.GreaterEqual, "is at least"), - (FilterContinuous.Between, "is between"), - (FilterContinuous.Outside, "is outside"), - (FilterContinuous.IsDefined, "is defined"), + (FilterContinuous.Equal, "equals", "equal"), + (FilterContinuous.NotEqual, "is not", "are not"), + (FilterContinuous.Less, "is below", "are below"), + (FilterContinuous.LessEqual, "is at most", "are at most"), + (FilterContinuous.Greater, "is greater than", "are greater than"), + (FilterContinuous.GreaterEqual, "is at least", "are at least"), + (FilterContinuous.Between, "is between", "are between"), + (FilterContinuous.Outside, "is outside", "are outside"), + (FilterContinuous.IsDefined, "is defined", "are defined"), ], DiscreteVariable: [ (FilterDiscreteType.Equal, "is"), @@ -198,18 +175,22 @@ class Outputs: (FilterDiscreteType.IsDefined, "is defined") ], StringVariable: [ - (FilterString.Equal, "equals"), - (FilterString.NotEqual, "is not"), - (FilterString.Less, "is before"), - (FilterString.LessEqual, "is equal or before"), - (FilterString.Greater, "is after"), - (FilterString.GreaterEqual, "is equal or after"), - (FilterString.Between, "is between"), - (FilterString.Outside, "is outside"), - (FilterString.Contains, "contains"), - (FilterString.StartsWith, "begins with"), - (FilterString.EndsWith, "ends with"), - (FilterString.IsDefined, "is defined"), + (FilterString.Equal, "equals", "equal"), + (FilterString.NotEqual, "is not", "are not"), + (FilterString.Less, "is before", "are before"), + (FilterString.LessEqual, "is equal or before", "are equal or before"), + (FilterString.Greater, "is after", "are after"), + (FilterString.GreaterEqual, "is equal or after", "are equal or after"), + (FilterString.Between, "is between", "are between"), + (FilterString.Outside, "is outside", "are outside"), + (FilterString.Contains, "contains", "contain"), + (FilterString.NotContain, "does not contain", "do not contain"), + (FilterString.StartsWith, "begins with", "begin with"), + (FilterString.NotStartsWith, "does not begin with", "do not begin with"), + (FilterString.EndsWith, "ends with", "end with"), + (FilterString.NotEndsWith, "does not end with", "do not end with"), + (FilterString.IsDefined, "is defined", "are defined"), + (FilterString.NotIsDefined, "is not defined", "are not defined"), ] } @@ -220,13 +201,13 @@ class Outputs: ("All variables", 0, [(None, "are defined")]), ("All numeric variables", 2, - [(v, _plural(t)) for v, t in Operators[ContinuousVariable]]), + [(v, t) for v, _, t in Operators[ContinuousVariable]]), ("All string variables", 3, - [(v, _plural(t)) for v, t in Operators[StringVariable]])): + [(v, t) for v, _, t in Operators[StringVariable]])): Operators[_all_name] = _all_ops AllTypes[_all_name] = _all_type - operator_names = {vtype: [name for _, name in filters] + operator_names = {vtype: [name for _, name, *_ in filters] for vtype, filters in Operators.items()} class Error(widget.OWWidget.Error): @@ -270,7 +251,8 @@ def __init__(self): box_setting = gui.vBox(self.buttonsArea) self.cb_pa = gui.checkBox( - box_setting, self, "purge_attributes", "Remove unused features", + box_setting, self, "purge_attributes", + "Remove unused values and constant features", callback=self.conditions_changed) self.cb_pc = gui.checkBox( box_setting, self, "purge_classes", "Remove unused classes", @@ -279,7 +261,7 @@ def __init__(self): self.report_button.setFixedWidth(120) gui.rubber(self.buttonsArea.layout()) - acbox = gui.auto_send(self.buttonsArea, self, "auto_commit") + gui.auto_send(self.buttonsArea, self, "auto_commit") self.set_data(None) self.resize(600, 400) @@ -300,8 +282,8 @@ def add_row(self, attr=None, condition_type=None, condition_value=None): index = QPersistentModelIndex(model.index(row, 3)) temp_button = QPushButton('×', self, flat=True, - styleSheet='* {font-size: 16pt; color: silver}' - '*:hover {color: black}') + styleSheet='* {font-size: 16pt; color: palette(button-text) }' + '*:hover {color: palette(bright-text)}') temp_button.clicked.connect(lambda: self.remove_one(index.row())) self.cond_list.setCellWidget(row, 3, temp_button) @@ -402,7 +384,7 @@ def _get_value_contents(box): model = child.popup.list_view.model() for row in range(model.rowCount()): item = model.item(row) - if item.checkState(): + if item.checkState() == Qt.Checked: cont.append(row + 1) names.append(item.text()) child.desc_text = ', '.join(names) @@ -466,7 +448,7 @@ def add_numeric(contents): if box and vtype == box.var_type: lc = self._get_lineedit_contents(box) + lc - if oper_combo.currentText().endswith(" defined"): + if "defined" in oper_combo.currentText(): label = QLabel() label.var_type = vtype self.cond_list.setCellWidget(oper_combo.row, 2, label) @@ -488,7 +470,7 @@ def add_numeric(contents): self.cond_list.setCellWidget(oper_combo.row, 2, combo) combo.currentIndexChanged.connect(self.conditions_changed) else: - box = gui.hBox(self, addToLayout=False) + box = gui.hBox(self.cond_list, addToLayout=False) box.var_type = vtype self.cond_list.setCellWidget(oper_combo.row, 2, box) if vtype == 2: # continuous: @@ -517,7 +499,7 @@ def datetime_changed(): invalidate_datetime() datetime_format = (var.have_date, var.have_time) - column = self.data.get_column_view(var_idx)[0] + column = self.data.get_column(var_idx) w = DateTimeWidget(self, column, datetime_format) w.set_datetime(lc[0]) box.controls = [w] @@ -552,7 +534,7 @@ def set_data(self, data): if not data: self.data_desc = None self.variable_model.set_domain(None) - self.commit() + self.commit.deferred() return self.data_desc = report.describe_data_brief(data) self.variable_model.set_domain(data.domain) @@ -565,7 +547,7 @@ def set_data(self, data): if not self.cond_list.model().rowCount(): self.add_row() - self.unconditional_commit() + self.commit.now() def conditions_changed(self): try: @@ -581,7 +563,7 @@ def conditions_changed(self): if self.update_on_change and ( self.last_output_conditions is None or self.last_output_conditions != self.conditions): - self.commit() + self.commit.deferred() except AttributeError: # Attribute error appears if the signal is triggered when the # controls are being constructed @@ -603,12 +585,13 @@ def _values_to_floats(attr, values): floats, ok = zip(*[parse(v) for v in values]) if not all(ok): raise ValueError('Some values could not be parsed as floats' - 'in the current locale: {}'.format(values)) + f' in the current locale: {values}') except TypeError: floats = values # values already floats assert all(isinstance(v, float) for v in floats) return floats + @gui.deferred def commit(self): matching_output = self.data non_matching_output = None @@ -628,7 +611,7 @@ def commit(self): attr = domain[attr_index] attr_type = vartype(attr) operators = self.Operators[type(attr)] - opertype, _ = operators[oper_idx] + opertype, *_ = operators[oper_idx] if attr_type == 0: filt = data_filter.IsDefined() elif attr_type in (2, 4): # continuous, time @@ -669,7 +652,7 @@ def commit(self): filters.negate = True non_matching_output = filters(self.data) - row_sel = np.in1d(self.data.ids, matching_output.ids) + row_sel = np.isin(self.data.ids, matching_output.ids) annotated_output = create_annotated_table(self.data, row_sel) # if hasattr(self.data, "name"): @@ -775,17 +758,15 @@ def send_report(self): self.report_items( "Output", (("Matching data", - "{} instances".format(match_inst) if match_inst else "None"), + f"{match_inst} {pl(match_inst, 'instance')}" if match_inst else "None"), ("Non-matching data", - nonmatch_inst > 0 and "{} instances".format(nonmatch_inst)))) + nonmatch_inst > 0 and f"{nonmatch_inst} {pl(nonmatch_inst, 'instance')}"))) - # Uncomment this on 2022/2/2 - # - # @classmethod - # def migrate_context(cls, context, version): - # if not version or version < 2: - # # Just remove; can't migrate because variables types are unknown - # context.values["conditions"] = [] + @classmethod + def migrate_context(cls, context, version): + if not version or version < 2: + # Just remove; can't migrate because variables types are unknown + context.values["conditions"] = [] class CheckBoxPopup(QWidget): @@ -893,7 +874,10 @@ def set_datetime(self, date_time): self.setDateTime( QDateTime(self.date(), self.calendarWidget.timeedit.time())) else: - self.setDateTime(date_time) + if isinstance(date_time, QDateTime): + self.setDateTime(date_time) + elif isinstance(date_time, QDate): + self.setDate(date_time) elif self.have_date and not self.have_time: self.setDate(date_time) elif not self.have_date and self.have_time: diff --git a/Orange/widgets/data/owsplit.py b/Orange/widgets/data/owsplit.py new file mode 100644 index 00000000000..12554c20660 --- /dev/null +++ b/Orange/widgets/data/owsplit.py @@ -0,0 +1,239 @@ +from functools import partial + +import numpy as np + +from AnyQt.QtCore import Qt + +from orangewidget.settings import Setting + +from Orange.widgets import gui +from Orange.widgets.settings import ContextSetting, DomainContextHandler +from Orange.widgets.widget import OWWidget, Msg, Output, Input +from Orange.widgets.utils.itemmodels import DomainModel +from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.data import \ + Table, Domain, DiscreteVariable, StringVariable, ContinuousVariable +from Orange.data.util import SharedComputeValue, get_unique_names + + +def get_substrings(values, delimiter): + return sorted({ss.strip() for s in values for ss in s.split(delimiter)} + - {""}) + + +class SplitColumnBase: + def __init__(self, data, attr, delimiter): + self.attr = attr + self.delimiter = delimiter + column = set(data.get_column(self.attr)) + self.new_values = tuple(get_substrings(column, self.delimiter)) + + def __eq__(self, other): + return self.attr == other.attr \ + and self.delimiter == other.delimiter \ + and self.new_values == other.new_values + + def __hash__(self): + return hash((self.attr, self.delimiter, self.new_values)) + + +class SplitColumnOneHot(SplitColumnBase): + InheritEq = True + + def __call__(self, data): + column = data.get_column(self.attr) + values = [{ss.strip() for ss in s.split(self.delimiter)} + for s in column] + return {v: np.array([i for i, xs in enumerate(values) if v in xs], + dtype=int) + for v in self.new_values} + + +class SplitColumnCounts(SplitColumnBase): + InheritEq = True + + def __call__(self, data): + column = data.get_column(self.attr) + values = [[ss.strip() for ss in s.split(self.delimiter)] + for s in column] + return {v: np.array([xs.count(v) for xs in values], dtype=float) + for v in self.new_values} + + +class StringEncodingBase(SharedComputeValue): + def __init__(self, fn, new_feature): + super().__init__(fn) + self.new_feature = new_feature + + def __eq__(self, other): + return super().__eq__(other) and self.new_feature == other.new_feature + + def __hash__(self): + return super().__hash__() ^ hash(self.new_feature) + + def compute(self, data, shared_data): + raise NotImplementedError # silence pylint + +class OneHotStrings(StringEncodingBase): + InheritEq = True + + def compute(self, data, shared_data): + indices = shared_data[self.new_feature] + col = np.zeros(len(data)) + col[indices] = 1 + return col + + +class CountStrings(StringEncodingBase): + InheritEq = True + + def compute(self, data, shared_data): + return shared_data[self.new_feature] + + +class DiscreteEncoding: + def __init__(self, variable, delimiter, onehot, value): + self.variable = variable + self.delimiter = delimiter + self.onehot = onehot + self.value = value + + def __call__(self, data): + column = data.get_column(self.variable).astype(float) + col = np.zeros(len(column)) + col[np.isnan(column)] = np.nan + for val_idx, value in enumerate(self.variable.values): + parts = value.split(self.delimiter) + if self.onehot: + col[column == val_idx] = int(self.value in parts) + else: + col[column == val_idx] = parts.count(self.value) + return col + + def __eq__(self, other): + return self.variable == other.variable \ + and self.value == other.value \ + and self.delimiter == other.delimiter \ + and self.onehot == other.onehot + + def __hash__(self): + return hash((self.variable, self.value, self.delimiter, self.onehot)) + + +class OWSplit(OWWidget): + name = "Split" + description = "Split text or categorical variables into indicator variables" + category = "Transform" + icon = "icons/Split.svg" + keywords = "text, columns, word, encoding, questionnaire, survey, term, counts, indicator" + priority = 700 + + class Inputs: + data = Input("Data", Table) + + class Outputs: + data = Output("Data", Table) + + class Warning(OWWidget.Warning): + no_disc = Msg("Data contains only numeric variables.") + + want_main_area = False + resizing_enabled = False + + Categorical, Numerical, Counts = range(3) + OutputLabels = ("Categorical (No, Yes)", "Numerical (0, 1)", "Counts") + + settingsHandler = DomainContextHandler() + attribute = ContextSetting(None) + delimiter = ContextSetting(";") + output_type = ContextSetting(Categorical) + auto_apply = Setting(True) + + def __init__(self): + super().__init__() + self.data = None + + variable_select_box = gui.vBox(self.controlArea, "Variable") + + gui.comboBox(variable_select_box, self, "attribute", + orientation=Qt.Horizontal, searchable=True, + callback=self.apply.deferred, + model=DomainModel(valid_types=(StringVariable, + DiscreteVariable))) + le = gui.lineEdit( + variable_select_box, self, "delimiter", "Delimiter: ", + orientation=Qt.Horizontal, callback=self.apply.deferred, + controlWidth=20) + le.box.layout().addStretch(1) + le.setAlignment(Qt.AlignCenter) + + gui.radioButtonsInBox( + self.controlArea, self, "output_type", self.OutputLabels, + box="Output Values", + callback=self.apply.deferred) + + gui.auto_apply(self.buttonsArea, self, commit=self.apply) + + @Inputs.data + def set_data(self, data): + self.closeContext() + self.data = data + + model = self.controls.attribute.model() + model.set_domain(data.domain if data is not None else None) + self.Warning.no_disc(shown=data is not None and not model) + if not model: + self.attribute = None + self.data = None + return + self.attribute = model[0] + self.openContext(data) + self.apply.now() + + @gui.deferred + def apply(self): + if self.attribute is None: + self.Outputs.data.send(None) + return + var = self.data.domain[self.attribute] + values, computer = self._get_compute_value(var) + new_columns = self._get_new_columns(values, computer) + new_domain = Domain( + self.data.domain.attributes + new_columns, + self.data.domain.class_vars, self.data.domain.metas + ) + extended_data = self.data.transform(new_domain) + self.Outputs.data.send(extended_data) + + def _get_compute_value(self, var): + if var.is_discrete: + values = get_substrings(var.values, self.delimiter) + computer = partial( + DiscreteEncoding, + var, self.delimiter, self.output_type != self.Counts) + else: + if self.output_type == self.Counts: + sc = SplitColumnCounts(self.data, var, self.delimiter) + computer = partial(CountStrings, sc) + else: + sc = SplitColumnOneHot(self.data, var, self.delimiter) + computer = partial(OneHotStrings, sc) + values = sc.new_values + return values, computer + + def _get_new_columns(self, values, computer): + names = get_unique_names(self.data.domain, values, equal_numbers=False) + if self.output_type == self.Categorical: + return tuple( + DiscreteVariable( + name, ("No", "Yes"), compute_value=computer(value)) + for value, name in zip(values, names)) + else: + return tuple( + ContinuousVariable( + name, compute_value=computer(value)) + for value, name in zip(values, names)) + + +if __name__ == "__main__": # pragma: no cover + WidgetPreview(OWSplit).run(Table.from_file("tests/orange-in-education.tab")) diff --git a/Orange/widgets/data/owsql.py b/Orange/widgets/data/owsql.py index 8487cfc1728..3ffcc2f2658 100644 --- a/Orange/widgets/data/owsql.py +++ b/Orange/widgets/data/owsql.py @@ -1,7 +1,10 @@ -from AnyQt.QtWidgets import QComboBox, QTextEdit, QMessageBox, QApplication +from AnyQt.QtWidgets import QComboBox, QTextEdit, QMessageBox, QApplication, \ + QGridLayout, QLineEdit from AnyQt.QtGui import QCursor from AnyQt.QtCore import Qt +from orangewidget.utils.combobox import ComboBoxSearch + from Orange.data import Table from Orange.data.sql.backend import Backend from Orange.data.sql.backend.base import BackendError @@ -14,6 +17,7 @@ from Orange.widgets.widget import Output, Msg MAX_DL_LIMIT = 1000000 +MAX_TABLES = 1000 def is_postgres(backend): @@ -43,7 +47,7 @@ class OWSql(OWBaseSql): icon = "icons/SQLTable.svg" priority = 30 category = "Data" - keywords = ["load"] + keywords = "sql table, load" class Outputs: data = Output("Data", Table, doc="Attribute-valued dataset read from the input file.") @@ -52,11 +56,12 @@ class Outputs: buttons_area_orientation = None + TABLE, CUSTOM_SQL = range(2) selected_backend = Setting(None) + data_source = Setting(TABLE) table = Setting(None) sql = Setting("") guess_values = Setting(True) - download = Setting(False) materialize = Setting(False) materialize_table_name = Setting("") @@ -64,9 +69,6 @@ class Outputs: class Information(OWBaseSql.Information): data_sampled = Msg("Data description was generated from a sample.") - class Warning(OWBaseSql.Warning): - missing_extension = Msg("Database is missing extensions: {}") - class Error(OWBaseSql.Error): no_backends = Msg("Please install a backend to use this widget.") @@ -76,9 +78,9 @@ def __init__(self): self.backendcombo = None self.tables = None self.tablecombo = None + self.tabletext = None self.sqltext = None self.custom_sql = None - self.downloadcb = None super().__init__() def _setup_gui(self): @@ -106,21 +108,33 @@ def __backend_changed(self): self.selected_backend = backend.display_name if backend else None def _add_tables_controls(self): - vbox = gui.vBox(self.controlArea, "Tables") - box = gui.vBox(vbox) + box = gui.vBox(self.controlArea, 'Data Selection') + form = QGridLayout() + radio_buttons = gui.radioButtons( + box, self, 'data_source', orientation=form, + callback=self.__on_data_source_changed) + radio_table = gui.appendRadioButton( + radio_buttons, 'Table:', addToLayout=False) + radio_custom_sql = gui.appendRadioButton( + radio_buttons, 'Custom SQL:', addToLayout=False) + self.tables = TableModel() - self.tablecombo = QComboBox( + self.tablecombo = ComboBoxSearch( minimumContentsLength=35, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon ) self.tablecombo.setModel(self.tables) self.tablecombo.setToolTip('table') self.tablecombo.activated[int].connect(self.select_table) - box.layout().addWidget(self.tablecombo) + + self.tabletext = QLineEdit(placeholderText='TABLE_NAME') + self.tabletext.setToolTip('table') + self.tabletext.editingFinished.connect(self.select_table) + self.tabletext.setVisible(False) self.custom_sql = gui.vBox(box) - self.custom_sql.setVisible(False) + self.custom_sql.setVisible(self.data_source == self.CUSTOM_SQL) self.sqltext = QTextEdit(self.custom_sql) self.sqltext.setPlainText(self.sql) self.custom_sql.layout().addWidget(self.sqltext) @@ -133,15 +147,18 @@ def _add_tables_controls(self): gui.button(self.custom_sql, self, 'Execute', callback=self.open_table) - box.layout().addWidget(self.custom_sql) + form.addWidget(radio_table, 1, 0, Qt.AlignLeft) + form.addWidget(self.tablecombo, 1, 1) + form.addWidget(self.tabletext, 1, 1) + form.addWidget(radio_custom_sql, 2, 0, Qt.AlignLeft) gui.checkBox(box, self, "guess_values", "Auto-discover categorical variables", callback=self.open_table) - self.downloadcb = gui.checkBox(box, self, "download", - "Download data to local memory", - callback=self.open_table) + def __on_data_source_changed(self): + self.custom_sql.setVisible(self.data_source == self.CUSTOM_SQL) + self.select_table() def highlight_error(self, text=""): err = ['', 'QLineEdit {border: 2px solid red;}'] @@ -155,14 +172,6 @@ def get_backend(self): return self.backends[self.backendcombo.currentIndex()] def on_connection_success(self): - if getattr(self.backend, 'missing_extension', False): - self.Warning.missing_extension( - ", ".join(self.backend.missing_extension)) - self.download = True - self.downloadcb.setEnabled(False) - if not is_postgres(self.backend): - self.download = True - self.downloadcb.setEnabled(False) super().on_connection_success() self.refresh_tables() self.select_table() @@ -173,8 +182,6 @@ def on_connection_error(self, err): def clear(self): super().clear() - self.Warning.missing_extension.clear() - self.downloadcb.setEnabled(True) self.highlight_error() self.tablecombo.clear() self.tablecombo.repaint() @@ -186,37 +193,44 @@ def refresh_tables(self): return self.tables.append("Select a table") - self.tables.append("Custom SQL") - self.tables.extend(self.backend.list_tables(self.schema)) - index = self.tablecombo.findText(str(self.table)) - self.tablecombo.setCurrentIndex(index if index != -1 else 0) + if self.backend.n_tables(self.schema) <= MAX_TABLES: + self.tables.extend(self.backend.list_tables(self.schema)) + index = self.tablecombo.findText(str(self.table)) + self.tablecombo.setCurrentIndex(index if index != -1 else 0) + self.tablecombo.setVisible(True) + self.tabletext.setVisible(False) + else: + self.tablecombo.setVisible(False) + self.tabletext.setVisible(True) self.tablecombo.repaint() # Called on tablecombo selection change: def select_table(self): - curIdx = self.tablecombo.currentIndex() - if self.tablecombo.itemText(curIdx) != "Custom SQL": - self.custom_sql.setVisible(False) + if self.data_source == self.TABLE: return self.open_table() else: - self.custom_sql.setVisible(True) self.data_desc_table = None - self.database_desc["Table"] = "(None)" + if self.database_desc: + self.database_desc["Table"] = "(None)" self.table = None if len(str(self.sql)) > 14: return self.open_table() return None def get_table(self): + if self.backend is None: + return None curIdx = self.tablecombo.currentIndex() - if curIdx <= 0: + if self.data_source == self.TABLE and curIdx <= 0 and \ + self.tabletext.text() == "": if self.database_desc: self.database_desc["Table"] = "(None)" self.data_desc_table = None return None - if self.tablecombo.itemText(curIdx) != "Custom SQL": - self.table = self.tables[self.tablecombo.currentIndex()] + if self.data_source == self.TABLE: + self.table = self.tables[curIdx] if curIdx > 0 else \ + self.tabletext.text() self.database_desc["Table"] = self.table if "Query" in self.database_desc: del self.database_desc["Query"] @@ -260,7 +274,7 @@ def get_table(self): sample = False - if table.approx_len() > LARGE_TABLE and self.guess_values: + if len(table) > LARGE_TABLE and self.guess_values: confirm = QMessageBox(self) confirm.setIcon(QMessageBox.Warning) confirm.setText("Attribute discovery might take " @@ -290,41 +304,45 @@ def get_table(self): QApplication.restoreOverrideCursor() table.domain = domain - if self.download: - if table.approx_len() > AUTO_DL_LIMIT: - if is_postgres(self.backend): - confirm = QMessageBox(self) - confirm.setIcon(QMessageBox.Warning) - confirm.setText("Data appears to be big. Do you really " - "want to download it to local memory?") - - if table.approx_len() <= MAX_DL_LIMIT: - confirm.addButton("Yes", QMessageBox.YesRole) - no_button = confirm.addButton("No", QMessageBox.NoRole) - sample_button = confirm.addButton("Yes, a sample", - QMessageBox.YesRole) - confirm.exec() - if confirm.clickedButton() == no_button: - return None - elif confirm.clickedButton() == sample_button: - table = table.sample_percentage( - AUTO_DL_LIMIT / table.approx_len() * 100) + if len(table) > AUTO_DL_LIMIT: + if is_postgres(self.backend): + confirm = QMessageBox(self) + confirm.setIcon(QMessageBox.Warning) + confirm.setText("Data appears to be big. Do you really " + "want to download it to local memory?\n" + "Table length: {:,}. Limit {:,}".format( + len(table), MAX_DL_LIMIT)) + + if len(table) <= MAX_DL_LIMIT: + confirm.addButton("Yes", QMessageBox.YesRole) + no_button = confirm.addButton("No", QMessageBox.NoRole) + sample_button = confirm.addButton("Yes, a sample", + QMessageBox.YesRole) + confirm.exec() + if confirm.clickedButton() == no_button: + return None + elif confirm.clickedButton() == sample_button: + table = table.sample_percentage( + AUTO_DL_LIMIT / len(table) * 100) + else: + if len(table) > MAX_DL_LIMIT: + QMessageBox.warning( + self, 'Warning', + "Data is too big to download.\n" + "Table length: {:,}. Limit {:,}".format(len(table), MAX_DL_LIMIT) + ) + return None else: - if table.approx_len() > MAX_DL_LIMIT: - QMessageBox.warning( - self, 'Warning', "Data is too big to download.\n") + confirm = QMessageBox.question( + self, 'Question', + "Data appears to be big. Do you really " + "want to download it to local memory?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if confirm == QMessageBox.No: return None - else: - confirm = QMessageBox.question( - self, 'Question', - "Data appears to be big. Do you really " - "want to download it to local memory?", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) - if confirm == QMessageBox.No: - return None - - table.download_data(MAX_DL_LIMIT) - table = Table(table) + + table.download_data(MAX_DL_LIMIT) + table = Table(table) return table diff --git a/Orange/widgets/data/owtable.py b/Orange/widgets/data/owtable.py index c8af2dacf4e..9367210b366 100644 --- a/Orange/widgets/data/owtable.py +++ b/Orange/widgets/data/owtable.py @@ -1,229 +1,236 @@ -import sys -import threading -import itertools import concurrent.futures +from dataclasses import dataclass +from typing import ( + Optional, Union, Sequence, List, TypedDict, Tuple, Any, Container +) -from collections import OrderedDict, namedtuple - -from math import isnan - -import numpy from scipy.sparse import issparse from AnyQt.QtWidgets import ( - QTableView, QHeaderView, QAbstractButton, QApplication, QStyleOptionHeader, - QStyle, QStylePainter + QTableView, QHeaderView, QApplication, QStyle, QStyleOptionHeader, + QStyleOptionViewItem ) -from AnyQt.QtGui import QColor, QClipboard +from AnyQt.QtGui import QColor, QClipboard, QPainter from AnyQt.QtCore import ( - Qt, QSize, QEvent, QObject, QMetaObject, - QAbstractProxyModel, QIdentityProxyModel, QModelIndex, - QItemSelectionModel, QItemSelection, QItemSelectionRange, + Qt, QSize, QMetaObject, QItemSelectionModel, QModelIndex, QRect ) -from AnyQt.QtCore import pyqtSlot as Slot +from AnyQt.QtCore import Slot + +from orangewidget.gui import OrangeUserRole import Orange.data -from Orange.data.storage import Storage from Orange.data.table import Table from Orange.data.sql.table import SqlTable -from Orange.statistics import basic_stats from Orange.widgets import gui +from Orange.widgets.data.utils.models import RichTableModel, TableSliceProxy from Orange.widgets.settings import Setting from Orange.widgets.utils.itemdelegates import TableDataDelegate -from Orange.widgets.utils.itemselectionmodel import ( - BlockSelectionModel, ranges, selection_blocks -) -from Orange.widgets.utils.tableview import TableView, \ - table_selection_to_mime_data +from Orange.widgets.utils.tableview import table_selection_to_mime_data from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import OWWidget, Input, Output -from Orange.widgets.utils import datacaching +from Orange.widgets.widget import OWWidget, Input, Output, Msg from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.utils.itemmodels import TableModel from Orange.widgets.utils.state_summary import format_summary_details +from Orange.widgets.utils import disconnected +from Orange.widgets.utils.headerview import HeaderView +from Orange.widgets.data.utils.tableview import RichTableView +from Orange.widgets.data.utils import tablesummary as tsummary + + +SubsetRole = next(OrangeUserRole) + + +class HeaderViewWithSubsetIndicator(HeaderView): + _IndicatorChar = "\N{BULLET}" + + def paintSection( + self, painter: QPainter, rect: QRect, logicalIndex: int + ) -> None: + opt = QStyleOptionHeader() + self.initStyleOption(opt) + self.initStyleOptionForIndex(opt, logicalIndex) + model = self.model() + if model is None: + return # pragma: no cover + opt.rect = rect + issubset = model.headerData(logicalIndex, Qt.Vertical, SubsetRole) + style = self.style() + # draw background + style.drawControl(QStyle.CE_HeaderSection, opt, painter, self) + indicator_rect = QRect(rect) + text_rect = QRect(rect) + indicator_width = opt.fontMetrics.horizontalAdvance( + self._IndicatorChar + " " + ) + indicator_rect.setWidth(indicator_width) + text_rect.setLeft(indicator_width) + if issubset: + optindicator = QStyleOptionHeader(opt) + optindicator.rect = indicator_rect + optindicator.textAlignment = Qt.AlignCenter + optindicator.text = self._IndicatorChar + # draw subset indicator + style.drawControl(QStyle.CE_HeaderLabel, optindicator, painter, self) + opt.rect = text_rect + # draw section label + style.drawControl(QStyle.CE_HeaderLabel, opt, painter, self) + + def sectionSizeFromContents(self, logicalIndex: int) -> QSize: + opt = QStyleOptionHeader() + self.initStyleOption(opt) + super().initStyleOptionForIndex(opt, logicalIndex) + opt.text = self._IndicatorChar + " " + opt.text + return self.style().sizeFromContents(QStyle.CT_HeaderSection, opt, QSize(), self) + + +class DataTableView(gui.HScrollStepMixin, RichTableView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + vheader = HeaderViewWithSubsetIndicator( + Qt.Vertical, self, highlightSections=True + ) + vheader.setSectionsClickable(True) + self.setVerticalHeader(vheader) -class RichTableModel(TableModel): - """A TableModel with some extra bells and whistles/ - - (adds support for gui.BarRole, include variable labels and icons - in the header) - """ - #: Rich header data flags. - Name, Labels, Icon = 1, 2, 4 - - def __init__(self, sourcedata, parent=None): - super().__init__(sourcedata, parent) - - self._header_flags = RichTableModel.Name - self._continuous = [var.is_continuous for var in self.vars] - labels = [] - for var in self.vars: - if isinstance(var, Orange.data.Variable): - labels.extend(var.attributes.keys()) - self._labels = list(sorted( - {label for label in labels if not label.startswith("_")})) - - def data(self, index, role=Qt.DisplayRole, - # for faster local lookup - _BarRole=gui.TableBarItem.BarRole): - # pylint: disable=arguments-differ - if role == _BarRole and self._continuous[index.column()]: - val = super().data(index, TableModel.ValueRole) - if val is None or isnan(val): - return None - - dist = super().data(index, TableModel.VariableStatsRole) - if dist is not None and dist.max > dist.min: - return (val - dist.min) / (dist.max - dist.min) - else: - return None - elif role == Qt.TextAlignmentRole and self._continuous[index.column()]: - return Qt.AlignRight | Qt.AlignVCenter - else: - return super().data(index, role) +class _TableDataDelegate(TableDataDelegate): + DefaultRoles = TableDataDelegate.DefaultRoles + (SubsetRole,) - def headerData(self, section, orientation, role): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - var = super().headerData( - section, orientation, TableModel.VariableRole) - if var is None: - return super().headerData( - section, orientation, Qt.DisplayRole) - - lines = [] - if self._header_flags & RichTableModel.Name: - lines.append(var.name) - if self._header_flags & RichTableModel.Labels: - lines.extend(str(var.attributes.get(label, "")) - for label in self._labels) - return "\n".join(lines) - elif orientation == Qt.Horizontal and role == Qt.DecorationRole and \ - self._header_flags & RichTableModel.Icon: - var = super().headerData( - section, orientation, TableModel.VariableRole) - if var is not None: - return gui.attributeIconDict[var] - else: - return None - else: - return super().headerData(section, orientation, role) - def setRichHeaderFlags(self, flags): - if flags != self._header_flags: - self._header_flags = flags - self.headerDataChanged.emit( - Qt.Horizontal, 0, self.columnCount() - 1) +class SubsetTableDataDelegate(_TableDataDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.subset_opacity = 0.5 - def richHeaderFlags(self): - return self._header_flags + def paint( + self, painter: QPainter, option: QStyleOptionViewItem, + index: QModelIndex + ) -> None: + issubset = self.cachedData(index, SubsetRole) + opacity = painter.opacity() + if not issubset: + painter.setOpacity(self.subset_opacity) + super().paint(painter, option, index) + if not issubset: + painter.setOpacity(opacity) -class TableSliceProxy(QIdentityProxyModel): - def __init__(self, parent=None, rowSlice=slice(0, -1), **kwargs): - super().__init__(parent, **kwargs) - self.__rowslice = rowSlice +class TableBarItemDelegate(SubsetTableDataDelegate, gui.TableBarItem, + _TableDataDelegate): + pass + - def setRowSlice(self, rowslice): - if rowslice.step is not None and rowslice.step != 1: - raise ValueError("invalid stride") +class _TableModel(RichTableModel): + SubsetRole = SubsetRole - if self.__rowslice != rowslice: - self.beginResetModel() - self.__rowslice = rowslice - self.endResetModel() + def __init__(self, *args, subsets=None, **kwargs): + super().__init__(*args, **kwargs) + self._subset = subsets or set() - def mapToSource(self, proxyindex): - model = self.sourceModel() - if model is None or not proxyindex.isValid(): - return QModelIndex() + def setSubsetRowIds(self, subsetids: Container[int]): + self._subset = subsetids + if self.rowCount(): + self.headerDataChanged.emit(Qt.Vertical, 0, self.rowCount() - 1) + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount() - 1, self.columnCount() - 1), + [SubsetRole], + ) - row, col = proxyindex.row(), proxyindex.column() - row = row + self.__rowslice.start - assert 0 <= row < model.rowCount() - return model.createIndex(row, col, proxyindex.internalPointer()) + def _is_subset(self, row): + row = self.mapToSourceRows(row) + try: + id_ = self.source.ids[row] + except (IndexError, AttributeError): # pragma: no cover + return False + return int(id_) in self._subset - def mapFromSource(self, sourceindex): - model = self.sourceModel() - if model is None or not sourceindex.isValid(): - return QModelIndex() - row, col = sourceindex.row(), sourceindex.column() - row = row - self.__rowslice.start - assert 0 <= row < self.rowCount() - return self.createIndex(row, col, sourceindex.internalPointer()) + def data(self, index: QModelIndex, role=Qt.DisplayRole) -> Any: + if role == _TableModel.SubsetRole: + return self._is_subset(index.row()) + return super().data(index, role) - def rowCount(self, parent=QModelIndex()): - if parent.isValid(): - return 0 - count = super().rowCount() - start, stop, step = self.__rowslice.indices(count) - assert step == 1 - return stop - start + def headerData(self, section, orientation, role): + if orientation == Qt.Vertical and role == _TableModel.SubsetRole: + return self._is_subset(section) + return super().headerData(section, orientation, role) -TableSlot = namedtuple("TableSlot", ["input_id", "table", "summary", "view"]) +@dataclass +class InputData: + table: Table + summary: tsummary.Summary + model: TableModel -class DataTableView(gui.HScrollStepMixin, TableView): - dataset: Table - input_slot: TableSlot +class _Selection(TypedDict): + rows: Tuple[int] + columns: Tuple[int] -class TableBarItemDelegate(gui.TableBarItem, TableDataDelegate): - pass +_Sorting = List[Tuple[str, int]] -class OWDataTable(OWWidget): +class OWTable(OWWidget): name = "Data Table" description = "View the dataset in a spreadsheet." icon = "icons/Table.svg" priority = 50 - keywords = [] + keywords = "data table, view" class Inputs: - data = Input("Data", Table, multiple=True, auto_summary=False) + data = Input("Data", Table, default=True) + data_subset = Input("Data Subset", Table) class Outputs: selected_data = Output("Selected Data", Table, default=True) annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table) + class Warning(OWWidget.Warning): + missing_sort_columns = Msg( + "Cannot restore sorting.\n" + "Missing columns in input table: {}" + ) + non_sortable_input = Msg( + "Cannot restore sorting.\n" + "Input table cannot be sorted due to implementation constraints." + ) buttons_area_orientation = Qt.Vertical show_distributions = Setting(False) - dist_color_RGB = Setting((220, 220, 220, 255)) show_attribute_labels = Setting(True) select_rows = Setting(True) auto_commit = Setting(True) color_by_class = Setting(True) - selected_rows = Setting([], schema_only=True) - selected_cols = Setting([], schema_only=True) - - settings_version = 2 + stored_selection: _Selection = Setting( + {"rows": [], "columns": []}, schema_only=True + ) + stored_sort: _Sorting = Setting( + [], schema_only=True + ) + settings_version = 1 def __init__(self): super().__init__() - - self._inputs = OrderedDict() - - self.__pending_selected_rows = self.selected_rows - self.selected_rows = None - self.__pending_selected_cols = self.selected_cols - self.selected_cols = None - - self.dist_color = QColor(*self.dist_color_RGB) + self.input: Optional[InputData] = None + self._subset_ids: Optional[set] = None + self.__pending_selection: Optional[_Selection] = self.stored_selection + self.__pending_sort: Optional[_Sorting] = self.stored_sort + self.__have_new_data = False + self.__have_new_subset = False + self.dist_color = QColor(220, 220, 220, 255) info_box = gui.vBox(self.controlArea, "Info") self.info_text = gui.widgetLabel(info_box) - self._set_input_summary(None) box = gui.vBox(self.controlArea, "Variables") self.c_show_attribute_labels = gui.checkBox( box, self, "show_attribute_labels", "Show variable labels (if present)", - callback=self._on_show_variable_labels_changed) + callback=self._update_variable_labels) gui.checkBox(box, self, "show_distributions", 'Visualize numeric values', @@ -232,148 +239,122 @@ def __init__(self): callback=self._on_distribution_color_changed) box = gui.vBox(self.controlArea, "Selection") - + self.clear_button = gui.button( + box, self, "Clear Selection", callback=self.clear_selection, + autoDefault=False, enabled=False) gui.checkBox(box, self, "select_rows", "Select full rows", callback=self._on_select_rows_changed) gui.rubber(self.controlArea) - gui.button(self.buttonsArea, self, "Restore Original Order", - callback=self.restore_order, - tooltip="Show rows in the original order", - autoDefault=False, - attribute=Qt.WA_LayoutUsesWidgetRect) + self.restore_button = gui.button( + self.buttonsArea, self, "Restore Original Order", + callback=self.restore_order, + tooltip="Show rows in the original order", + autoDefault=False, enabled=False, + attribute=Qt.WA_LayoutUsesWidgetRect) gui.auto_send(self.buttonsArea, self, "auto_commit") - # GUI with tabs - self.tabs = gui.tabWidget(self.mainArea) - self.tabs.currentChanged.connect(self._on_current_tab_changed) + view = DataTableView(sortingEnabled=True) + view.setItemDelegate(SubsetTableDataDelegate(view)) + view.selectionFinished.connect(self.update_selection) + + if self.select_rows: + view.setSelectionBehavior(QTableView.SelectRows) + + header = view.horizontalHeader() + header.setSectionsMovable(True) + header.setSectionsClickable(True) + header.setSortIndicatorShown(True) + header.setSortIndicator(-1, Qt.AscendingOrder) + header.sortIndicatorChanged.connect( + self._on_sort_indicator_changed, Qt.UniqueConnection + ) + + self.view = view + self.mainArea.layout().addWidget(self.view) + self._update_input_summary() def copy_to_clipboard(self): self.copy() - @staticmethod - def sizeHint(): + def sizeHint(self): return QSize(800, 500) @Inputs.data - def set_dataset(self, data, tid=None): + def set_dataset(self, data: Optional[Table]): """Set the input dataset.""" - if data is not None: - datasetname = getattr(data, "name", "Data") - if tid in self._inputs: - # update existing input slot - slot = self._inputs[tid] - view = slot.view - # reset the (header) view state. - view.setModel(None) - view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) - assert self.tabs.indexOf(view) != -1 - self.tabs.setTabText(self.tabs.indexOf(view), datasetname) - else: - view = DataTableView() - view.setSortingEnabled(True) - view.setItemDelegate(TableDataDelegate(view)) - - if self.select_rows: - view.setSelectionBehavior(QTableView.SelectRows) - - header = view.horizontalHeader() - header.setSectionsMovable(True) - header.setSectionsClickable(True) - header.setSortIndicatorShown(True) - header.setSortIndicator(-1, Qt.AscendingOrder) - - # QHeaderView does not 'reset' the model sort column, - # because there is no guaranty (requirement) that the - # models understand the -1 sort column. - def sort_reset(index, order): - if view.model() is not None and index == -1: - view.model().sort(index, order) - - header.sortIndicatorChanged.connect(sort_reset) - self.tabs.addTab(view, datasetname) - - view.dataset = data - self.tabs.setCurrentWidget(view) - - self._setup_table_view(view, data) - slot = TableSlot(tid, data, table_summary(data), view) - view.input_slot = slot - self._inputs[tid] = slot - - self.tabs.setCurrentIndex(self.tabs.indexOf(view)) - - self._set_input_summary(slot) - - if isinstance(slot.summary.len, concurrent.futures.Future): + if data: + summary = tsummary.table_summary(data) + self.input = InputData( + table=data, + summary=summary, + model=_TableModel(data) + ) + if isinstance(summary.len, concurrent.futures.Future): def update(_): QMetaObject.invokeMethod( self, "_update_info", Qt.QueuedConnection) - - slot.summary.len.add_done_callback(update) - - elif tid in self._inputs: - slot = self._inputs.pop(tid) - view = slot.view - view.hide() - view.deleteLater() - self.tabs.removeTab(self.tabs.indexOf(view)) - - current = self.tabs.currentWidget() - if current is not None: - self._set_input_summary(current.input_slot) + summary.len.add_done_callback(update) else: - self._set_input_summary(None) - - self.tabs.tabBar().setVisible(self.tabs.count() > 1) - - if data and self.__pending_selected_rows is not None: - self.selected_rows = self.__pending_selected_rows - self.__pending_selected_rows = None - else: - self.selected_rows = [] - - if data and self.__pending_selected_cols is not None: - self.selected_cols = self.__pending_selected_cols - self.__pending_selected_cols = None + self.input = None + self.__have_new_data = True + + @Inputs.data_subset + def set_subset_dataset(self, subset: Optional[Table]): + """Set the data subset""" + if subset is not None and not isinstance(subset, SqlTable): + ids = set(subset.ids) else: - self.selected_cols = [] - - self.set_selection() - self.unconditional_commit() - - def _setup_table_view(self, view, data): - """Setup the `view` (QTableView) with `data` (Orange.data.Table) - """ - if data is None: - view.setModel(None) + ids = None + self._subset_ids = ids + self.__have_new_subset = True + + def handleNewSignals(self): + self.restore_button.setEnabled(False) + self.clear_button.setEnabled(False) + super().handleNewSignals() + self.Warning.non_sortable_input.clear() + self.Warning.missing_sort_columns.clear() + data: Optional[Table] = self.input.table if self.input else None + model = self.input.model if self.input else None + + if self.__have_new_data: + self._setup_table_view() + self._update_input_summary() + + if data is not None and self.__pending_sort is not None: + self.__restore_sort() + + if data is not None and self.__pending_selection is not None: + selection = self.__pending_selection + self.__pending_selection = None + rows = selection["rows"] + columns = selection["columns"] + self.set_selection(rows, columns) + + if self.__have_new_subset and model is not None: + model.setSubsetRowIds(self._subset_ids or set()) + self.__have_new_subset = False + + self._setup_view_delegate() + + if self.__have_new_data: + self.commit.now() + self.__have_new_data = False + + def _setup_table_view(self): + """Setup the view with current input data.""" + if self.input is None: + self.view.setModel(None) return - datamodel = RichTableModel(data) - - rowcount = data.approx_len() - - if self.color_by_class and data.domain.has_discrete_class: - color_schema = [ - QColor(*c) for c in data.domain.class_var.colors] - else: - color_schema = None - if self.show_distributions: - view.setItemDelegate( - TableBarItemDelegate( - view, color=self.dist_color, color_schema=color_schema) - ) - else: - view.setItemDelegate(TableDataDelegate(view)) - - # Enable/disable view sorting based on data's type - view.setSortingEnabled(is_sortable(data)) - header = view.horizontalHeader() - header.setSectionsClickable(is_sortable(data)) - header.setSortIndicatorShown(is_sortable(data)) - header.sortIndicatorChanged.connect(self.update_selection) + datamodel = self.input.model + datamodel.setSubsetRowIds(self._subset_ids or set()) + view = self.view + data = self.input.table + rowcount = len(data) view.setModel(datamodel) vheader = view.verticalHeader() @@ -401,312 +382,212 @@ def _setup_table_view(self, view, data): assert view.model().rowCount() <= maxrows assert vheader.sectionSize(0) > 1 or datamodel.rowCount() == 0 + self._setup_view_delegate() # update the header (attribute names) - self._update_variable_labels(view) - - selmodel = BlockSelectionModel( - view.model(), parent=view, selectBlocks=not self.select_rows) - view.setSelectionModel(selmodel) - view.selectionFinished.connect(self.update_selection) + self._update_variable_labels() - #noinspection PyBroadException - def set_corner_text(self, table, text): - """Set table corner text.""" - # As this is an ugly hack, do everything in - # try - except blocks, as it may stop working in newer Qt. - # pylint: disable=broad-except - if not hasattr(table, "btn") and not hasattr(table, "btnfailed"): - try: - btn = table.findChild(QAbstractButton) - - class Efc(QObject): - @staticmethod - def eventFilter(o, e): - if (isinstance(o, QAbstractButton) and - e.type() == QEvent.Paint): - # paint by hand (borrowed from QTableCornerButton) - btn = o - opt = QStyleOptionHeader() - opt.initFrom(btn) - state = QStyle.State_None - if btn.isEnabled(): - state |= QStyle.State_Enabled - if btn.isActiveWindow(): - state |= QStyle.State_Active - if btn.isDown(): - state |= QStyle.State_Sunken - opt.state = state - opt.rect = btn.rect() - opt.text = btn.text() - opt.position = QStyleOptionHeader.OnlyOneSection - painter = QStylePainter(btn) - painter.drawControl(QStyle.CE_Header, opt) - return True # eat event - return False - table.efc = Efc() - # disconnect default handler for clicks and connect a new one, which supports - # both selection and deselection of all data - btn.clicked.disconnect() - btn.installEventFilter(table.efc) - btn.clicked.connect(self._on_select_all) - table.btn = btn - - if sys.platform == "darwin": - btn.setAttribute(Qt.WA_MacSmallSize) - - except Exception: - table.btnfailed = True - - if hasattr(table, "btn"): - try: - btn = table.btn - btn.setText(text) - opt = QStyleOptionHeader() - opt.text = btn.text() - s = btn.style().sizeFromContents( - QStyle.CT_HeaderSection, - opt, QSize(), - btn) - if s.isValid(): - table.verticalHeader().setMinimumWidth(s.width()) - except Exception: - pass - - def _set_input_summary(self, slot): + def _update_input_summary(self): def format_summary(summary): - if isinstance(summary, ApproxSummary): - length = summary.len.result() if summary.len.done() else \ - summary.approx_len - elif isinstance(summary, Summary): - length = summary.len - return length + return summary.len summary, details = self.info.NoInput, "" - if slot: - summary = format_summary(slot.summary) - details = format_summary_details(slot.table) + if self.input: + summary = format_summary(self.input.summary) + details = format_summary_details(self.input.table) self.info.set_input_summary(summary, details) - self.info_text.setText("\n".join(self._info_box_text(slot))) - - @staticmethod - def _info_box_text(slot): - def format_part(part): - if isinstance(part, DenseArray): - if not part.nans: - return "" - perc = 100 * part.nans / (part.nans + part.non_nans) - return f" ({perc:.1f} % missing data)" - - if isinstance(part, SparseArray): - tag = "sparse" - elif isinstance(part, SparseBoolArray): - tag = "tags" - else: # isinstance(part, NotAvailable) - return "" - dens = 100 * part.non_nans / (part.nans + part.non_nans) - return f" ({tag}, density {dens:.2f} %)" - - def desc(n, part): - if n == 0: - return f"No {part}s" - elif n == 1: - return f"1 {part}" - else: - return f"{n} {part}s" - - if slot is None: - return ["No data."] - summary = slot.summary - text = [] - if isinstance(summary, ApproxSummary): - if summary.len.done(): - text.append(f"{summary.len.result()} instances") - else: - text.append(f"~{summary.approx_len} instances") - elif isinstance(summary, Summary): - text.append(f"{summary.len} instances") - if sum(p.nans for p in [summary.X, summary.Y, summary.M]) == 0: - text[-1] += " (no missing data)" - - text.append(desc(len(summary.domain.attributes), "feature") - + format_part(summary.X)) - - if not summary.domain.class_vars: - text.append("No target variable.") + if self.input is None: + summary = ["No data."] else: - if len(summary.domain.class_vars) > 1: - c_text = desc(len(summary.domain.class_vars), "outcome") - elif summary.domain.has_continuous_class: - c_text = "Numeric outcome" - else: - c_text = "Target with " \ - + desc(len(summary.domain.class_var.values), "value") - text.append(c_text + format_part(summary.Y)) - - text.append(desc(len(summary.domain.metas), "meta attribute") - + format_part(summary.M)) - return text - - def _on_select_all(self, _): - data_info = self.tabs.currentWidget().input_slot.summary - if len(self.selected_rows) == data_info.len \ - and len(self.selected_cols) == len(data_info.domain.variables): - self.tabs.currentWidget().clearSelection() - else: - self.tabs.currentWidget().selectAll() - - def _on_current_tab_changed(self, index): - """Update the status bar on current tab change""" - view = self.tabs.widget(index) - if view is not None and view.model() is not None: - self._set_input_summary(view.input_slot) - self.update_selection() - else: - self._set_input_summary(None) - - def _update_variable_labels(self, view): - "Update the variable labels visibility for `view`" - model = view.model() - if isinstance(model, TableSliceProxy): - model = model.sourceModel() + summary = tsummary.format_summary(self.input.summary) + self.info_text.setText("\n".join(summary)) + def _update_variable_labels(self): + """Update the variable labels visibility for current view.""" + if self.input is None: + return + model = self.input.model if self.show_attribute_labels: model.setRichHeaderFlags( - RichTableModel.Labels | RichTableModel.Name) - - labelnames = set() - domain = model.source.domain - for a in itertools.chain(domain.metas, domain.variables): - labelnames.update(a.attributes.keys()) - labelnames = sorted( - [label for label in labelnames if not label.startswith("_")]) - self.set_corner_text(view, "\n".join([""] + labelnames)) + RichTableModel.Labels | RichTableModel.Name + ) else: model.setRichHeaderFlags(RichTableModel.Name) - self.set_corner_text(view, "") - - def _on_show_variable_labels_changed(self): - """The variable labels (var.attribues) visibility was changed.""" - for slot in self._inputs.values(): - self._update_variable_labels(slot.view) def _on_distribution_color_changed(self): - for ti in range(self.tabs.count()): - widget = self.tabs.widget(ti) - model = widget.model() - while isinstance(model, QAbstractProxyModel): - model = model.sourceModel() - data = model.source - class_var = data.domain.class_var - if self.color_by_class and class_var and class_var.is_discrete: - color_schema = [QColor(*c) for c in class_var.colors] - else: - color_schema = None - if self.show_distributions: - delegate = TableBarItemDelegate(widget, color=self.dist_color, - color_schema=color_schema) - else: - delegate = TableDataDelegate(widget) - widget.setItemDelegate(delegate) - tab = self.tabs.currentWidget() - if tab: - tab.reset() + if self.input is None: + return # pragma: no cover + self._setup_view_delegate() + + def _setup_view_delegate(self): + if self.input is None: + return + model = self.input.model + data = model.source + class_var = data.domain.class_var + if self.color_by_class and class_var and class_var.is_discrete: + color_schema = [QColor(*c) for c in class_var.colors] + else: + color_schema = None + if self.show_distributions: + delegate = TableBarItemDelegate( + self.view, color=self.dist_color, color_schema=color_schema + ) + else: + delegate = SubsetTableDataDelegate(self.view) + delegate.subset_opacity = 0.5 if self._subset_ids is not None else 1.0 + self.view.setItemDelegate(delegate) def _on_select_rows_changed(self): - for slot in self._inputs.values(): - selection_model = slot.view.selectionModel() - selection_model.setSelectBlocks(not self.select_rows) - if self.select_rows: - slot.view.setSelectionBehavior(QTableView.SelectRows) - # Expand the current selection to full row selection. - selection_model.select( - selection_model.selection(), - QItemSelectionModel.Select | QItemSelectionModel.Rows - ) - else: - slot.view.setSelectionBehavior(QTableView.SelectItems) + if self.input is None: + return + selection_model = self.view.selectionModel() + selection_model.setSelectBlocks(not self.select_rows) + if self.select_rows: + self.view.setSelectionBehavior(QTableView.SelectRows) + # Expand the current selection to full row selection. + selection_model.select( + selection_model.selection(), + QItemSelectionModel.Select | QItemSelectionModel.Rows + ) + else: + self.view.setSelectionBehavior(QTableView.SelectItems) def restore_order(self): """Restore the original data order of the current view.""" - table = self.tabs.currentWidget() - if table is not None: - table.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) + self.view.sortByColumn(-1, Qt.AscendingOrder) + self.stored_sort = [] + self.Warning.missing_sort_columns.clear() @Slot() def _update_info(self): - current = self.tabs.currentWidget() - if current is not None and current.model() is not None: - self._set_input_summary(current.input_slot) + self._update_input_summary() + + def _on_sort_indicator_changed(self, index: int, order: Qt.SortOrder) -> None: + self.restore_button.setEnabled(index != -1) + if index == -1: + self.stored_sort = [] + elif self.input is not None: + model = self.input.model + coldesc = model.columns[index] + colid = self.__encode_column_id(coldesc) + order = -1 if order == Qt.DescendingOrder else 1 + # Drop any previously applied sort on this column + self.stored_sort = [(n, d) for n, d in self.stored_sort + if n != colid] + self.stored_sort.append((colid, order)) + self.update_selection() + self.Warning.missing_sort_columns.clear() + + def set_sort_columns(self, sorting: List[Tuple[str, int]]): + """ + Set the model sorting parameters. + + Parameters + ---------- + sorting: List[Tuple[str, int]] + For each (name: str, inc: int) tuple where `name` is the column + name and `inc` is 1 for increasing order and -1 for decreasing + order, the model is sorted by that column. + """ + if self.input is None: + return # pragma: no cover + self.stored_sort = [] + # Map header ids (names) to column indices + columns = {id_: i for i, id_ in enumerate(self.__header_ids())} + # Suppress the _on_sort_indicator_changed -> commit calls + with disconnected(self.view.horizontalHeader().sortIndicatorChanged, + self._on_sort_indicator_changed, Qt.UniqueConnection): + for colid, order in sorting: + if colid in columns: + self.view.sortByColumn( + columns[colid], + Qt.AscendingOrder if order == 1 else Qt.DescendingOrder + ) + self.stored_sort.append((colid, order)) + + def __restore_sort(self) -> None: + assert self.input is not None + sort = self.__pending_sort + self.__pending_sort = None + if not sort: + return # pragma: no cover + if not self.view.isSortingEnabled() and sort: + self.Warning.non_sortable_input() + self.Warning.missing_sort_columns.clear() + return + # Map header ids (names) to column indices + columns = {id_: i for i, id_ in enumerate(self.__header_ids())} + missing_columns = [] + sort_ = [] + for colid, order in sort: + if colid in columns: + sort_.append((colid, order)) + else: + missing_columns.append(self.__decode_column_id(colid)) + self.set_sort_columns(sort_) + self.restore_button.setEnabled(True) + if missing_columns: + self.Warning.missing_sort_columns(", ".join(missing_columns)) + + @staticmethod + def __encode_column_id( + coldesc: Union[TableModel.Column, TableModel.Basket] + ) -> str: + def escape(s: str) -> str: # escape possible leading slash + if s.startswith("\\"): + return "\\" + s + return s + if isinstance(coldesc, TableModel.Column): + return escape(coldesc.var.name) + else: + lookup = ("TARGET", "META", "FEATURES",) + return f"\\BASKET({lookup[coldesc.role]})" + + @staticmethod + def __decode_column_id(cid: str) -> str: + if cid.startswith("\\"): + return cid[1:] + return cid + + def __header_ids(self) -> List[str]: + if self.input is None: + return [] + return [self.__encode_column_id(c) for c in self.input.model.columns] def update_selection(self, *_): - self.commit() - - def set_selection(self): - if self.selected_rows and self.selected_cols: - view = self.tabs.currentWidget() - model = view.model() - if model.rowCount() <= self.selected_rows[-1] or \ - model.columnCount() <= self.selected_cols[-1]: - return + # Calling get_selection is expensive, so we consult selectionModel directly + sel_model = self.view.selectionModel() + selection = sel_model.selection() + self.clear_button.setEnabled(not selection.isEmpty()) + self.commit.deferred() - selection = QItemSelection() - rowranges = list(ranges(self.selected_rows)) - colranges = list(ranges(self.selected_cols)) - - for rowstart, rowend in rowranges: - for colstart, colend in colranges: - selection.append( - QItemSelectionRange( - view.model().index(rowstart, colstart), - view.model().index(rowend - 1, colend - 1) - ) - ) - view.selectionModel().select( - selection, QItemSelectionModel.ClearAndSelect) + def set_selection(self, rows: Sequence[int], columns: Sequence[int]) -> None: + """ + Set the selected `rows` and `columns`. - @staticmethod - def get_selection(view): + `rows` are indices into underlying :class:`Table` + """ + self.view.setBlockSelection(rows, columns) + + def get_selection(self): """ Return the selected row and column indices of the selection in view. """ - selmodel = view.selectionModel() - - selection = selmodel.selection() - model = view.model() - # map through the proxies into input table. - while isinstance(model, QAbstractProxyModel): - selection = model.mapSelectionToSource(selection) - model = model.sourceModel() - - assert isinstance(selmodel, BlockSelectionModel) - assert isinstance(model, TableModel) - - row_spans, col_spans = selection_blocks(selection) - rows = list(itertools.chain.from_iterable(itertools.starmap(range, row_spans))) - cols = list(itertools.chain.from_iterable(itertools.starmap(range, col_spans))) - rows = numpy.array(rows, dtype=numpy.intp) - # map the rows through the applied sorting (if any) - rows = model.mapToSourceRows(rows) - rows = rows.tolist() - return rows, cols + return self.view.blockSelection() - @staticmethod - def _get_model(view): - model = view.model() - while isinstance(model, QAbstractProxyModel): - model = model.sourceModel() - return model + def clear_selection(self): + self.set_selection([], []) + @gui.deferred def commit(self): """ Commit/send the current selected row/column selection. """ selected_data = table = rowsel = None - view = self.tabs.currentWidget() - if view and view.model() is not None: - model = self._get_model(view) - table = model.source # The input data table + if self.input is not None: + model = self.input.model + table = self.input.table # Selections of individual instances are not implemented # for SqlTables @@ -715,8 +596,8 @@ def commit(self): self.Outputs.annotated_data.send(None) return - rowsel, colsel = self.get_selection(view) - self.selected_rows, self.selected_cols = rowsel, colsel + rowsel, colsel = self.get_selection() + self.stored_selection = {"rows": rowsel, "columns": colsel} domain = table.domain @@ -740,11 +621,16 @@ def select_vars(role): metas = select_vars(TableModel.Meta) domain = Orange.data.Domain(attrs, class_vars, metas) - # Send all data by default - if not rowsel: - selected_data = table - else: + sortsection = self.view.horizontalHeader().sortIndicatorSection() + if rowsel: selected_data = table.from_table(domain, table, rowsel) + elif sortsection != -1: + # Send sorted data + permutation = model.mapToSourceRows(...) + selected_data = table.from_table(table.domain, table, permutation) + else: + # Send all data by default + selected_data = table self.Outputs.selected_data.send(selected_data) self.Outputs.annotated_data.send(create_annotated_table(table, rowsel)) @@ -753,102 +639,21 @@ def copy(self): """ Copy current table selection to the clipboard. """ - view = self.tabs.currentWidget() - if view is not None: - mime = table_selection_to_mime_data(view) + if self.input is not None: + mime = table_selection_to_mime_data(self.view) QApplication.clipboard().setMimeData( mime, QClipboard.Clipboard ) def send_report(self): - view = self.tabs.currentWidget() - if not view or not view.model(): + if self.input is None: return - model = self._get_model(view) + model = self.input.model self.report_data_brief(model.source) - self.report_table(view) - - -# Table Summary - -# Basic statistics for X/Y/metas arrays -DenseArray = namedtuple( - "DenseArray", ["nans", "non_nans", "stats"]) -SparseArray = namedtuple( - "SparseArray", ["nans", "non_nans", "stats"]) -SparseBoolArray = namedtuple( - "SparseBoolArray", ["nans", "non_nans", "stats"]) -NotAvailable = namedtuple("NotAvailable", []) - -#: Orange.data.Table summary -Summary = namedtuple( - "Summary", - ["len", "domain", "X", "Y", "M"]) - -#: Orange.data.sql.table.SqlTable summary -ApproxSummary = namedtuple( - "ApproxSummary", - ["approx_len", "len", "domain", "X", "Y", "M"]) - - -def table_summary(table): - if isinstance(table, SqlTable): - approx_len = table.approx_len() - len_future = concurrent.futures.Future() - - def _len(): - len_future.set_result(len(table)) - threading.Thread(target=_len).start() # KILL ME !!! - - return ApproxSummary(approx_len, len_future, table.domain, - NotAvailable(), NotAvailable(), NotAvailable()) - else: - domain = table.domain - n_instances = len(table) - # dist = basic_stats.DomainBasicStats(table, include_metas=True) - bstats = datacaching.getCached( - table, basic_stats.DomainBasicStats, (table, True) - ) - - dist = bstats.stats - # pylint: disable=unbalanced-tuple-unpacking - X_dist, Y_dist, M_dist = numpy.split( - dist, numpy.cumsum([len(domain.attributes), - len(domain.class_vars)])) - - def parts(array, density, col_dist): - array = numpy.atleast_2d(array) - nans = sum([dist.nans for dist in col_dist]) - non_nans = sum([dist.non_nans for dist in col_dist]) - if density == Storage.DENSE: - return DenseArray(nans, non_nans, col_dist) - elif density == Storage.SPARSE: - return SparseArray(nans, non_nans, col_dist) - elif density == Storage.SPARSE_BOOL: - return SparseBoolArray(nans, non_nans, col_dist) - elif density == Storage.MISSING: - return NotAvailable() - else: - assert False - return None - - X_part = parts(table.X, table.X_density(), X_dist) - Y_part = parts(table.Y, table.Y_density(), Y_dist) - M_part = parts(table.metas, table.metas_density(), M_dist) - return Summary(n_instances, domain, X_part, Y_part, M_part) - - -def is_sortable(table): - if isinstance(table, SqlTable): - return False - elif isinstance(table, Orange.data.Table): - return True - else: - return False + self.report_table(self.view) if __name__ == "__main__": # pragma: no cover - WidgetPreview(OWDataTable).run( - [(Table("iris"), "iris"), - (Table("brown-selected"), "brown-selected"), - (Table("housing"), "housing")]) + WidgetPreview(OWTable).run( + input_data=Table("iris"), + ) diff --git a/Orange/widgets/data/owtransform.py b/Orange/widgets/data/owtransform.py index 1fbf465b8e0..f4d9a94080e 100644 --- a/Orange/widgets/data/owtransform.py +++ b/Orange/widgets/data/owtransform.py @@ -6,14 +6,31 @@ from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import OWWidget, Input, Output, Msg +from Orange.widgets.utils.concurrent import TaskState, ConcurrentWidgetMixin -class OWTransform(OWWidget): +class TransformRunner: + @staticmethod + def run( + data: Table, + template_data: Table, + state: TaskState + ) -> Optional[Table]: + if data is None or template_data is None: + return None + + state.set_status("Transforming...") + transformed_data = data.transform(template_data.domain) + return transformed_data + + +class OWTransform(OWWidget, ConcurrentWidgetMixin): name = "Apply Domain" description = "Applies template domain on data table." + category = "Transform" icon = "icons/Transform.svg" - priority = 2110 - keywords = ["transform"] + priority = 1230 + keywords = "apply domain, transform" class Inputs: data = Input("Data", Table, default=True) @@ -30,47 +47,27 @@ class Error(OWWidget.Error): buttons_area_orientation = None def __init__(self): - super().__init__() + OWWidget.__init__(self) + ConcurrentWidgetMixin.__init__(self) self.data = None # type: Optional[Table] self.template_data = None # type: Optional[Table] self.transformed_info = describe_data(None) # type: OrderedDict - info_box = gui.widgetBox(self.controlArea, "Info") - self.input_label = gui.widgetLabel(info_box, "") - self.template_label = gui.widgetLabel(info_box, "") - self.output_label = gui.widgetLabel(info_box, "") - self.set_input_label_text() - self.set_template_label_text() + box = gui.widgetBox(self.controlArea, True) + gui.label( + box, self, """ +The widget takes Data, to which it re-applies transformations +that were applied to Template Data. - def set_input_label_text(self): - text = "No data on input." - if self.data: - text = "Input data with {:,} instances and {:,} features.".format( - len(self.data), - len(self.data.domain.attributes)) - self.input_label.setText(text) - - def set_template_label_text(self): - text = "No template data on input." - if self.data and self.template_data: - text = "Template domain applied." - elif self.template_data: - text = "Template data includes {:,} features.".format( - len(self.template_data.domain.attributes)) - self.template_label.setText(text) - - def set_output_label_text(self, data): - text = "" - if data: - text = "Output data includes {:,} features.".format( - len(data.domain.attributes)) - self.output_label.setText(text) +These include selecting a subset of variables as well as +computing variables from other variables appearing in the data, +like, for instance, discretization, feature construction, PCA etc. +""".strip(), box=True) @Inputs.data @check_sql_input def set_data(self, data): self.data = data - self.set_input_label_text() @Inputs.template_data @check_sql_input @@ -82,18 +79,8 @@ def handleNewSignals(self): def apply(self): self.clear_messages() - transformed_data = None - if self.data and self.template_data: - try: - transformed_data = self.data.transform(self.template_data.domain) - except Exception as ex: # pylint: disable=broad-except - self.Error.error(ex) - - data = transformed_data - self.transformed_info = describe_data(data) - self.Outputs.transformed_data.send(data) - self.set_template_label_text() - self.set_output_label_text(data) + self.cancel() + self.start(TransformRunner.run, self.data, self.template_data) def send_report(self): if self.data: @@ -103,6 +90,21 @@ def send_report(self): if self.transformed_info: self.report_items("Transformed data", self.transformed_info) + def on_partial_result(self, _): + pass + + def on_done(self, result: Optional[Table]): + self.transformed_info = describe_data(result) + self.Outputs.transformed_data.send(result) + + def on_exception(self, ex): + self.Error.error(ex) + self.Outputs.transformed_data.send(None) + + def onDeleteWidget(self): + self.shutdown() + super().onDeleteWidget() + if __name__ == "__main__": # pragma: no cover from Orange.preprocess import Discretize diff --git a/Orange/widgets/data/owtranspose.py b/Orange/widgets/data/owtranspose.py index 973586d392c..ae12f845fa4 100644 --- a/Orange/widgets/data/owtranspose.py +++ b/Orange/widgets/data/owtranspose.py @@ -12,7 +12,8 @@ def run(data: Table, - variable: Optional[Union[Variable, bool]], + variable: Optional[Union[Variable, bool, str]], + meta_attr_name: str, feature_name: str, remove_redundant_inst: bool, state: TaskState @@ -27,7 +28,9 @@ def callback(i: float, status=""): if state.is_interruption_requested(): raise Exception - return Table.transpose(data, variable, feature_name=feature_name, + return Table.transpose(data, variable, + meta_attr_name=meta_attr_name, + feature_name=feature_name, remove_redundant_inst=remove_redundant_inst, progress_callback=callback) @@ -35,9 +38,10 @@ def callback(i: float, status=""): class OWTranspose(OWWidget, ConcurrentWidgetMixin): name = "Transpose" description = "Transpose data table." + category = "Transform" icon = "icons/Transpose.svg" - priority = 2000 - keywords = [] + priority = 110 + keywords = "transpose" class Inputs: data = Input("Data", Table) @@ -57,8 +61,11 @@ class Outputs: feature_name = ContextSetting("") feature_names_column = ContextSetting(None) remove_redundant_inst = ContextSetting(False) + output_column_name = Setting("", schema_only=True) auto_apply = Setting(True) + settings_version = 2 + class Warning(OWWidget.Warning): duplicate_names = Msg("Values are not unique.\nTo avoid multiple " "features with the same name, values \nof " @@ -75,8 +82,8 @@ def __init__(self): # self.apply is changed later, pylint: disable=unnecessary-lambda box = gui.radioButtons( - self.controlArea, self, "feature_type", box="Feature names", - callback=lambda: self.apply()) + self.controlArea, self, "feature_type", box="Output column names", + callback=self.commit.deferred) button = gui.appendRadioButton(box, "Generic") edit = gui.lineEdit( @@ -85,7 +92,7 @@ def __init__(self): placeholderText="Type a prefix ...", toolTip="Custom feature name") edit.editingFinished.connect(self._apply_editing) - self.meta_button = gui.appendRadioButton(box, "From variable:") + self.meta_button = gui.appendRadioButton(box, "From column:") self.feature_model = DomainModel( valid_types=(ContinuousVariable, StringVariable), alphabetical=False) @@ -97,20 +104,27 @@ def __init__(self): self.remove_check = gui.checkBox( gui.indentedBox(box, gui.checkButtonOffsetHint(button)), self, "remove_redundant_inst", "Remove redundant instance", - callback=lambda: self.apply()) + callback=self.commit.deferred) + + box = gui.vBox(self.controlArea, + "Name for column with original column names") + gui.lineEdit( + box, self, "output_column_name", + placeholderText="Column name", + callback=self.commit.deferred) - gui.auto_apply(self.buttonsArea, self, commit=self.apply) + gui.auto_apply(self.buttonsArea, self) self.set_controls() def _apply_editing(self): self.feature_type = self.GENERIC self.feature_name = self.feature_name.strip() - self.apply() + self.commit.deferred() def _feature_combo_changed(self): self.feature_type = self.FROM_VAR - self.apply() + self.commit.deferred() @Inputs.data def set_data(self, data): @@ -122,7 +136,7 @@ def set_data(self, data): self.set_controls() if self.feature_model: self.openContext(data) - self.unconditional_apply() + self.commit.now() def set_controls(self): self.feature_model.set_domain(self.data.domain if self.data else None) @@ -133,19 +147,21 @@ def set_controls(self): else: self.feature_names_column = None - def apply(self): + @gui.deferred + def commit(self): self.clear_messages() variable = self.feature_type == self.FROM_VAR and \ self.feature_names_column if variable and self.data: - names = self.data.get_column_view(variable)[0] + names = self.data.get_column(variable) if len(names) != len(set(names)): self.Warning.duplicate_names(variable) if self.data and self.data.domain.has_discrete_attributes(): self.Warning.discrete_attrs() feature_name = self.feature_name or self.DEFAULT_PREFIX + meta_attr_name = self.output_column_name or "Column name" self.start(run, self.data, variable, - feature_name, self.remove_redundant_inst) + meta_attr_name, feature_name, self.remove_redundant_inst) def on_partial_result(self, _): pass @@ -174,6 +190,11 @@ def send_report(self): if self.data: self.report_data("Data", self.data) + @classmethod + def migrate_settings(cls, settings, version): + if version < 2: + settings.setdefault("output_column_name", "Feature name") + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWTranspose).run(Table("iris")) diff --git a/Orange/widgets/data/owunique.py b/Orange/widgets/data/owunique.py index d1915c99362..ea07f59062f 100644 --- a/Orange/widgets/data/owunique.py +++ b/Orange/widgets/data/owunique.py @@ -3,6 +3,7 @@ import numpy as np from AnyQt.QtCore import Qt +from orangewidget.utils.listview import ListViewFilter from Orange.data import Table from Orange.widgets import widget, gui, settings @@ -14,6 +15,9 @@ class OWUnique(widget.OWWidget): name = 'Unique' icon = 'icons/Unique.svg' description = 'Filter instances unique by specified key attribute(s).' + category = "Transform" + priority = 1120 + keywords = 'unique, distinct, remove, duplicates, filter' class Inputs: data = widget.Input("Data", Table) @@ -44,14 +48,16 @@ def __init__(self): self.var_model = DomainModel(parent=self, order=DomainModel.MIXED) var_list = gui.listView( self.controlArea, self, "selected_vars", box="Group by", - model=self.var_model, callback=lambda: self.commit()) + model=self.var_model, callback=self.commit.deferred, + viewType=ListViewFilter + ) var_list.setSelectionMode(var_list.ExtendedSelection) gui.comboBox( self.controlArea, self, 'tiebreaker', box=True, label='Instance to select in each group:', items=tuple(self.TIEBREAKERS), - callback=lambda: self.commit(), sendSelectedValue=True) + callback=self.commit.deferred, sendSelectedValue=True) gui.auto_commit( self.controlArea, self, 'autocommit', 'Commit', orientation=Qt.Horizontal) @@ -68,8 +74,9 @@ def set_data(self, data): else: self.var_model.set_domain(None) - self.unconditional_commit() + self.commit.now() + @gui.deferred def commit(self): if self.data is None: self.Outputs.data.send(None) @@ -78,7 +85,7 @@ def commit(self): def _compute_unique_data(self): uniques = {} - keys = zip(*[self.data.get_column_view(attr)[0] + keys = zip(*[self.data.get_column(attr) for attr in self.selected_vars or self.var_model]) for i, key in enumerate(keys): uniques.setdefault(key, []).append(i) diff --git a/Orange/widgets/data/tests/actually-a-tab-file.xlsx b/Orange/widgets/data/tests/actually-a-tab-file.xlsx new file mode 100644 index 00000000000..87dffb64ded --- /dev/null +++ b/Orange/widgets/data/tests/actually-a-tab-file.xlsx @@ -0,0 +1,27 @@ +age prescription astigmatic tear_rate lenses +discrete discrete discrete discrete discrete + class +young myope no reduced none +young myope no normal soft +young myope yes reduced none +young myope yes normal hard +young hypermetrope no reduced none +young hypermetrope no normal soft +young hypermetrope yes reduced none +young hypermetrope yes normal hard +pre-presbyopic myope no reduced none +pre-presbyopic myope no normal soft +pre-presbyopic myope yes reduced none +pre-presbyopic myope yes normal hard +pre-presbyopic hypermetrope no reduced none +pre-presbyopic hypermetrope no normal soft +pre-presbyopic hypermetrope yes reduced none +pre-presbyopic hypermetrope yes normal none +presbyopic myope no reduced none +presbyopic myope no normal none +presbyopic myope yes reduced none +presbyopic myope yes normal hard +presbyopic hypermetrope no reduced none +presbyopic hypermetrope no normal soft +presbyopic hypermetrope yes reduced none +presbyopic hypermetrope yes normal none diff --git a/Orange/widgets/data/tests/an_excel_file-too.foo b/Orange/widgets/data/tests/an_excel_file-too.foo new file mode 100644 index 00000000000..bc3fd8089b5 Binary files /dev/null and b/Orange/widgets/data/tests/an_excel_file-too.foo differ diff --git a/Orange/widgets/data/tests/an_excel_file.foo b/Orange/widgets/data/tests/an_excel_file.foo new file mode 100644 index 00000000000..bc3fd8089b5 Binary files /dev/null and b/Orange/widgets/data/tests/an_excel_file.foo differ diff --git a/Orange/widgets/data/tests/an_excel_file.xlsx b/Orange/widgets/data/tests/an_excel_file.xlsx new file mode 100644 index 00000000000..bc3fd8089b5 Binary files /dev/null and b/Orange/widgets/data/tests/an_excel_file.xlsx differ diff --git a/Orange/widgets/data/tests/orange-in-education.tab b/Orange/widgets/data/tests/orange-in-education.tab new file mode 100644 index 00000000000..51ad69048ca --- /dev/null +++ b/Orange/widgets/data/tests/orange-in-education.tab @@ -0,0 +1,103 @@ +Role Orange use Familiar with Timestamp Country Classes with Orange +professor student teaching\ assistant in-class,\ in\ hands-on\ workshops in-class,\ in\ hands-on\ workshops;outside\ the\ classroom in-class,\ in\ lectures in-class,\ in\ lectures;in-class,\ in\ hands-on\ workshops in-class,\ in\ lectures;in-class,\ in\ hands-on\ workshops;outside\ the\ classroom in-class,\ in\ lectures;outside\ the\ classroom outside\ the\ classroom YouTube\ videos YouTube\ videos;lectures\ notes\ published\ on\ the\ Orange\ blog YouTube\ videos;lectures\ notes\ published\ on\ the\ Orange\ blog;published\ literature YouTube\ videos;published\ literature lectures\ notes\ published\ on\ the\ Orange\ blog lectures\ notes\ published\ on\ the\ Orange\ blog;published\ literature published\ literature time string string + meta meta meta +professor outside the classroom YouTube videos;lectures notes published on the Orange blog 2020-12-12 09:06:34 Pakistan Machine Learning +professor in-class, in lectures YouTube videos 2021-03-19 21:36:49 Portugal Data mining +student in-class, in hands-on workshops;outside the classroom YouTube videos;lectures notes published on the Orange blog 2020-12-10 03:35:34 Canada - Ontario prediction +student outside the classroom 2021-04-12 11:15:13 Italy computer science +professor in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos;lectures notes published on the Orange blog;published literature 2021-03-30 01:18:39 Ecuador computer science;text mining +student in-class, in hands-on workshops YouTube videos 2021-03-31 01:54:17 France business analytics +professor in-class, in lectures YouTube videos 2020-12-10 16:51:59 Germany Material Science +professor in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos;lectures notes published on the Orange blog;published literature 2021-03-29 04:39:05 Canada computer science +student in-class, in lectures;outside the classroom YouTube videos;lectures notes published on the Orange blog 2020-12-10 23:36:42 Sweden digital humanities +professor outside the classroom YouTube videos 2021-04-13 15:18:12 Brazil computer science;text mining +student in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos;lectures notes published on the Orange blog;published literature 2021-03-27 19:43:11 Czech Republic big data analysis in management +teaching assistant in-class, in lectures YouTube videos 2020-12-11 13:39:51 Indonesia computer science;text mining +professor in-class, in lectures;in-class, in hands-on workshops YouTube videos;lectures notes published on the Orange blog 2021-04-17 23:57:00 Switzerland digital humanities +professor in-class, in lectures YouTube videos;lectures notes published on the Orange blog;published literature 2020-12-11 07:26:54 Bulgaria computer science +professor in-class, in lectures;in-class, in hands-on workshops YouTube videos;lectures notes published on the Orange blog 2020-12-16 14:49:04 Spain computer science;text mining +student in-class, in lectures;in-class, in hands-on workshops YouTube videos 2021-03-24 08:09:51 India data science +student in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos 2020-12-18 11:11:20 United Kingdom computer science +student in-class, in lectures YouTube videos 2020-12-20 12:07:00 Turkey digital humanities +student outside the classroom YouTube videos 2021-04-22 04:04:37 Argentina data science +student outside the classroom YouTube videos;lectures notes published on the Orange blog 2021-04-05 07:34:26 Indonesia biology +professor in-class, in lectures;in-class, in hands-on workshops;outside the classroom 2020-12-10 13:11:04 Latvia computer science;text mining +teaching assistant in-class, in hands-on workshops;outside the classroom lectures notes published on the Orange blog;published literature 2020-12-16 16:48:41 Portugal text mining +teaching assistant in-class, in lectures;in-class, in hands-on workshops YouTube videos;lectures notes published on the Orange blog 2021-04-16 07:33:47 Egypt computer science +professor outside the classroom published literature 2021-04-08 03:01:17 Brazil digital humanities +professor in-class, in lectures YouTube videos 2020-12-15 04:49:08 India Management +student in-class, in lectures published literature 2020-12-12 18:52:57 Colombia text mining +professor in-class, in lectures YouTube videos 2020-12-17 16:54:40 Turkey computer science +student outside the classroom YouTube videos 2020-12-10 18:43:37 Ireland computer science +professor in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos 2020-12-11 08:23:55 India Business Administration +student in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos;lectures notes published on the Orange blog 2021-03-22 11:44:04 Turkey Data Mining +student in-class, in lectures;outside the classroom YouTube videos 2021-03-20 16:03:47 Netherlands digital humanities +student in-class, in lectures;in-class, in hands-on workshops YouTube videos 2020-12-12 07:59:12 Indonesia computer science;text mining +student in-class, in lectures lectures notes published on the Orange blog 2021-03-21 17:38:31 Saudi Arabia Statistics +student outside the classroom YouTube videos 2021-03-26 21:24:08 United States of America - Massachusetts computer science;text mining +student in-class, in lectures;outside the classroom 2020-12-11 00:42:56 Malaysia computer science +teaching assistant outside the classroom YouTube videos 2021-03-21 21:23:57 United States of America - California Astronomy +professor in-class, in lectures;in-class, in hands-on workshops YouTube videos;lectures notes published on the Orange blog 2020-12-10 22:16:14 Brazil computer science;data science +professor in-class, in hands-on workshops 2021-03-29 19:06:32 France Final studies project +student outside the classroom YouTube videos 2020-12-13 02:38:30 Australia Chemistry +professor outside the classroom 2021-04-06 19:26:58 China biology +student in-class, in hands-on workshops published literature 2020-12-12 22:27:35 China - Hong Kong SAR computer science +professor outside the classroom YouTube videos 2021-04-06 12:09:38 New Zealand text mining +professor in-class, in lectures;in-class, in hands-on workshops YouTube videos 2021-04-09 15:37:47 France computer science;text mining;data mining +teaching assistant outside the classroom YouTube videos 2020-12-19 12:45:24 Saudi Arabia computer science +professor in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos;lectures notes published on the Orange blog 2020-12-16 17:42:32 Brazil computer science +professor in-class, in hands-on workshops YouTube videos 2020-12-10 09:03:23 Russian Federation sport sciences +professor in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos 2021-03-19 23:54:38 Portugal Data Mining +professor outside the classroom YouTube videos;lectures notes published on the Orange blog;published literature 2021-03-29 17:27:09 Philippines text mining;Research Methods in Medicine +student in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos;published literature 2020-12-16 17:46:16 Ukraine computer science;artificial intelligence +professor in-class, in hands-on workshops YouTube videos 2021-03-25 14:22:49 Thailand computer science +professor in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos 2020-12-11 19:45:35 United States of America - California text mining;Consumer Insights +student in-class, in lectures;in-class, in hands-on workshops 2020-12-14 08:52:29 Netherlands computer science;digital humanities;design +professor in-class, in lectures published literature 2021-04-07 05:02:58 Korea (Republic of) Smart Factory +student in-class, in lectures YouTube videos 2020-12-25 21:04:48 Croatia computer science +student in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos;lectures notes published on the Orange blog 2021-04-19 15:31:54 United Kingdom computer science +student in-class, in lectures;in-class, in hands-on workshops;outside the classroom YouTube videos;lectures notes published on the Orange blog 2021-04-08 15:25:51 India HR Analytics +professor in-class, in hands-on workshops;outside the classroom YouTube videos;published literature 2020-12-10 16:33:04 United States of America - Pennsylvania Electrodynamics +professor in-class, in lectures;in-class, in hands-on workshops YouTube videos 2020-12-16 17:19:53 Canada - Quebec / Québec agronomy +student in-class, in hands-on workshops published literature 2021-04-13 13:53:44 Romania text mining +student in-class, in hands-on workshops 2021-03-25 23:57:48 Brazil computer science;text mining +professor outside the classroom YouTube videos 2020-12-09 17:48:41 Thailand computer science;biology +professor outside the classroom YouTube videos 2021-03-21 14:42:52 Brazil text mining +student outside the classroom YouTube videos 2021-03-20 21:45:37 India text mining +teaching assistant outside the classroom YouTube videos 2021-04-15 19:08:14 China Transportation data analysis +student in-class, in hands-on workshops lectures notes published on the Orange blog 2020-12-15 05:49:47 India computer science;text mining +professor in-class, in hands-on workshops YouTube videos 2021-03-30 20:43:35 France computer science +student outside the classroom YouTube videos 2021-03-23 11:28:40 Argentina computer science;text mining +teaching assistant in-class, in lectures YouTube videos;lectures notes published on the Orange blog 2020-12-22 16:22:35 Germany text mining +student outside the classroom lectures notes published on the Orange blog 2021-04-08 13:22:44 India text mining +professor in-class, in lectures;outside the classroom lectures notes published on the Orange blog 2021-04-15 07:56:12 Korea (Republic of) computer science +professor in-class, in hands-on workshops lectures notes published on the Orange blog 2021-03-24 14:45:22 India computer science +student in-class, in lectures;in-class, in hands-on workshops;outside the classroom published literature 2021-04-15 04:44:38 India computer science +student in-class, in hands-on workshops YouTube videos 2021-03-29 14:36:24 United States of America - Ohio data science +student in-class, in hands-on workshops YouTube videos 2021-03-23 10:27:41 Singapore text mining +professor outside the classroom YouTube videos 2020-12-11 14:16:14 Indonesia computer science +teaching assistant in-class, in lectures;outside the classroom YouTube videos 2020-12-22 22:29:56 Japan text mining +student in-class, in lectures YouTube videos 2020-12-15 14:56:45 Indonesia computer science +student outside the classroom YouTube videos 2021-04-10 19:19:58 Italy biology +student in-class, in lectures 2021-03-22 12:51:18 United Kingdom computer science +student in-class, in hands-on workshops YouTube videos;lectures notes published on the Orange blog;published literature 2021-04-12 18:44:38 Brazil text mining +student outside the classroom YouTube videos 2021-04-08 20:43:15 Brazil My personal work +professor in-class, in lectures YouTube videos;lectures notes published on the Orange blog 2021-04-10 15:18:30 China - Taiwan Big data analysis +teaching assistant in-class, in lectures YouTube videos;lectures notes published on the Orange blog 2020-12-10 01:18:39 Indonesia text mining +professor outside the classroom published literature 2020-12-23 21:06:49 Turkey biology +professor in-class, in lectures YouTube videos 2021-04-12 07:42:57 Korea (Republic of) Business Administration +professor in-class, in hands-on workshops YouTube videos;lectures notes published on the Orange blog 2021-04-13 12:01:24 Oman computer science +teaching assistant outside the classroom YouTube videos;lectures notes published on the Orange blog 2021-03-22 21:14:46 Canada - Ontario Geological Engineering +student outside the classroom YouTube videos 2021-04-19 18:31:40 Argentina computer science +professor in-class, in hands-on workshops published literature 2021-04-10 11:06:15 Russian Federation computer science +professor in-class, in hands-on workshops published literature 2020-12-10 13:19:53 Mexico computer science +professor in-class, in lectures YouTube videos;lectures notes published on the Orange blog 2020-12-13 21:39:59 United States of America - Florida text mining;sport analytics +professor in-class, in lectures;in-class, in hands-on workshops;outside the classroom 2021-03-19 21:39:43 Germany ethics in digital transformation +teaching assistant outside the classroom YouTube videos;published literature 2021-03-19 17:56:23 Hungary computer science;text mining;health management +student in-class, in lectures;in-class, in hands-on workshops;outside the classroom 2021-03-22 13:44:29 India data science +professor in-class, in lectures lectures notes published on the Orange blog 2020-12-10 21:41:44 Brazil industrial automation +student outside the classroom YouTube videos 2020-12-09 16:39:59 Spain text mining +student outside the classroom published literature 2020-12-18 18:13:38 Brazil biology +student outside the classroom lectures notes published on the Orange blog;published literature 2021-03-30 17:45:22 Brazil computer science;text mining +professor in-class, in hands-on workshops;outside the classroom YouTube videos 2021-03-25 14:34:24 Brazil computer science +professor in-class, in lectures YouTube videos;lectures notes published on the Orange blog 2021-04-22 00:44:51 Portugal computer science diff --git a/Orange/widgets/data/tests/test_owaggregatecolumns.py b/Orange/widgets/data/tests/test_owaggregatecolumns.py index 27c53adc784..9bef5c7a034 100644 --- a/Orange/widgets/data/tests/test_owaggregatecolumns.py +++ b/Orange/widgets/data/tests/test_owaggregatecolumns.py @@ -13,6 +13,7 @@ ) from Orange.widgets.data.owaggregatecolumns import OWAggregateColumns from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.utils.signals import AttributeList class TestOWAggregateColumn(WidgetTest): @@ -38,17 +39,17 @@ def test_no_input(self): self.send_signal(widget.Inputs.data, self.data1) self.assertEqual(widget.variables, []) - widget.commit() + widget.commit.now() output = self.get_output(self.widget.Outputs.data) self.assertIs(output, self.data1) widget.variables = [domain[n] for n in "c1 c2 t2".split()] - widget.commit() + widget.commit.now() output = self.get_output(self.widget.Outputs.data) self.assertIsNotNone(output) self.send_signal(widget.Inputs.data, None) - widget.commit() + widget.commit.now() self.assertIsNone(self.get_output(self.widget.Outputs.data)) def test_compute_data(self): @@ -69,7 +70,7 @@ def test_compute_data(self): def test_var_name(self): domain = self.data1.domain self.send_signal(self.widget.Inputs.data, self.data1) - self.widget.variables = self.widget.variable_model[:] + self.widget.selection_method = self.widget.SelectAllAndMeta self.widget.var_name = "test" output = self.widget._compute_data() @@ -84,15 +85,16 @@ def test_var_name(self): def test_var_types(self): domain = self.data1.domain self.send_signal(self.widget.Inputs.data, self.data1) + variables = [domain[n] for n in "t1 c2 t2".split()] - self.widget.variables = [domain[n] for n in "t1 c2 t2".split()] for self.widget.operation in self.widget.Operations: - self.assertIsInstance(self.widget._new_var(), ContinuousVariable) + self.assertIsInstance(self.widget._new_var(variables), + ContinuousVariable) - self.widget.variables = [domain[n] for n in "t1 t2".split()] + variables = [domain[n] for n in "t1 t2".split()] for self.widget.operation in self.widget.Operations: self.assertIsInstance( - self.widget._new_var(), + self.widget._new_var(variables), TimeVariable if self.widget.operation in ("Min", "Max", "Mean", "Median") else ContinuousVariable) @@ -100,7 +102,7 @@ def test_var_types(self): def test_operations(self): domain = self.data1.domain self.send_signal(self.widget.Inputs.data, self.data1) - self.widget.variables = [domain[n] for n in "c1 c2 t2".split()] + variables = [domain[n] for n in "c1 c2 t2".split()] m1, m2 = 4 / 3, 8 / 3 for self.widget.operation, expected in { @@ -109,16 +111,18 @@ def test_operations(self): "Mean": [m1, m2], "Variance": [(m1 ** 2 + (m1 - 1) ** 2 + (m1 - 3) ** 2) / 3, ((m2 - 3) ** 2 + (m2 - 1) ** 2 + (m2 - 4) ** 2) / 3], - "Median": [1, 3]}.items(): + "Median": [1, 3], + "Count non-zero": [2, 3]}.items(): np.testing.assert_equal( - self.widget._compute_column(), expected, + self.widget._compute_column(variables), expected, err_msg=f"error in '{self.widget.operation}'") def test_operations_with_nan(self): domain = self.data1.domain self.send_signal(self.widget.Inputs.data, self.data1) - self.data1.X[1, 0] = np.nan - self.widget.variables = [domain[n] for n in "c1 c2 t2".split()] + with self.data1.unlocked(): + self.data1.X[1, 0] = np.nan + variables = [domain[n] for n in "c1 c2 t2".split()] m1, m2 = 4 / 3, 5 / 2 for self.widget.operation, expected in { @@ -127,9 +131,10 @@ def test_operations_with_nan(self): "Mean": [m1, m2], "Variance": [(m1 ** 2 + (m1 - 1) ** 2 + (m1 - 3) ** 2) / 3, ((m2 - 1) ** 2 + (m2 - 4) ** 2) / 2], - "Median": [1, 2.5]}.items(): + "Median": [1, 2.5], + "Count non-zero": [2, 2]}.items(): np.testing.assert_equal( - self.widget._compute_column(), expected, + self.widget._compute_column(variables), expected, err_msg=f"error in '{self.widget.operation}'") def test_contexts(self): @@ -157,6 +162,151 @@ def test_selection_in_context(self): self.assertSequenceEqual(self.widget.variables[:], self.data1.domain.variables[1:3]) + def test_features_signal(self): + widget = self.widget + widget.selection_method = widget.SelectAll + self.send_signal(widget.Inputs.data, self.data1) + + self.assertEqual([attr.name for attr in widget._variables()], + "c1 c2 t1".split()) + + attr_list = [self.data1.domain[attr] for attr in "c1 t2".split()] + self.send_signal(widget.Inputs.features, AttributeList(attr_list)) + self.assertEqual(widget._variables(), attr_list) + self.assertFalse(widget.Warning.missing_features.is_shown()) + self.assertFalse(widget.Warning.discrete_features.is_shown()) + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [3, 7]) + + attr_list = [self.data1.domain[attr] for attr in "c1 t2 d1".split()] + self.send_signal(widget.Inputs.features, AttributeList(attr_list)) + self.assertEqual(widget._variables(), attr_list[:2]) + self.assertFalse(widget.Warning.missing_features.is_shown()) + self.assertTrue(widget.Warning.discrete_features.is_shown()) + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [3, 7]) + + attr_list.append(ContinuousVariable("foo")) + self.send_signal(widget.Inputs.features, AttributeList(attr_list)) + self.assertEqual(widget._variables(), attr_list[:2]) + self.assertTrue(widget.Warning.missing_features.is_shown()) + self.assertTrue(widget.Warning.discrete_features.is_shown()) + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [3, 7]) + + self.send_signal(widget.Inputs.features, None) + self.assertFalse(widget.Warning.missing_features.is_shown()) + self.assertFalse(widget.Warning.discrete_features.is_shown()) + + del attr_list[2] # discrete variable + attr_list.append(ContinuousVariable("foo")) + self.send_signal(widget.Inputs.features, AttributeList(attr_list)) + self.assertEqual(widget._variables(), attr_list[:2]) + self.assertTrue(widget.Warning.missing_features.is_shown()) + self.assertFalse(widget.Warning.discrete_features.is_shown()) + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [3, 7]) + + self.assertEqual(widget.selection_group.checkedId(), + widget.InputFeatures) + self.assertTrue(all( + button.isEnabled() is (i == widget.InputFeatures) + for i, button in enumerate(widget.selection_group.buttons()))) + self.assertFalse(widget.controls.variables.isEnabled()) + + self.send_signal(widget.Inputs.features, None) + self.assertEqual([attr.name for attr in widget._variables()], + "c1 c2 t1".split()) + self.assertEqual(widget.selection_group.checkedId(), widget.SelectAll) + self.assertTrue(all( + button.isEnabled() is (i != widget.InputFeatures) + for i, button in enumerate(widget.selection_group.buttons()))) + self.assertFalse(widget.controls.variables.isEnabled()) + + self.send_signal(widget.Inputs.features, AttributeList()) + self.assertEqual(widget.selection_group.checkedId(), widget.InputFeatures) + self.assertTrue(all(button.isDisabled()) + for button in widget.selection_group.buttons()) + self.assertFalse(widget.controls.variables.isEnabled()) + + attr_list = [self.data1.domain[attr] for attr in "d1 d2".split()] + self.send_signal(widget.Inputs.features, AttributeList(attr_list)) + self.assertEqual(widget.selection_group.checkedId(), widget.InputFeatures) + self.assertTrue(all(button.isDisabled()) + for button in widget.selection_group.buttons()) + self.assertFalse(widget.controls.variables.isEnabled()) + self.assertNotIn( + "agg", + [var.name for var in self.get_output(widget.Outputs.data).domain]) + + def test_selection_radios(self): + widget = self.widget + self.send_signal(widget.Inputs.data, self.data1) + widget.variables = [self.data1.domain[attr] for attr in "c1 t2".split()] + + widget.selection_group.button(widget.SelectAll).click() + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [3, 46]) + + widget.selection_group.button(widget.SelectAllAndMeta).click() + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [6, 50]) + + widget.selection_group.button(widget.SelectManually).click() + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [3, 7]) + + def test_operation_changed(self): + widget = self.widget + self.send_signal(widget.Inputs.data, self.data1) + widget.selection_group.button(widget.SelectAll).click() + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [3, 46]) + + oper = widget.Operations["Max"].name + widget.operation_combo.setCurrentText(oper) + widget.operation_combo.textActivated[str].emit(oper) + np.testing.assert_equal( + self.get_output(widget.Outputs.data).get_column_view("agg")[0], + [2, 42]) + + def test_and_others(self): + self.assertEqual( + self.widget._and_others(self.data1.domain.variables[:1], 1), + "'c1'") + self.assertEqual( + self.widget._and_others(self.data1.domain.variables[:1], 10), + "'c1'") + self.assertEqual( + self.widget._and_others(self.data1.domain.variables, 20), + "'c1', 'c2', 'd1', 'd2', 't1' and 'd3'") + self.assertEqual( + self.widget._and_others(self.data1.domain.variables, 6), + "'c1', 'c2', 'd1', 'd2', 't1' and 'd3'") + self.assertEqual( + self.widget._and_others(self.data1.domain.variables, 5), + "'c1', 'c2', 'd1', 'd2', 't1' and 1 more") + self.assertEqual( + self.widget._and_others(self.data1.domain.variables, 2), + "'c1', 'c2' and 4 more") + + def test_missing(self): + attrs = self.data1.domain.attributes + self.assertEqual(self.widget._missing(attrs, attrs), "") + + self.assertEqual(self.widget._missing(attrs, attrs[1:]), + f"'{attrs[0].name}'") + self.assertEqual(self.widget._missing(attrs, attrs[2:]), + f"'{attrs[0].name}' and '{attrs[1].name}'") + def test_report(self): self.widget.send_report() diff --git a/Orange/widgets/data/tests/test_owcolor.py b/Orange/widgets/data/tests/test_owcolor.py index 859c2cdd242..d19aa51f0d5 100644 --- a/Orange/widgets/data/tests/test_owcolor.py +++ b/Orange/widgets/data/tests/test_owcolor.py @@ -31,6 +31,11 @@ def test_name(self): desc.name = None self.assertEqual(desc.name, "x") + def test_no_compute_value(self): + x = ContinuousVariable("x", compute_value=lambda x: 42) + desc = owcolor.AttrDesc(x) + self.assertIsNone(desc.var.compute_value) + def test_reset(self): x = ContinuousVariable("x") desc = owcolor.AttrDesc(x) @@ -89,20 +94,20 @@ def test_create_variable(self): desc.set_color(2, [7, 8, 9]) desc.name = "z" desc.set_value(1, "d") - var = desc.create_variable() + var = desc.create_variable(self.var) self.assertIsInstance(var, DiscreteVariable) self.assertEqual(var.name, "z") self.assertEqual(var.values, ("a", "d", "c")) np.testing.assert_equal(var.colors, [[1, 2, 3], [4, 5, 6], [7, 8, 9]]) self.assertIsInstance(var.compute_value, Identity) - self.assertIs(var.compute_value.variable, desc.var) + self.assertIs(var.compute_value.variable, self.var) palette = desc.var.attributes["palette"] = object() - var = desc.create_variable() + var = desc.create_variable(self.var) self.assertIs(desc.var.attributes["palette"], palette) self.assertFalse(hasattr(var.attributes, "palette")) self.assertIsInstance(var.compute_value, Identity) - self.assertIs(var.compute_value.variable, desc.var) + self.assertIs(var.compute_value.variable, self.var) def test_reset(self): desc = self.desc @@ -228,8 +233,8 @@ def test_from_dict_exceptions(self): class ContAttrDescTest(unittest.TestCase): def setUp(self): - x = ContinuousVariable("x") - self.desc = owcolor.ContAttrDesc(x) + self.var = ContinuousVariable("x") + self.desc = owcolor.ContAttrDesc(self.var) def test_palette(self): desc = self.desc @@ -246,19 +251,19 @@ def test_create_variable(self): palette_name = _find_other_palette( colorpalettes.ContinuousPalettes[desc.palette_name]).name desc.palette_name = palette_name - var = desc.create_variable() + var = desc.create_variable(self.var) self.assertIsInstance(var, ContinuousVariable) self.assertEqual(var.name, "z") self.assertEqual(var.palette.name, palette_name) self.assertIsInstance(var.compute_value, Identity) - self.assertIs(var.compute_value.variable, desc.var) + self.assertIs(var.compute_value.variable, self.var) colors = desc.var.attributes["colors"] = object() - var = desc.create_variable() + var = desc.create_variable(self.var) self.assertIs(desc.var.attributes["colors"], colors) self.assertFalse(hasattr(var.attributes, "colors")) self.assertIsInstance(var.compute_value, Identity) - self.assertIs(var.compute_value.variable, desc.var) + self.assertIs(var.compute_value.variable, self.var) def test_reset(self): desc = self.desc @@ -639,7 +644,7 @@ def test_invalid_input_colors(self): self.send_signal(self.widget.Inputs.data, t) def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_commit') as commit: + with patch.object(self.widget.commit, 'now') as commit: self.widget.auto_apply = False commit.reset_mock() self.send_signal(self.widget.Inputs.data, self.iris) @@ -649,7 +654,7 @@ def test_commit_on_data_changed(self): widget = self.widget model = widget.cont_model self.send_signal(widget.Inputs.data, self.iris) - with patch.object(widget, 'commit') as commit: + with patch.object(widget.commit, 'deferred') as commit: commit.reset_mock() model.setData(model.index(0, 0), "y", Qt.EditRole) commit.assert_called() @@ -701,6 +706,21 @@ def test_report(self): def test_string_variables(self): self.send_signal(self.widget.Inputs.data, Table("zoo")) + def test_changed_compute_value(self): + # test a bug where the widget did not register changes in compute_value + # because it reused an old context + w = self.widget + domain1 = Domain([ContinuousVariable("x", compute_value=lambda _: 1)]) + data1 = self.iris.transform(domain1) + self.send_signal(w.Inputs.data, data1) + outp = self.get_output(w.Outputs.data) + np.testing.assert_array_equal(outp, 1) + domain2 = Domain([ContinuousVariable("x", compute_value=lambda _: 2)]) + data2 = self.iris.transform(domain2) + self.send_signal(w.Inputs.data, data2) + outp = self.get_output(w.Outputs.data) + np.testing.assert_array_equal(outp, 2) + def test_reset(self): self.send_signal(self.widget.Inputs.data, self.iris) cont_model = self.widget.cont_model @@ -793,6 +813,32 @@ def test_load(self, msg_box): msg_box.reset_mock() self.widget._parse_var_defs.assert_called_with(json.load.return_value) + @patch("Orange.widgets.data.owcolor.QMessageBox.warning") + def test_load_ignore_warning(self, msg_box): + self.widget._parse_var_defs(dict(categorical={}, numeric={})) + msg_box.assert_not_called() + + no_change = dict(renamed_values={}, colors={}) + for names, message in ( + (("foo",), + "'foo'"), + (("foo", "bar"), + "'foo' and 'bar'"), + (("foo", "bar", "baz"), + "'foo', 'bar' and 'baz'"), + (("foo", "bar", "baz", "qux"), + "'foo', 'bar', 'baz' and 'qux'"), + (("foo", "bar", "baz", "qux", "quux"), + "'foo', 'bar', 'baz', 'qux' and 'quux'"), + (("foo", "bar", "baz", "qux", "quux", "corge"), + "'foo', 'bar', 'baz', 'qux' and 2 other"), + (("foo", "bar", "baz", "qux", "quux", "corge", "grault"), + "'foo', 'bar', 'baz', 'qux' and 3 other")): + self.widget._parse_var_defs(dict( + categorical=dict.fromkeys(names, no_change), + numeric={})) + self.assertIn(message, msg_box.call_args[0][2]) + def _create_descs(self): disc_vars = [DiscreteVariable(f"var{c}", values=("a", "b", "c")) for c in "AB"] @@ -882,11 +928,6 @@ def test_parse_var_defs_no_rename(self, msg_box): "numeric": {"varD": {"rename": "varA"}}}) msg_box.assert_not_called() - self.widget._parse_var_defs( - {"categorical": {"varA": {"rename": "X"}}, - "numeric": {"var not": {"rename": "X"}}}) - msg_box.assert_not_called() - if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owconcatenate.py b/Orange/widgets/data/tests/test_owconcatenate.py index 25a0fa56089..6c059657906 100644 --- a/Orange/widgets/data/tests/test_owconcatenate.py +++ b/Orange/widgets/data/tests/test_owconcatenate.py @@ -4,6 +4,7 @@ from unittest.mock import patch, Mock import numpy as np +from numpy.testing import assert_array_equal from Orange.data import ( Table, Domain, ContinuousVariable, DiscreteVariable, StringVariable @@ -25,9 +26,9 @@ def setUp(self): self.titanic = Table("titanic") def test_no_input(self): - self.widget.apply() + self.widget.commit.now() self.widget.controls.append_source_column.toggle() - self.widget.apply() + self.widget.commit.now() self.assertIsNone(self.get_output(self.widget.Outputs.data)) def test_single_input(self): @@ -70,36 +71,54 @@ def test_two_inputs_intersection(self): outvars = output.domain.variables self.assertEqual(0, len(outvars)) + def get_source_var(self, vars_before): + output = self.get_output(self.widget.Outputs.data) + outvars = output.domain.variables + output.domain.metas + return (set(outvars) - set(vars_before)).pop() + def test_source(self): self.send_signal(self.widget.Inputs.additional_data, self.iris, 0) self.send_signal(self.widget.Inputs.additional_data, self.titanic, 1) outputb = self.get_output(self.widget.Outputs.data) outvarsb = outputb.domain.variables - def get_source(): - output = self.get_output(self.widget.Outputs.data) - outvars = output.domain.variables + output.domain.metas - return (set(outvars) - set(outvarsb)).pop() # test adding source self.widget.controls.append_source_column.toggle() - source = get_source() + source = self.get_source_var(outvarsb) self.assertEqual(source.name, "Source ID") # test name changing self.widget.controls.source_attr_name.setText("Source") self.widget.controls.source_attr_name.callback() - source = get_source() + source = self.get_source_var(outvarsb) self.assertEqual(source.name, "Source") # test source_column role places = ["class_vars", "attributes", "metas"] for i, place in enumerate(places): self.widget.source_column_role = i - self.widget.apply() - source = get_source() + self.widget.commit.now() + source = self.get_source_var(outvarsb) output = self.get_output(self.widget.Outputs.data) self.assertTrue(source in getattr(output.domain, place)) data = output.transform(Domain([source])) + self.assertTupleEqual(("iris", "titanic"), source.values) np.testing.assert_equal(data[:len(self.iris)].X, 0) np.testing.assert_equal(data[len(self.iris):].X, 1) + def test_source_ignore_compute_value(self): + """Test source variable correct also when ignore_compute_value on""" + self.send_signal(self.widget.Inputs.additional_data, self.iris, 0) + self.send_signal(self.widget.Inputs.additional_data, self.titanic, 1) + outputb = self.get_output(self.widget.Outputs.data) + outvarsb = outputb.domain.variables + + self.widget.controls.append_source_column.toggle() + self.widget.controls.ignore_compute_value.toggle() # on + source = self.get_source_var(outvarsb) + output = self.get_output(self.widget.Outputs.data) + data = output.transform(Domain([source])) + self.assertTupleEqual(("iris", "titanic"), source.values) + np.testing.assert_equal(data[: len(self.iris)].X, 0) + np.testing.assert_equal(data[len(self.iris) :].X, 1) + def test_singleclass_source_class(self): self.send_signal(self.widget.Inputs.primary_data, self.iris) # add source into a class variable @@ -113,7 +132,7 @@ def test_disable_merging_on_primary(self): self.assertTrue(self.widget.mergebox.isEnabled()) def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_apply') as apply: + with patch.object(self.widget.commit, 'now') as apply: self.widget.auto_commit = False apply.reset_mock() self.send_signal(self.widget.Inputs.primary_data, self.iris) @@ -360,6 +379,101 @@ def test_get_unique_vars(self): self.assertIs(S1, uS1) + def test_ignore_domain(self): + widget = self.widget + + a, b, c, d, e, f, g, h, i = (ContinuousVariable(x) for x in "abcdefghi") + j, k, l = (DiscreteVariable(x, values=tuple("xyz")) for x in "jkl") + m = DiscreteVariable("m", values=tuple("xyzu")) + + abcj = Table.from_list(Domain([a, b], c, [j]), [[0, 1, 2, 0], [4, 5, 6, 2]]) + defk = Table.from_list(Domain([d, e], f, [k]), [[3, 4, 5, 1], [6, 7, 8, 2]]) + ghil = Table.from_list(Domain([g, h], i, [l]), [[7, 8, 9, 0]]) + + widget.ignore_names = True + widget.append_source_column = True + widget.source_column_role = widget.AttributeRole + self.send_signal(widget.Inputs.primary_data, abcj) + self.send_signal(widget.Inputs.additional_data, defk, 1) + self.send_signal(widget.Inputs.additional_data, ghil, 2) + + self.assertTrue(widget.controls.ignore_names.isEnabled()) + self.assertFalse(widget.controls.ignore_compute_value.isEnabled()) + self.assertFalse(widget.Error.incompatible_domains.is_shown()) + out = self.get_output() + self.assertEqual(out.domain.attributes[:2], (a, b)) + self.assertIs(out.domain.class_var, c) + self.assertEqual(out.domain.metas, (j, )) + np.testing.assert_equal(out.X, [[0, 1, 0], + [4, 5, 0], + [3, 4, 1], + [6, 7, 1], + [7, 8, 2]]) + np.testing.assert_equal(out.Y, [2, 6, 5, 8, 9]) + np.testing.assert_equal(out.metas, [[0], [2], [1], [2], [0]]) + + self.send_signal(widget.Inputs.primary_data, None) + self.assertFalse(widget.controls.ignore_names.isEnabled()) + self.assertTrue(widget.controls.ignore_compute_value.isEnabled()) + self.assertFalse(widget.Error.incompatible_domains.is_shown()) + self.assertIsNotNone(self.get_output()) + + self.send_signal(widget.Inputs.primary_data, abcj) + self.assertTrue(widget.controls.ignore_names.isEnabled()) + self.assertFalse(widget.Error.incompatible_domains.is_shown()) + out = self.get_output() + self.assertEqual(out.domain.attributes[:2], (a, b)) + self.assertIs(out.domain.class_var, c) + self.assertEqual(out.domain.metas, (j, )) + + self.send_signal(widget.Inputs.additional_data, None, 1) + self.assertFalse(widget.Error.incompatible_domains.is_shown()) + out = self.get_output() + self.assertEqual(out.domain.attributes[:2], (a, b)) + self.assertIs(out.domain.class_var, c) + self.assertEqual(out.domain.metas, (j, )) + np.testing.assert_equal(out.X, [[0, 1, 0], + [4, 5, 0], + [7, 8, 1]]) + np.testing.assert_equal(out.Y, [2, 6, 9]) + np.testing.assert_equal(out.metas, [[0], [2], [0]]) + + self.send_signal(widget.Inputs.additional_data, None, 2) + self.assertFalse(widget.Error.incompatible_domains.is_shown()) + out = self.get_output() + self.assertEqual(out.domain.attributes[:2], (a, b)) + self.assertIs(out.domain.class_var, c) + self.assertEqual(out.domain.metas, (j, )) + np.testing.assert_equal(out.X, [[0, 1, 0], + [4, 5, 0]]) + np.testing.assert_equal(out.Y, [2, 6]) + np.testing.assert_equal(out.metas, [[0], [2]]) + + self.send_signal(widget.Inputs.primary_data, None) + self.assertFalse(widget.Error.incompatible_domains.is_shown()) + self.assertIsNone(self.get_output()) + + self.send_signal(widget.Inputs.primary_data, abcj) + self.send_signal(widget.Inputs.additional_data, defk, 1) + self.assertFalse(widget.Error.incompatible_domains.is_shown()) + self.assertIsNotNone(self.get_output()) + + self.send_signal(widget.Inputs.additional_data, + Table.from_list(Domain([a, b]), [[1, 2]]), + 2) + self.assertTrue(widget.Error.incompatible_domains.is_shown()) + self.assertIsNone(self.get_output()) + + self.send_signal(widget.Inputs.additional_data, + Table.from_list(Domain([a, b], c, [m]), [[1, 2]]), + 2) + self.assertTrue(widget.Error.incompatible_domains.is_shown()) + self.assertIsNone(self.get_output()) + + self.send_signal(widget.Inputs.primary_data, None) + self.assertFalse(widget.Error.incompatible_domains.is_shown()) + self.assertIsNotNone(self.get_output()) + def test_different_number_decimals(self): widget = self.widget @@ -398,7 +512,7 @@ def times2(*_): return table_n, table_m def test_dumb_tables(self): - self.widget.apply = Mock() + self.widget.commit.deferred = Mock() table_n, table_m = self._create_compute_values() na1, na2, na3, na4, nc1 = table_n.domain.variables ma1, ma2, ma3 = table_m.domain.attributes @@ -449,7 +563,7 @@ def test_dont_ignore_compute_value(self): self.send_signal(self.widget.Inputs.additional_data, table_m, 2) self.widget.ignore_compute_value = False - self.widget.apply() + self.widget.commit.now() output = self.get_output(self.widget.Outputs.data) attributes = output.domain.attributes @@ -475,7 +589,7 @@ def test_ignore_compute_value(self): self.send_signal(self.widget.Inputs.additional_data, table_m, 2) self.widget.ignore_compute_value = True - self.widget.apply() + self.widget.commit.now() output = self.get_output(self.widget.Outputs.data) attributes = output.domain.attributes @@ -493,6 +607,173 @@ def test_ignore_compute_value(self): self.assertEqual(len(output.domain.metas), 1) self.assertIs(output.domain.metas[0].compute_value.variable, ma4) # renamed + def test_explicit_closing(self): + w = self.widget + self.send_signal(w.Inputs.additional_data, self.iris[:1], 0) + self.send_signal(w.Inputs.additional_data, self.iris[1:2], 1) + self.send_signal(w.Inputs.additional_data, self.iris[2:3], 2) + + def assert_output_equal(expected: np.ndarray): + out = self.get_output(w.Outputs.data) + assert_array_equal(out.X, expected) + + assert_output_equal(self.iris[:3].X) + self.send_signal(w.Inputs.additional_data, None, 1) + assert_output_equal(self.iris[:3:2].X) + self.send_signal(w.Inputs.additional_data, self.iris[1:2], 1) + assert_output_equal(self.iris[:3].X) + self.send_signal(w.Inputs.additional_data, + w.Inputs.additional_data.closing_sentinel, 1) + assert_output_equal(self.iris[:3:2].X) + self.send_signal(w.Inputs.additional_data, self.iris[1:2], 1) + assert_output_equal(np.vstack((self.iris[:3:2].X, self.iris[1:2].X))) + + def test_concatenate_feature_attributes(self): + attrs = {"foo": "bar"} + + # case 1 + iris1 = Table("iris")[:5] + iris2 = Table("iris")[5:10] + iris1.domain.attributes[0].attributes = attrs.copy() + + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.domain.attributes[0].attributes, attrs) + self.assertEqual(iris1.domain.attributes[0].attributes, attrs) + self.assertEqual(iris2.domain.attributes[0].attributes, {}) + + # case 2 + iris1 = Table("iris")[:5] + iris2 = Table("iris")[5:10] + iris2.domain.attributes[0].attributes = attrs.copy() + + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.domain.attributes[0].attributes, attrs) + self.assertEqual(iris1.domain.attributes[0].attributes, {}) + self.assertEqual(iris2.domain.attributes[0].attributes, attrs) + + attrs = {"foo": "foo", "bar": "bar"} + + # case 3 + iris1 = Table("iris")[:5] + iris2 = Table("iris")[5:10] + iris1.domain.attributes[0].attributes = {"bar": "bar"} + iris2.domain.attributes[0].attributes = attrs.copy() + + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.domain.attributes[0].attributes, attrs) + self.assertEqual(iris1.domain.attributes[0].attributes, {"bar": "bar"}) + self.assertEqual(iris2.domain.attributes[0].attributes, attrs) + + # case 4 + iris1 = Table("iris")[:5] + iris2 = Table("iris")[5:10] + iris1.domain.attributes[0].attributes = attrs.copy() + iris2.domain.attributes[0].attributes = {"bar": "bar"} + + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.domain.attributes[0].attributes, attrs) + self.assertEqual(iris1.domain.attributes[0].attributes, attrs) + self.assertEqual(iris2.domain.attributes[0].attributes, {"bar": "bar"}) + + # case 5 + iris1 = Table("iris")[:5] + iris2 = Table("iris")[5:10] + iris1.domain.attributes[0].attributes = {"foo": "foo"} + iris2.domain.attributes[0].attributes = {"bar": "bar"} + + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.domain.attributes[0].attributes, attrs) + self.assertEqual(iris1.domain.attributes[0].attributes, {"foo": "foo"}) + self.assertEqual(iris2.domain.attributes[0].attributes, {"bar": "bar"}) + + # case 6 + iris1 = Table("iris")[:5, :3] + iris2 = Table("iris")[5:10, 2:] + iris1.domain.attributes[0].attributes = {"foo": "bar"} + iris2.domain.attributes[0].attributes = {"foo": "baz"} + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.domain.attributes[0].attributes, {"foo": "bar"}) + self.assertEqual(output.domain.attributes[2].attributes, {"foo": "baz"}) + self.assertEqual(iris1.domain.attributes[0].attributes, {"foo": "bar"}) + self.assertEqual(iris2.domain.attributes[0].attributes, {"foo": "baz"}) + + def test_concatenate_feature_attributes_warn(self): + iris1 = Table("iris")[:5] + iris2 = Table("iris")[5:10] + iris3 = Table("iris")[10:15] + iris4 = Table("iris")[15:20] + iris1.domain.attributes[0].attributes = {"foo": "bar"} + iris2.domain.attributes[0].attributes = {"foo": "baz"} + iris3.domain.attributes[0].attributes = {"foo": "bar"} + iris4.domain.attributes[0].attributes = {"bar": "baz"} + + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + self.assertTrue(self.widget.Warning.unmergeable_attributes.is_shown()) + self.assertEqual(iris1.domain.attributes[0].attributes, {"foo": "bar"}) + self.assertEqual(iris2.domain.attributes[0].attributes, {"foo": "baz"}) + + self.send_signal(self.widget.Inputs.additional_data, iris3, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.domain.attributes[0].attributes, {"foo": "bar"}) + self.assertEqual(iris1.domain.attributes[0].attributes, {"foo": "bar"}) + self.assertEqual(iris3.domain.attributes[0].attributes, {"foo": "bar"}) + + self.send_signal(self.widget.Inputs.additional_data, iris4, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.domain.attributes[0].attributes, + {"foo": "bar", "bar": "baz"}) + self.assertEqual(iris1.domain.attributes[0].attributes, {"foo": "bar"}) + self.assertEqual(iris4.domain.attributes[0].attributes, {"bar": "baz"}) + + iris1 = Table("iris")[:5] + iris2 = Table("iris")[5:10] + iris1.domain.attributes[0].attributes = {"foo": "bar", "bar": "baz"} + iris2.domain.attributes[0].attributes = {"foo": "baz", "bar": "baz"} + + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + self.assertTrue(self.widget.Warning.unmergeable_attributes.is_shown()) + self.send_signal(self.widget.Inputs.additional_data, None, 1) + self.assertFalse(self.widget.Warning.unmergeable_attributes.is_shown()) + + def test_concatenate_feature_attributes_dict(self): + iris1 = Table("iris")[:5] + iris2 = Table("iris")[5:10] + iris1.domain.attributes[0].attributes = {"foo": {"bar": "baz"}} + iris1.domain.attributes[1].attributes = {"foo": {"bar": "baz"}} + iris2.domain.attributes[0].attributes = {"foo": "bar", "bar": "baz"} + iris2.domain.attributes[1].attributes = {"foo": {"bar": "baz"}} + + self.send_signal(self.widget.Inputs.additional_data, iris1, 0) + self.send_signal(self.widget.Inputs.additional_data, iris2, 1) + output = self.get_output(self.widget.Outputs.data) + self.assertTrue(self.widget.Warning.unmergeable_attributes.is_shown()) + self.assertEqual(output.domain.attributes[0].attributes, + {"foo": "bar", "bar": "baz"}) + self.assertEqual(output.domain.attributes[1].attributes, + {"foo": {"bar": "baz"}}) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owcontinuize.py b/Orange/widgets/data/tests/test_owcontinuize.py index f717eebfdb5..70bcd843ca3 100644 --- a/Orange/widgets/data/tests/test_owcontinuize.py +++ b/Orange/widgets/data/tests/test_owcontinuize.py @@ -1,13 +1,19 @@ # Test methods with long descriptive names can omit docstrings -# pylint: disable=missing-docstring,unsubscriptable-object +# pylint: disable=missing-docstring,unsubscriptable-object,protected-access import unittest +from unittest.mock import Mock, patch import numpy as np +from AnyQt.QtCore import Qt, QModelIndex, QItemSelectionModel +from AnyQt.QtTest import QSignalSpy + +from orangewidget.tests.base import GuiTest +from orangewidget.utils.itemmodels import SeparatedListDelegate from Orange.data import Table, DiscreteVariable, ContinuousVariable, Domain -from Orange.preprocess import transformation -from Orange.widgets.data import owcontinuize -from Orange.widgets.data.owcontinuize import OWContinuize, WeightedIndicator +from Orange.widgets.data.owcontinuize import OWContinuize, DefaultKey, \ + ContinuousOptions, Normalize, Continuize, DiscreteOptions, \ + ContDomainModel, DefaultContModel, ListViewSearch, DefaultId from Orange.widgets.tests.base import WidgetTest @@ -19,267 +25,844 @@ def test_empty_data(self): """No crash on empty data""" data = Table("iris") widget = self.widget - widget.multinomial_treatment = 1 self.send_signal(self.widget.Inputs.data, data) - widget.unconditional_commit() - imp_data = self.get_output(self.widget.Outputs.data) - np.testing.assert_equal(imp_data.X, data.X) - np.testing.assert_equal(imp_data.Y, data.Y) + widget.commit.now() - widget.continuous_treatment = 1 self.send_signal(self.widget.Inputs.data, Table.from_domain(data.domain)) - widget.unconditional_commit() - imp_data = self.get_output(self.widget.Outputs.data) - self.assertEqual(len(imp_data), 0) + widget.commit.now() + self.assertIsNone(self.get_output(self.widget.Outputs.data)) self.send_signal(self.widget.Inputs.data, None) - widget.unconditional_commit() - imp_data = self.get_output(self.widget.Outputs.data) - self.assertIsNone(imp_data) + widget.commit.now() + self.assertIsNone(self.get_output(self.widget.Outputs.data)) def test_continuous(self): table = Table("housing") self.send_signal(self.widget.Inputs.data, table) - self.widget.unconditional_commit() + self.widget.commit.now() def test_one_column_equal_values(self): - """ - No crash on a column with equal values and with selected option - normalize by standard deviation. - GH-2144 - """ table = Table("iris") - table = table[:, 1] - table[:] = 42.0 + table = table[:, 1].copy() + with table.unlocked(): + table[:] = 42.0 self.send_signal(self.widget.Inputs.data, table) # Normalize.NormalizeBySD self.widget.continuous_treatment = 2 - self.widget.unconditional_commit() + self.widget.commit.now() def test_one_column_nan_values_normalize_sd(self): - """ - No crash on a column with NaN values and with selected option - normalize by standard deviation (Not the same issue which is - tested above). - GH-2144 - """ table = Table("iris") - table[:, 2] = np.NaN + with table.unlocked(): + table[:, 2] = np.nan self.send_signal(self.widget.Inputs.data, table) # Normalize.NormalizeBySD self.widget.continuous_treatment = 2 - self.widget.unconditional_commit() + self.widget.commit.now() + table = Table("iris") - table[1, 2] = np.NaN + with table.unlocked(): + table[1, 2] = np.nan self.send_signal(self.widget.Inputs.data, table) - self.widget.unconditional_commit() + self.widget.commit.now() def test_one_column_nan_values_normalize_span(self): - """ - No crash on a column with NaN values and with selected option - normalize by span. - GH-2144 - """ table = Table("iris") - table[:, 2] = np.NaN + with table.unlocked(): + table[:, 2] = np.nan self.send_signal(self.widget.Inputs.data, table) # Normalize.NormalizeBySpan self.widget.continuous_treatment = 1 - self.widget.unconditional_commit() + self.widget.commit.now() + table = Table("iris") - table[1, 2] = np.NaN + with table.unlocked(): + table[1, 2] = np.nan self.send_signal(self.widget.Inputs.data, table) - self.widget.unconditional_commit() - - def test_disable_normalize_sparse(self): - def assert_enabled(enabled): - for button, (method, supports_sparse) in \ - zip(buttons, w.continuous_treats): - self.assertEqual(button.isEnabled(), enabled or supports_sparse, - msg=f"Error in {method}") - buttons[w.Normalize.Leave].click() - buttons[w.Normalize.Standardize].click() - + self.widget.commit.now() + + def test_commit_calls_prepare_output(self): + # This test ensures that commit returns the result of _prepare_output, + # so further tests can just check the latter. If this is changed, the + # test will fail, which is OK - test can be removed, but other tests + # then have to check the output and not just _prepare_output. + out = object() + self.widget._prepare_output = lambda: out + self.widget.Outputs.data.send = Mock() + self.widget.commit.now() + self.widget.Outputs.data.send.assert_called_with(out) + + def test_check_unsuppoerted_sparse_continuous(self): + # This test checks response at two points: + # - when scaling sparse data with a method that does not support it, + # the wiget must show an error and output nothing + # - the above is tested via method _unsupported_sparse, so we also + # directly check this method w = self.widget - buttons = w.controls.continuous_treatment.buttons + hints = w.cont_var_hints iris = Table("iris") - sparse_iris = iris.to_sparse() + iris = iris.transform(Domain(iris.domain[:2], + iris.domain.class_var, + iris.domain.attributes[2:])) + sparse_iris = iris.to_sparse(sparse_class=True, sparse_metas=True) + + for attr in (iris.domain.attributes[0], iris.domain.metas[0]): + for key in (DefaultKey, attr.name): + hints[DefaultKey] = Normalize.Leave + for hints[key], desc in ContinuousOptions.items(): + if desc.id_ == Normalize.Default: + continue + msg = f"at {attr} = {desc.label}, " \ + + ("default" if key is DefaultKey else key) + + # input dense + self.send_signal(w.Inputs.data, iris) + self.assertFalse(w._unsupported_sparse(), msg) + self.assertFalse(w.Error.unsupported_sparse.is_shown(), msg) + self.assertIsNotNone(self.get_output(w.Outputs.data), msg) + + # input sparse + self.send_signal(w.Inputs.data, sparse_iris) + self.assertIsNot(w._unsupported_sparse(), + desc.supports_sparse, msg) + self.assertIsNot(w.Error.unsupported_sparse.is_shown(), + desc.supports_sparse, msg) + if desc.supports_sparse: + self.assertIsNotNone(self.get_output(w.Outputs.data), + msg) + else: + self.assertIsNone(self.get_output(w.Outputs.data), + msg) + self.send_signal(w.Inputs.data, None) + self.assertFalse(w.Error.unsupported_sparse.is_shown(), + msg) + del hints[key] + + def test_check_unsuppoerted_sparse_discrete(self): + # This test checks response at two points: + # - when scaling sparse data with a method that does not support it, + # the wiget must show an error and output nothing + # - the above is tested via method _unsupported_sparse, so we also + # directly check this method + w = self.widget + hints = w.disc_var_hints + zoo = Table("zoo") + zoo = zoo.transform(Domain(zoo.domain.attributes[:2], + None, + zoo.domain.attributes[2:])) + sparse_zoo = zoo.to_sparse(sparse_metas=True) # input dense - self.send_signal(w.Inputs.data, iris) - assert_enabled(True) - self.assertEqual(w.continuous_treatment, w.Normalize.Standardize) + for attr in (zoo.domain[0], zoo.domain.metas[0]): + for key in (DefaultKey, attr.name): + hints[DefaultKey] = Continuize.Leave + for hints[key], desc in DiscreteOptions.items(): + if desc.id_ == Continuize.Default: + continue + msg = f"at {key} = {desc.label}, " \ + + ("default" if key is DefaultKey else key) + + self.send_signal(w.Inputs.data, zoo) + self.assertFalse(w._unsupported_sparse(), msg) + self.assertFalse(w.Error.unsupported_sparse.is_shown(), msg) + self.assertIsNotNone(self.get_output(w.Outputs.data), msg) + + self.send_signal(w.Inputs.data, sparse_zoo) + self.assertIsNot(w._unsupported_sparse(), + desc.supports_sparse, msg) + self.assertIsNot(w.Error.unsupported_sparse.is_shown(), + desc.supports_sparse, msg) + if desc.supports_sparse: + self.assertIsNotNone(self.get_output(w.Outputs.data), msg) + else: + self.assertIsNone(self.get_output(w.Outputs.data), msg) + self.send_signal(w.Inputs.data, None) + self.assertFalse(w.Error.unsupported_sparse.is_shown(), msg) + del hints[key] + + def test_update_cont_radio_buttons(self): + w = self.widget + w.disc_var_hints[DefaultKey] = Continuize.AsOrdinal + w.disc_var_hints["chest pain"] \ + = w.disc_var_hints["rest ECG"] \ + = Continuize.Remove + w.disc_var_hints["exerc ind ang"] = Continuize.FirstAsBase + + w.cont_var_hints[DefaultKey] = Normalize.Center + w.cont_var_hints["cholesterol"] = Normalize.Scale + + self.send_signal(w.Inputs.data, Table("heart_disease")) + + dview = w.disc_view + dmod = dview.model() + dselmod = dview.selectionModel() + dgroup = w.disc_group + + with patch.object(w, "_update_radios") as upd: + w._on_var_selection_changed(dview) + upd.assert_not_called() + + dselmod.select(dmod.index(1, 0), + QItemSelectionModel.ClearAndSelect) # chest_pain + self.assertEqual(dgroup.checkedId(), Continuize.Remove) + self.assertTrue(dgroup.button(99).isEnabled()) + + dselmod.select(dmod.index(2, 0), + QItemSelectionModel.ClearAndSelect) # blood sugar + self.assertEqual(dgroup.checkedId(), Continuize.Default) + + dselmod.select(dmod.index(3, 0), + QItemSelectionModel.ClearAndSelect) # rest ECG + self.assertEqual(dgroup.checkedId(), Continuize.Remove) + + dselmod.select(dmod.index(4, 0), + QItemSelectionModel.ClearAndSelect) # exerc ind ang + self.assertEqual(dgroup.checkedId(), Continuize.FirstAsBase) + + dselmod.select(dmod.index(3, 0), + QItemSelectionModel.Select) # read ECG and exerc ind ang + self.assertEqual(dgroup.checkedId(), -1) + + dview.select_default() + self.assertEqual(dgroup.checkedId(), Continuize.AsOrdinal) + self.assertFalse(dgroup.button(99).isEnabled()) + + cview = w.cont_view + cmod = cview.model() + cselmod = cview.selectionModel() + cgroup = w.cont_group + + cselmod.select(cmod.index(2, 0), + QItemSelectionModel.ClearAndSelect) # cholesterol + self.assertEqual(cgroup.checkedId(), Normalize.Scale) + self.assertEqual(dgroup.checkedId(), Continuize.AsOrdinal) + self.assertTrue(cgroup.button(99).isEnabled()) + + cview.select_default() + self.assertEqual(cgroup.checkedId(), Normalize.Center) + self.assertEqual(dgroup.checkedId(), Continuize.AsOrdinal) + self.assertFalse(cgroup.button(99).isEnabled()) + + w._uncheck_all_buttons(cgroup) + self.assertEqual(cgroup.checkedId(), -1) + self.assertEqual(dgroup.checkedId(), Continuize.AsOrdinal) + + w._uncheck_all_buttons(dgroup) + self.assertEqual(dgroup.checkedId(), -1) + + def test_update_disc_radio_buttons_mixed(self): + def select(xs): + dselmod.select(dmod.index(xs[0], 0), + QItemSelectionModel.ClearAndSelect) + for x in xs[1:]: + dselmod.select(dmod.index(x, 0), + QItemSelectionModel.Select) - # input sparse - self.send_signal(w.Inputs.data, sparse_iris) - self.assertEqual(w.continuous_treatment, w.Normalize.Scale) - assert_enabled(False) - self.assertEqual(w.continuous_treatment, w.Normalize.Leave) + w = self.widget + dview = w.disc_view + dmod = dview.model() + dselmod = dview.selectionModel() + dgroup = w.disc_group + + domain = Domain( + [DiscreteVariable(x, values=["0", "1"]) for x in "abc"], + DiscreteVariable("d", values=["0", "1"]), + [DiscreteVariable(x, values=["0", "1"]) for x in "efg"]) + data = Table.from_list(domain, [[1] * 7] * 2) + + w.disc_var_hints = {DefaultKey: Continuize.FirstAsBase} + self.send_signal(w.Inputs.data, data) + select([1, 4, 8]) + self.assertEqual(dgroup.checkedId(), -1) + select([1]) + self.assertEqual(dgroup.checkedId(), DefaultId) + select([4, 8]) + self.assertEqual(dgroup.checkedId(), Continuize.Leave) + + w.disc_var_hints = {DefaultKey: Continuize.FirstAsBase, + "b": Continuize.Leave} + self.send_signal(w.Inputs.data, data) + select([1, 4, 8]) + self.assertEqual(dgroup.checkedId(), Continuize.Leave) + + w.disc_var_hints = {DefaultKey: Continuize.FirstAsBase, + "e": DefaultId, "d": DefaultId} + self.send_signal(w.Inputs.data, data) + select([1, 4, 8]) + self.assertEqual(dgroup.checkedId(), DefaultId) + + def test_update_cont_radio_buttons_mixed(self): + def select(xs): + cselmod.select(cmod.index(xs[0], 0), + QItemSelectionModel.ClearAndSelect) + for x in xs[1:]: + cselmod.select(cmod.index(x, 0), + QItemSelectionModel.Select) + + w = self.widget + cview = w.cont_view + cmod = cview.model() + cselmod = cview.selectionModel() + cgroup = w.cont_group + + domain = Domain([ContinuousVariable(x) for x in "abc"], + ContinuousVariable("d"), + [ContinuousVariable(x) for x in "efg"]) + data = Table.from_list(domain, [[1] * 7] * 2) + + w.cont_var_hints = {DefaultKey: Normalize.Center} + self.send_signal(w.Inputs.data, data) + select([1, 4, 8]) + self.assertEqual(cgroup.checkedId(), -1) + select([1]) + self.assertEqual(cgroup.checkedId(), DefaultId) + select([4, 8]) + self.assertEqual(cgroup.checkedId(), Normalize.Leave) + + w.cont_var_hints = {DefaultKey: Normalize.Center, + "b": Normalize.Leave} + self.send_signal(w.Inputs.data, data) + select([1, 4, 8]) + self.assertEqual(cgroup.checkedId(), Normalize.Leave) + + w.cont_var_hints = {DefaultKey: Normalize.Center, + "e": DefaultId, "d": DefaultId} + self.send_signal(w.Inputs.data, data) + select([1, 4, 8]) + self.assertEqual(cgroup.checkedId(), DefaultId) + + def test_set_hints_on_new_data(self): + w = self.widget + domain = Domain([ContinuousVariable(c) for c in "abc"] + + [DiscreteVariable("m", values=tuple("xy"))], + ContinuousVariable("d"), + [ContinuousVariable(c) for c in "ef"]) + data = Table.from_list(domain, [[0] * 6]) + + w.cont_var_hints["b"] = Normalize.Leave + w.cont_var_hints["f"] = Normalize.Normalize11 + w.cont_var_hints["x"] = Normalize.Normalize11 - # remove data self.send_signal(w.Inputs.data, None) - assert_enabled(True) + self.send_signal(w.Inputs.data, data) + + model = w.cont_view.model() + self.assertEqual(model.index(0, 0).data(model.HintRole), + ("preset", False)) + self.assertEqual(model.index(1, 0).data(model.HintRole), + (ContinuousOptions[Normalize.Leave].short_desc, True)) + self.assertEqual(model.index(5, 0).data(model.HintRole), + (ContinuousOptions[Normalize.Normalize11].short_desc, True)) + self.assertNotIn("x", w.cont_var_hints) + + def test_reset_hints(self): + w = self.widget + domain = Domain([ContinuousVariable(c) for c in "abc"] + + [DiscreteVariable("m", values=tuple("xy"))], + ContinuousVariable("d"), + [ContinuousVariable(c) for c in "ef"]) + data = Table.from_list(domain, [[0] * 6]) + + w.cont_var_hints[DefaultKey] = Normalize.Center + w.cont_var_hints["b"] = Normalize.Leave + w.cont_var_hints["f"] = Normalize.Normalize11 + w.cont_var_hints["x"] = Normalize.Normalize11 + w.disc_var_hints[DefaultKey] = Continuize.Indicators + w.cont_var_hints["m"] = Continuize.Remove + + self.send_signal(w.Inputs.data, data) + w._on_reset_hints() + + self.assertEqual(w.cont_var_hints[DefaultKey], Normalize.Leave) + self.assertEqual(w.disc_var_hints[DefaultKey], Continuize.FirstAsBase) + + def test_change_hints_disc(self): + w = self.widget + w.disc_var_hints[DefaultKey] = Continuize.AsOrdinal + w.disc_var_hints["chest pain"] \ + = w.disc_var_hints["rest ECG"] \ + = Continuize.Remove + w.disc_var_hints["exerc ind ang"] = Continuize.FirstAsBase + + dview = w.disc_view + dmod = dview.model() + dselmod = dview.selectionModel() + dgroup = w.disc_group + + self.send_signal(w.Inputs.data, Table("heart_disease")) + self.assertEqual( + dmod.index(3, 0).data(dmod.HintRole), + (DiscreteOptions[Continuize.Remove].short_desc, True)) + + dselmod.select(dmod.index(1, 0), + QItemSelectionModel.ClearAndSelect) # chest pain + dselmod.select(dmod.index(4, 0), + QItemSelectionModel.Select) # exerc ind ang + dgroup.button(Continuize.AsOrdinal).setChecked(True) + dgroup.idClicked.emit(Continuize.AsOrdinal) + + self.assertFalse("gender" in w.disc_var_hints) + self.assertEqual(w.disc_var_hints["chest pain"], Continuize.AsOrdinal) + self.assertEqual(w.disc_var_hints["exerc ind ang"], Continuize.AsOrdinal) + self.assertEqual(w.disc_var_hints["rest ECG"], Continuize.Remove) + + dselmod.select(dmod.index(1, 0), + QItemSelectionModel.ClearAndSelect) # chest pain + dselmod.select(dmod.index(0, 0), + QItemSelectionModel.Select) # gender + dgroup.button(99).setChecked(True) + dgroup.idClicked.emit(99) + self.assertFalse("chest pain" in w.disc_var_hints) + self.assertFalse("gender" in w.disc_var_hints) + self.assertEqual(w.disc_var_hints["rest ECG"], Continuize.Remove) + + self.assertEqual(dmod.index(0, 0).data(dmod.HintRole), + ("preset", False)) + self.assertEqual( + dmod.index(3, 0).data(dmod.HintRole), + (DiscreteOptions[Continuize.Remove].short_desc, True)) + + dview.select_default() + dgroup.button(Continuize.AsOrdinal).setChecked(True) + dgroup.idClicked.emit(Continuize.AsOrdinal) + self.assertEqual(w.disc_var_hints[DefaultKey], Continuize.AsOrdinal) + + def test_change_hints_disc_class_meta(self): + w = self.widget + dview = w.disc_view + dmod = dview.model() + dselmod = dview.selectionModel() + dgroup = w.disc_group + + domain = Domain([DiscreteVariable(x, values=["0", "1"]) for x in "abc"], + DiscreteVariable("d", values=["0", "1"]), + [DiscreteVariable(x, values=["0", "1"]) for x in "efg"]) + data = Table.from_list(domain, [[1] * 7] * 2) + self.send_signal(w.Inputs.data, data) + + dselmod.select(dmod.index(1, 0), + QItemSelectionModel.ClearAndSelect) # attribute b + dselmod.select(dmod.index(4, 0), + QItemSelectionModel.Select) # meta e + dselmod.select(dmod.index(8, 0), + QItemSelectionModel.Select) # class d + dgroup.button(Continuize.Remove).setChecked(True) + dgroup.idClicked.emit(Continuize.Remove) + self.assertEqual(w.disc_var_hints["b"], Continuize.Remove) + self.assertEqual(w.disc_var_hints["e"], Continuize.Remove) + self.assertEqual(w.disc_var_hints["d"], Continuize.Remove) + self.assertEqual( + dmod.index(1, 0).data(dmod.HintRole), + (DiscreteOptions[Continuize.Remove].short_desc, True)) + self.assertEqual( + dmod.index(4, 0).data(dmod.HintRole), + (DiscreteOptions[Continuize.Remove].short_desc, True)) + self.assertEqual( + dmod.index(8, 0).data(dmod.HintRole), + (DiscreteOptions[Continuize.Remove].short_desc, True)) + + dgroup.button(DefaultId).setChecked(True) + dgroup.idClicked.emit(DefaultId) + self.assertNotIn("b", w.disc_var_hints) + self.assertEqual(w.disc_var_hints["e"], DefaultId) + self.assertEqual(w.disc_var_hints["d"], DefaultId) + self.assertEqual( + dmod.index(1, 0).data(dmod.HintRole), + (DiscreteOptions[DefaultId].short_desc, False)) + self.assertEqual( + dmod.index(4, 0).data(dmod.HintRole), + (DiscreteOptions[DefaultId].short_desc, True)) + self.assertEqual( + dmod.index(8, 0).data(dmod.HintRole), + (DiscreteOptions[DefaultId].short_desc, True)) + + dgroup.button(Continuize.Leave).setChecked(True) + dgroup.idClicked.emit(Continuize.Leave) + self.assertEqual(w.disc_var_hints["b"], Continuize.Leave) + self.assertNotIn("e", w.disc_var_hints) + self.assertNotIn("d", w.disc_var_hints) + self.assertEqual( + dmod.index(1, 0).data(dmod.HintRole), + (DiscreteOptions[Continuize.Leave].short_desc, True)) + self.assertEqual( + dmod.index(4, 0).data(dmod.HintRole), + (DiscreteOptions[Continuize.Leave].short_desc, False)) + self.assertEqual( + dmod.index(8, 0).data(dmod.HintRole), + (DiscreteOptions[Continuize.Leave].short_desc, False)) + + def test_change_hints_cont(self): + w = self.widget + w.cont_var_hints[DefaultKey] = Normalize.Center + w.cont_var_hints["cholesterol"] = Normalize.Scale + + self.send_signal(w.Inputs.data, Table("heart_disease")) + + cview = w.cont_view + cmod = cview.model() + cselmod = cview.selectionModel() + cgroup = w.cont_group + + cselmod.select(cmod.index(2, 0), + QItemSelectionModel.ClearAndSelect) # cholesterol + cselmod.select(cmod.index(3, 0), + QItemSelectionModel.Select) # max HR + cgroup.button(Normalize.Normalize11).setChecked(True) + cgroup.idClicked.emit(Normalize.Normalize11) + + self.assertFalse("age" in w.cont_var_hints) + self.assertEqual(w.cont_var_hints["cholesterol"], Normalize.Normalize11) + self.assertEqual(w.cont_var_hints["max HR"], Normalize.Normalize11) + + cselmod.select(cmod.index(2, 0), + QItemSelectionModel.ClearAndSelect) # cholesterol + cselmod.select(cmod.index(0, 0), + QItemSelectionModel.Select) # age + cgroup.button(99).setChecked(True) + cgroup.idClicked.emit(99) + self.assertFalse("age" in w.cont_var_hints) + self.assertFalse("cholesterol" in w.cont_var_hints) + self.assertEqual(w.cont_var_hints["max HR"], Normalize.Normalize11) + + self.assertEqual(cmod.index(0, 0).data(cmod.HintRole), + ("preset", False)) + self.assertEqual( + cmod.index(3, 0).data(cmod.HintRole), + (ContinuousOptions[Normalize.Normalize11].short_desc, True)) + + def test_change_hints_cont_class_meta(self): + w = self.widget + cview = w.cont_view + cmod = cview.model() + cselmod = cview.selectionModel() + cgroup = w.cont_group + + domain = Domain([ContinuousVariable(x) for x in "abc"], + ContinuousVariable("d"), + [ContinuousVariable(x) for x in "efg"]) + data = Table.from_list(domain, [[1] * 7] * 2) + self.send_signal(w.Inputs.data, data) + + cselmod.select(cmod.index(1, 0), + QItemSelectionModel.ClearAndSelect) # attribute b + cselmod.select(cmod.index(4, 0), + QItemSelectionModel.Select) # meta e + cselmod.select(cmod.index(8, 0), + QItemSelectionModel.Select) # class d + cgroup.button(Normalize.Center).setChecked(True) + cgroup.idClicked.emit(Normalize.Center) + self.assertEqual(w.cont_var_hints["b"], Normalize.Center) + self.assertEqual(w.cont_var_hints["e"], Normalize.Center) + self.assertEqual(w.cont_var_hints["d"], Normalize.Center) + self.assertEqual( + cmod.index(1, 0).data(cmod.HintRole), + (ContinuousOptions[Normalize.Center].short_desc, True)) + self.assertEqual( + cmod.index(4, 0).data(cmod.HintRole), + (ContinuousOptions[Normalize.Center].short_desc, True)) + self.assertEqual( + cmod.index(8, 0).data(cmod.HintRole), + (ContinuousOptions[Normalize.Center].short_desc, True)) + + cgroup.button(DefaultId).setChecked(True) + cgroup.idClicked.emit(DefaultId) + self.assertNotIn("b", w.cont_var_hints) + self.assertEqual(w.cont_var_hints["e"], DefaultId) + self.assertEqual(w.cont_var_hints["d"], DefaultId) + self.assertEqual( + cmod.index(1, 0).data(cmod.HintRole), + (ContinuousOptions[DefaultId].short_desc, False)) + self.assertEqual( + cmod.index(4, 0).data(cmod.HintRole), + (ContinuousOptions[DefaultId].short_desc, True)) + self.assertEqual( + cmod.index(8, 0).data(cmod.HintRole), + (ContinuousOptions[DefaultId].short_desc, True)) + + cgroup.button(Normalize.Leave).setChecked(True) + cgroup.idClicked.emit(Normalize.Leave) + self.assertEqual(w.cont_var_hints["b"], Normalize.Leave) + self.assertNotIn("e", w.cont_var_hints) + self.assertNotIn("d", w.cont_var_hints) + self.assertEqual( + cmod.index(1, 0).data(cmod.HintRole), + (ContinuousOptions[Normalize.Leave].short_desc, True)) + self.assertEqual( + cmod.index(4, 0).data(cmod.HintRole), + (ContinuousOptions[Normalize.Leave].short_desc, False)) + self.assertEqual( + cmod.index(8, 0).data(cmod.HintRole), + (ContinuousOptions[Normalize.Leave].short_desc, False)) + + def test_is_attr_and_default(self): + w = self.widget + a, b, d, e, f = (ContinuousVariable(x) for x in "abdef") + c, g = (DiscreteVariable(x, values=["0", "1"]) for x in "cg") + domain = Domain([a, b, c], d, [e, f, g]) + data = Table.from_list(domain, [[1] * 7] * 2) + self.send_signal(w.Inputs.data, data) + + self.assertTrue(w.is_attr(a)) + self.assertTrue(w.is_attr(b)) + self.assertTrue(w.is_attr(c)) + self.assertFalse(w.is_attr(d)) + self.assertFalse(w.is_attr(e)) + self.assertFalse(w.is_attr(f)) + self.assertFalse(w.is_attr(g)) + + self.assertEqual(w.default_for_var(a), DefaultId) + self.assertEqual(w.default_for_var(c), DefaultId) + self.assertEqual(w.default_for_var(d), Normalize.Leave) + self.assertEqual(w.default_for_var(e), Normalize.Leave) + self.assertEqual(w.default_for_var(g), Continuize.Leave) + + def test_hint_for_var(self): + w = self.widget + c1, c2, c3, c4, c5 = (ContinuousVariable(f"c{x}") for x in range(1, 6)) + d1, d2, d3, d4 = (DiscreteVariable(f"d{x}", values=["0", "1"]) for x in range(1, 5)) + domain = Domain([c1, c2, d1, d2], c5, [c3, c4, d3, d4]) + data = Table.from_list(domain, [[1] * 7] * 2) + w.cont_var_hints = { + DefaultKey: Normalize.Center, + "c1": Normalize.Scale, + "c3": Normalize.Standardize, + } + w.disc_var_hints = { + DefaultKey: Continuize.FrequentAsBase, + "d1": Continuize.Remove, + "d3": Continuize.Indicators + } + self.send_signal(w.Inputs.data, data) + + self.assertEqual(w._hint_for_var(c1), Normalize.Scale) + self.assertEqual(w._hint_for_var(c2), Normalize.Center) + self.assertEqual(w._hint_for_var(c3), Normalize.Standardize) + self.assertEqual(w._hint_for_var(c4), Normalize.Leave) + + self.assertEqual(w._hint_for_var(d1), Continuize.Remove) + self.assertEqual(w._hint_for_var(d2), Continuize.FrequentAsBase) + self.assertEqual(w._hint_for_var(d3), Continuize.Indicators) + self.assertEqual(w._hint_for_var(d4), Continuize.Leave) + + def test_transformations(self): + domain = Domain([DiscreteVariable(c, values="abc") + for c in ("default", "leave", "first", "frequent", + "one-hot", "remove-if", "remove", "ordinal", + "normordinal")], + DiscreteVariable("y", values="abc"), + [ContinuousVariable(c) + for c in ("cdefault", "cleave", + "cstandardize", "ccenter", "cscale", + "cnormalize11", "cnormalize01")] + ) + data = Table.from_list(domain, + [[x] * 17 for x in range(3)] + [[2] * 17]) - # input sparse - buttons[w.Normalize.Normalize11].click() - self.send_signal(w.Inputs.data, sparse_iris) - self.assertEqual(w.continuous_treatment, w.Normalize.Leave) - assert_enabled(False) + w = self.widget + w.disc_var_hints = { + var.name: id_ + for var, id_ in zip(domain.attributes, DiscreteOptions) + if id_ != 99 + } + w.disc_var_hints[DefaultKey] = Continuize.FrequentAsBase + + w.cont_var_hints = { + var.name: id_ + for var, id_ in zip(domain.metas, ContinuousOptions) + if id_ != 99 + } + w.cont_var_hints[DefaultKey] = Normalize.Center + + self.send_signal(w.Inputs.data, data) + outp = self.get_output(w.Outputs.data) - # input dense - self.send_signal(w.Inputs.data, iris) - assert_enabled(True) + np.testing.assert_almost_equal( + outp.X, + [[1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0], + [0, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0.5], + [0, 0, 2, 0, 1, 0, 0, 0, 0, 1, 2, 1], + [0, 0, 2, 0, 1, 0, 0, 0, 0, 1, 2, 1], + ] + ) + np.testing.assert_almost_equal( + outp.Y, + [0, 1, 2, 2] + ) + np.testing.assert_almost_equal( + outp.metas, + [[0, 0, -1.50755672, -1.25, 0, -1, 0], + [1, 1, -0.30151134, -0.25, 1.20604538, 0, 0.5], + [2, 2, 0.90453403, 0.75, 2.41209076, 1, 1], + [2, 2, 0.90453403, 0.75, 2.41209076, 1, 1], + ] + ) - def test_migrate_settings_to_v2(self): - Normalize = OWContinuize.Normalize + def test_send_report(self): + w = self.widget + self.send_signal(w.Inputs.data, Table("heart_disease")) + self.widget.send_report() + + w.disc_var_hints[DefaultKey] = Continuize.AsOrdinal + w.disc_var_hints["chest pain"] \ + = w.disc_var_hints["rest ECG"] \ + = Continuize.Remove + w.disc_var_hints["exerc ind ang"] = Continuize.FirstAsBase + + self.send_signal(w.Inputs.data, Table("heart_disease")) + self.widget.send_report() + + w.cont_var_hints[DefaultKey] = Normalize.Center + w.cont_var_hints["cholesterol"] = Normalize.Scale + + self.send_signal(w.Inputs.data, Table("heart_disease")) + self.widget.send_report() + w.continuize_class = True + w.disc_var_hints[DefaultKey] = Continuize.AsOrdinal + w.disc_var_hints["chest pain"] \ + = w.disc_var_hints["rest ECG"] \ + = Continuize.Remove + w.disc_var_hints["exerc ind ang"] = Continuize.FirstAsBase + + w.cont_var_hints[DefaultKey] = Normalize.Center + w.cont_var_hints["cholesterol"] = Normalize.Scale + + self.send_signal(w.Inputs.data, Table("heart_disease")) + self.widget.send_report() + + def test_migrate_settings_to_v3(self): + # why not?, pylint: disable=use-dict-literal widget = self.create_widget( OWContinuize, stored_settings=dict(continuous_treatment=0)) - self.assertEqual(widget.continuous_treatment, Normalize.Leave) + self.assertEqual(widget.cont_var_hints[DefaultKey], + Normalize.Leave) widget = self.create_widget( OWContinuize, stored_settings=dict(continuous_treatment=1, zero_based=True)) - self.assertEqual(widget.continuous_treatment, Normalize.Normalize01) + self.assertEqual(widget.cont_var_hints[DefaultKey], + Normalize.Normalize01) widget = self.create_widget( OWContinuize, stored_settings=dict(continuous_treatment=1, zero_based=False)) - self.assertEqual(widget.continuous_treatment, Normalize.Normalize11) + self.assertEqual(widget.cont_var_hints[DefaultKey], + Normalize.Normalize11) widget = self.create_widget( OWContinuize, stored_settings=dict(continuous_treatment=2)) - self.assertEqual(widget.continuous_treatment, Normalize.Standardize) - - def test_normalizations(self): - buttons = self.widget.controls.continuous_treatment.buttons - Normalize = self.widget.Normalize - - domain = Domain([ContinuousVariable(name) for name in "xyz"]) - col0 = np.arange(0, 10, 2).reshape(5, 1) - col1 = np.ones((5, 1)) - col2 = np.arange(-2, 3).reshape(5, 1) - means = np.array([4, 1, 0]) - sds = np.sqrt(np.array([16 + 4 + 0 + 4 + 16, 5, 4 + 1 + 0 + 1 + 4]) / 5) - - x = np.hstack((col0, col1, col2)) - data = Table.from_numpy(domain, x) - self.send_signal(OWContinuize.Inputs.data, data) - - buttons[Normalize.Leave].click() - out = self.get_output(self.widget.Outputs.data) - np.testing.assert_equal(out.X, x) - - buttons[Normalize.Standardize].click() - out = self.get_output(self.widget.Outputs.data) - np.testing.assert_almost_equal(out.X, (x - means) / sds) - - buttons[Normalize.Center].click() - out = self.get_output(self.widget.Outputs.data) - np.testing.assert_almost_equal(out.X, x - means) - - buttons[Normalize.Scale].click() - out = self.get_output(self.widget.Outputs.data) - np.testing.assert_almost_equal(out.X, x / sds) - - buttons[Normalize.Normalize01].click() - out = self.get_output(self.widget.Outputs.data) - col = (np.arange(5) / 4).reshape(5, 1) - np.testing.assert_almost_equal( - out.X, - np.hstack((col, np.zeros((5, 1)), col)) - ) + self.assertEqual(widget.cont_var_hints[DefaultKey], + Normalize.Standardize) - buttons[Normalize.Normalize11].click() - out = self.get_output(self.widget.Outputs.data) - col = (np.arange(5) / 2).reshape(5, 1) - 1 - np.testing.assert_almost_equal( - out.X, - np.hstack((col, np.zeros((5, 1)), col)) + widget = self.create_widget( + OWContinuize, + stored_settings=dict(multinomial_treatment=2) ) + self.assertEqual(widget.disc_var_hints[DefaultKey], + Continuize.Indicators) - def test_send_report(self): - self.widget.send_report() + def test_migrate_settings_to_v3_class_treatment(self): + # why not?, pylint: disable=use-dict-literal + domain = Domain([ContinuousVariable(c) for c in "abc"], + DiscreteVariable("y")) + data = Table.from_list(domain, [[0] * 4] * 2) + + widget = self.create_widget( + OWContinuize, + stored_settings=dict(multinomial_treatment=4, + class_treatment=3) + ) + self.send_signal(widget.Inputs.data, data) + self.assertEqual(widget.disc_var_hints["y"], Continuize.Indicators) + self.assertEqual(widget.disc_var_hints[DefaultKey], Continuize.Remove) + widget = self.create_widget( + OWContinuize, + stored_settings=dict(multinomial_treatment=4, + class_treatment=0) + ) + self.send_signal(widget.Inputs.data, data) + self.assertNotIn("y", widget.disc_var_hints) + self.assertEqual(widget.disc_var_hints[DefaultKey], 4) -class TestOWContinuizeUtils(unittest.TestCase): - def test_dummy_coding_zero_based(self): - var = DiscreteVariable("foo", values=tuple("abc")) - - varb, varc = owcontinuize.dummy_coding(var) - - self.assertEqual(varb.name, "foo=b") - self.assertIsInstance(varb.compute_value, transformation.Indicator) - self.assertEqual(varb.compute_value.value, 1) - self.assertIs(varb.compute_value.variable, var) - - self.assertEqual(varc.name, "foo=c") - self.assertIsInstance(varc.compute_value, transformation.Indicator) - self.assertEqual(varc.compute_value.value, 2) - self.assertIs(varc.compute_value.variable, var) - - def test_dummy_coding_base_value(self): - var = DiscreteVariable("foo", values=tuple("abc")) - - varb, varc = owcontinuize.dummy_coding(var, base_value=0) - - self.assertEqual(varb.name, "foo=b") - self.assertIsInstance(varb.compute_value, transformation.Indicator) - self.assertEqual(varb.compute_value.value, 1) - self.assertEqual(varc.name, "foo=c") - self.assertIsInstance(varc.compute_value, transformation.Indicator) - self.assertEqual(varc.compute_value.value, 2) - - varb, varc = owcontinuize.dummy_coding(var, base_value=1) - - self.assertEqual(varb.name, "foo=a") - self.assertIsInstance(varb.compute_value, transformation.Indicator) - self.assertEqual(varb.compute_value.value, 0) - self.assertEqual(varc.name, "foo=c") - self.assertIsInstance(varc.compute_value, transformation.Indicator) - self.assertEqual(varc.compute_value.value, 2) - - def test_one_hot_coding(self): - var = DiscreteVariable("foo", values=tuple("abc")) - - new_vars = owcontinuize.one_hot_coding(var) - for i, (c, nvar) in enumerate(zip("abc", new_vars)): - self.assertEqual(nvar.name, f"foo={c}") - self.assertIsInstance(nvar.compute_value, transformation.Indicator) - self.assertEqual(nvar.compute_value.value, i) - self.assertIs(nvar.compute_value.variable, var) - - -class TestWeightedIndicator(unittest.TestCase): - def test_equality(self): - disc1 = DiscreteVariable("d1", values=tuple("abc")) - disc1a = DiscreteVariable("d1", values=tuple("abc")) - disc2 = DiscreteVariable("d2", values=tuple("abc")) - assert disc1 == disc1a - - t1 = WeightedIndicator(disc1, 0, 1) - t1a = WeightedIndicator(disc1a, 0, 1) - t2 = WeightedIndicator(disc2, 0, 1) - self.assertEqual(t1, t1) - self.assertEqual(t1, t1a) - self.assertNotEqual(t1, t2) - - self.assertEqual(hash(t1), hash(t1a)) - self.assertNotEqual(hash(t1), hash(t2)) - - t1 = WeightedIndicator(disc1, 0, 1) - t1a = WeightedIndicator(disc1a, 1, 1) - self.assertNotEqual(t1, t1a) - self.assertNotEqual(hash(t1), hash(t1a)) - - t1 = WeightedIndicator(disc1, 0, 1) - t1a = WeightedIndicator(disc1a, 0, 2) - self.assertNotEqual(t1, t1a) - self.assertNotEqual(hash(t1), hash(t1a)) + widget = self.create_widget( + OWContinuize, + stored_settings=dict(multinomial_treatment=Continuize.Remove) + ) + self.send_signal(widget.Inputs.data, data) + self.assertNotIn("y", widget.disc_var_hints) + self.assertEqual(widget.disc_var_hints[DefaultKey], Continuize.Remove) + + +class TestModelsAndViews(GuiTest): + def test_contmodel(self): + domain = Domain([ContinuousVariable(c) for c in "abc"], + ContinuousVariable("y")) + model = ContDomainModel(ContinuousVariable) + model.set_domain(domain) + + ind = model.index(0, 0) + self.assertEqual(ind.data()[0], "a") + self.assertEqual(ind.data(model.FilterRole)[0], "a") + self.assertIsNone(ind.data(Qt.ToolTipRole)) + + ind = model.index(1, 0) + model.setData(ind, ("mega encoding", True), model.HintRole) + self.assertEqual(ind.data(), ("b", "mega encoding", True)) + self.assertEqual(ind.data(model.HintRole), ("mega encoding", True)) + self.assertIn("b", ind.data(model.FilterRole)) + self.assertIn("mega encoding", ind.data(model.FilterRole)) + self.assertNotIn("bmega encoding", ind.data(model.FilterRole)) + self.assertIsNone(ind.data(Qt.ToolTipRole)) + + ind = model.index(3, 0) # separator + self.assertIsNone(ind.data()) + self.assertIsNone(ind.data(model.HintRole)) + self.assertIsNone(ind.data(model.FilterRole)) + + def test_defaultcontmodel(self): + model = DefaultContModel() + self.assertEqual(1, model.rowCount(QModelIndex())) + self.assertEqual(1, model.columnCount(QModelIndex())) + ind = model.index(0, 0) + spy = QSignalSpy(model.dataChanged) + model.setMethod("mega encoding") + self.assertEqual(spy[0][0].row(), 0) + self.assertEqual(ind.data(), "General preset: mega encoding") + self.assertIsNotNone(ind.data(Qt.DecorationRole)) + self.assertIsNotNone(ind.data(Qt.ToolTipRole)) + + +class TestListViewDelegate(unittest.TestCase): + def test_displaytext(self): + delegate = ListViewSearch.Delegate() + self.assertEqual(delegate.displayText(("a", "foo", False), Mock()), + "a: foo") + self.assertEqual(delegate.displayText(("a", "foo", True), Mock()), + "a: foo") + self.assertIsNone(delegate.displayText(None, Mock())) + + @patch.object(SeparatedListDelegate, "initStyleOption") + def test_bold(self, _): + delegate = ListViewSearch.Delegate() + option = Mock() + index = Mock() + index.data = lambda role: ("foo", True) \ + if role == ContDomainModel.HintRole else None + delegate.initStyleOption(option, index) + option.font.setBold.assert_called_with(True) + index.data = lambda role: ("foo", False) \ + if role == ContDomainModel.HintRole else None + delegate.initStyleOption(option, index) + option.font.setBold.assert_called_with(False) + index.data = lambda role: None \ + if role == ContDomainModel.HintRole else None + delegate.initStyleOption(option, index) + option.font.setBold.assert_called_with(False) if __name__ == "__main__": diff --git a/Orange/widgets/data/tests/test_owcorrelations.py b/Orange/widgets/data/tests/test_owcorrelations.py index 6502136868b..d68118b63f4 100644 --- a/Orange/widgets/data/tests/test_owcorrelations.py +++ b/Orange/widgets/data/tests/test_owcorrelations.py @@ -7,12 +7,13 @@ import numpy.testing as npt from AnyQt.QtCore import Qt +from scipy.stats import pearsonr from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable from Orange.tests import test_filename from Orange.widgets.data.owcorrelations import ( OWCorrelations, KMeansCorrelationHeuristic, CorrelationRank, - CorrelationType + CorrelationType, mock_data ) from Orange.widgets.tests.base import WidgetTest from Orange.widgets.tests.utils import simulate @@ -38,7 +39,7 @@ def test_input_data_cont(self): self.wait_until_finished() n_attrs = len(self.data_cont.domain.attributes) self.process_events() - self.assertEqual(self.widget.vizrank.rank_model.columnCount(), 3) + self.assertEqual(self.widget.vizrank.rank_model.columnCount(), 4) self.assertEqual(self.widget.vizrank.rank_model.rowCount(), n_attrs * (n_attrs - 1) / 2) self.send_signal(self.widget.Inputs.data, None) @@ -48,9 +49,9 @@ def test_input_data_cont(self): def test_input_data_disc(self): """Check correlation table for dataset with discrete attributes""" self.send_signal(self.widget.Inputs.data, self.data_disc) - self.assertTrue(self.widget.Warning.not_enough_vars.is_shown()) + self.assertTrue(self.widget.Error.not_enough_vars.is_shown()) self.send_signal(self.widget.Inputs.data, None) - self.assertFalse(self.widget.Warning.not_enough_vars.is_shown()) + self.assertFalse(self.widget.Error.not_enough_vars.is_shown()) def test_input_data_mixed(self): """Check correlation table for dataset with continuous and discrete @@ -60,7 +61,7 @@ def test_input_data_mixed(self): n_attrs = len([a for a in domain.attributes if a.is_continuous]) self.wait_until_finished() self.process_events() - self.assertEqual(self.widget.vizrank.rank_model.columnCount(), 3) + self.assertEqual(self.widget.vizrank.rank_model.columnCount(), 4) self.assertEqual(self.widget.vizrank.rank_model.rowCount(), n_attrs * (n_attrs - 1) / 2) @@ -70,9 +71,9 @@ def test_input_data_one_feature(self): self.wait_until_finished() self.process_events() self.assertEqual(self.widget.vizrank.rank_model.columnCount(), 0) - self.assertTrue(self.widget.Warning.not_enough_vars.is_shown()) + self.assertTrue(self.widget.Error.not_enough_vars.is_shown()) self.send_signal(self.widget.Inputs.data, None) - self.assertFalse(self.widget.Warning.not_enough_vars.is_shown()) + self.assertFalse(self.widget.Error.not_enough_vars.is_shown()) def test_input_data_one_instance(self): """Check correlation table for dataset with one instance""" @@ -81,9 +82,9 @@ def test_input_data_one_instance(self): self.process_events() self.assertEqual(self.widget.vizrank.rank_model.columnCount(), 0) self.assertFalse(self.widget.Information.removed_cons_feat.is_shown()) - self.assertTrue(self.widget.Warning.not_enough_inst.is_shown()) + self.assertTrue(self.widget.Error.not_enough_inst.is_shown()) self.send_signal(self.widget.Inputs.data, None) - self.assertFalse(self.widget.Warning.not_enough_inst.is_shown()) + self.assertFalse(self.widget.Error.not_enough_inst.is_shown()) def test_input_data_with_constant_features(self): """Check correlation table for dataset with constant columns""" @@ -112,7 +113,7 @@ def test_input_data_with_constant_features(self): self.wait_until_finished() self.process_events() self.assertEqual(self.widget.vizrank.rank_model.columnCount(), 0) - self.assertTrue(self.widget.Warning.not_enough_vars.is_shown()) + self.assertTrue(self.widget.Error.not_enough_vars.is_shown()) self.assertTrue(self.widget.Information.removed_cons_feat.is_shown()) self.send_signal(self.widget.Inputs.data, None) @@ -130,7 +131,60 @@ def test_input_data_cont_target(self): data = self.housing[:5, 13:] self.send_signal(self.widget.Inputs.data, data) - self.assertTrue(self.widget.Warning.not_enough_vars.is_shown()) + self.assertTrue(self.widget.Error.not_enough_vars.is_shown()) + + def test_feature_model_imputation(self): + data = mock_data() + attributes = data.domain.attributes[:-1] + class_var = data.domain.attributes[-1] + data = data.transform(Domain(attributes, class_var)) + self.send_signal(data) + assert self.widget.feature is not class_var, "No imputation?" + self.assertEqual(self.widget.feature.name, class_var.name) + + self.widget.feature = self.widget.actual_data.domain.attributes[-1] + assert self.widget.feature is not attributes[-1], "No imputation?" + self.assertEqual(self.widget.feature.name, attributes[-1].name) + + def test_imputation(self): + self.send_signal(mock_data()) + self.wait_until_finished() + self.process_events() + s = 1 / 2 + t = 1 / 3 + exp = np.array( + [[1, 0, 0, 1, 0, 0], # d 0 + [0, 1, 1, 0, 1, 1], # e 1 + [1, 0, 0, 1, 0, 0], # f 2 + [1, 0, s, 1, 0, s], # g 3 + [1, 0, s, 1, 0, s], # h 4 + [t, 0, t, 1, 0, t], # i 5 + [0, s, s, s, s, 1]] # j 6 + ) + names = "defghij" + npt.assert_almost_equal(self.widget.actual_data.X, exp.T) + + model = self.widget.vizrank.rank_model + ind = model.index + for i in range(model.rowCount()): + pair = [var.name + for var in ind(i, 0).data(CorrelationRank._AttrRole)] + r, _ = pearsonr(*(exp[names.index(name)] for name in pair)) + self.assertAlmostEqual( + ind(i, 0).data(CorrelationRank.CorrRole), r, + places=7, + msg=f"Mismatch for {pair}") + + def test_no_imputation(self): + data = mock_data() + self.send_signal(data) + self.assertFalse(np.any(np.isnan(self.widget.actual_data.X))) + + self.widget.controls.impute_missing.setChecked(False) + npt.assert_almost_equal(self.widget.actual_data.X, data.X[:, 1:]) + + self.widget.controls.impute_missing.setChecked(True) + self.assertFalse(np.any(np.isnan(self.widget.actual_data.X))) def test_output_data(self): """Check dataset on output""" @@ -158,10 +212,15 @@ def test_output_correlations(self): self.assertIsInstance(correlations, Table) self.assertEqual(len(correlations), 6) self.assertEqual(len(correlations.domain.metas), 2) - self.assertListEqual(["Correlation", "FDR"], + self.assertListEqual(["Correlation", "uncorrected p", "FDR"], [m.name for m in correlations.domain.attributes]) - array = np.array([[0.963, 0], [0.872, 0], [0.818, 0], [-0.421, 0], - [-0.357, 0.000009], [-0.109, 0.1827652]]) + array = np.array( + [[ 9.62757097e-01, 5.77666099e-86, 3.46599659e-85], + [ 8.71754157e-01, 1.03845406e-47, 3.11536219e-47], + [ 8.17953633e-01, 2.31484915e-37, 4.62969830e-37], + [-4.20516096e-01, 8.42936639e-08, 1.26440496e-07], + [-3.56544090e-01, 7.52389096e-06, 9.02866915e-06], + [-1.09369250e-01, 1.82765215e-01, 1.82765215e-01]]) npt.assert_almost_equal(correlations.X, array) def test_input_changed(self): @@ -312,19 +371,55 @@ def setUp(self): self.vizrank = CorrelationRank(None) self.vizrank.attrs = self.attrs - def test_compute_score(self): + def test_compute_score_iris(self): self.vizrank.master = Mock() - self.vizrank.master.cont_data = self.iris + self.vizrank.master.actual_data = self.iris self.vizrank.master.correlation_type = CorrelationType.PEARSON npt.assert_almost_equal(self.vizrank.compute_score((1, 0)), [-0.1094, -0.1094, 0.1828], 4) + def test_compute_score_nans(self): + self.vizrank.master = Mock() + data = mock_data()[:, 1:] + self.vizrank.master.actual_data = data + self.vizrank.attrs = data.domain.attributes + self.vizrank.master.correlation_type = CorrelationType.PEARSON + npt.assert_almost_equal( + self.vizrank.compute_score((1, 0)), [-1, -1, 0]) + npt.assert_almost_equal( + self.vizrank.compute_score((2, 0)), [-1, 1, 0]) + + col0, col3 = data.X[:, 0], data.X[:, 3] + r, p = pearsonr(col0, col3) + npt.assert_almost_equal( + self.vizrank.compute_score((3, 0)), [-abs(r), r, p]) + + npt.assert_almost_equal( + self.vizrank.compute_score((4, 0)), [-1, 1, 0]) + + npt.assert_almost_equal( + self.vizrank.compute_score((5, 4)), [-1, 1, 0]) + + # Test that we return inf and nan if there are less than 2 values + npt.assert_almost_equal( + self.vizrank.compute_score((6, 4)), [np.inf, np.nan, np.nan]) + + npt.assert_almost_equal( + self.vizrank.compute_score((6, 5)), [np.inf, np.nan, np.nan]) + + # I suppose that p-value is 1 because we have only two samples, + # and B(0, 0) = 1. This is good because it distinguishes this case + # from the one above + npt.assert_almost_equal( + self.vizrank.compute_score((6, 0)), [-1, -1, 1]) + + def test_row_for_state(self): row = self.vizrank.row_for_state((-0.2, 0.2, 0.1), (1, 0)) self.assertEqual(row[0].data(Qt.DisplayRole), "+0.200") self.assertEqual(row[0].data(CorrelationRank.PValRole), 0.1) self.assertEqual(row[1].data(Qt.DisplayRole), self.attrs[0].name) - self.assertEqual(row[2].data(Qt.DisplayRole), self.attrs[1].name) + self.assertEqual(row[3].data(Qt.DisplayRole), self.attrs[1].name) def test_iterate_states(self): self.assertListEqual(list(self.vizrank.iterate_states(None)), @@ -358,7 +453,7 @@ def test_get_clusters_of_attributes(self): clusters = self.heuristic.get_clusters_of_attributes() # results depend on scikit-learn k-means implementation result = sorted([c.instances for c in clusters]) - self.assertListEqual([[0], [1, 2, 3, 4, 5, 6, 7], [8]], + self.assertListEqual([[0, 3, 5], [1, 2, 6, 7], [4, 8]], result) def test_get_states(self): @@ -374,6 +469,28 @@ def test_get_states_one_cluster(self): self.assertEqual(len(states), 1) self.assertSetEqual(states, {(0, 1)}) + def test_impute_means(self): + arr = np.array([[1, 2, np.nan, 3, 4, np.nan], + [5, 7, np.nan, np.nan, np.nan, np.nan], + [1, 2, 3, 4, 5, 6], + [np.nan, np.nan, np.nan, np.nan, np.nan, np.nan]]) + exp = np.array([[1, 2, 2.5, 3, 4, 2.5], + [5, 7, 6, 6, 6, 6], + [1, 2, 3, 4, 5, 6], + [0, 0, 0, 0, 0, 0]]) + KMeansCorrelationHeuristic._impute_means(arr) + np.testing.assert_almost_equal(arr, exp) + + def test_nans(self): + data = Table("iris") + with data.unlocked(data.X): + data.X[0, 0] = np.nan + data.X[:, 1] = np.nan + heuristic = KMeansCorrelationHeuristic(data) + clusters = heuristic.get_clusters_of_attributes() + result = sorted([c.instances for c in clusters]) + self.assertListEqual(result, [[0, 2, 3], [1]]), + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owcreateclass.py b/Orange/widgets/data/tests/test_owcreateclass.py index 7419bbae45e..20439c47b23 100644 --- a/Orange/widgets/data/tests/test_owcreateclass.py +++ b/Orange/widgets/data/tests/test_owcreateclass.py @@ -5,6 +5,7 @@ import numpy as np +from orangewidget.settings import Context from Orange.data import Table, StringVariable, DiscreteVariable, Domain from Orange.widgets.data.owcreateclass import ( OWCreateClass, @@ -23,55 +24,85 @@ def test_map_by_substring(self): np.testing.assert_equal( map_by_substring(self.arr, ["abc", "a", "bc", ""], - case_sensitive=True, match_beginning=False), + case_sensitive=True, match_beginning=False, + regular_expressions=False), [0, 1, 2, 0, 3]) np.testing.assert_equal( map_by_substring(self.arr, ["abc", "a", "Bc", ""], - case_sensitive=True, match_beginning=False), + case_sensitive=True, match_beginning=False, + regular_expressions=False), [0, 1, 3, 0, 3]) np.testing.assert_equal( map_by_substring(self.arr, ["abc", "a", "Bc", ""], - case_sensitive=False, match_beginning=False), + case_sensitive=False, match_beginning=False, + regular_expressions=False), [0, 1, 2, 0, 3]) np.testing.assert_equal( map_by_substring(self.arr, ["abc", "a", "bc", ""], - case_sensitive=False, match_beginning=True), + case_sensitive=False, match_beginning=True, + regular_expressions=False), [0, 1, 2, 3, 3]) np.testing.assert_equal( - map_by_substring(self.arr, ["", ""], False, False), + map_by_substring(self.arr, ["", ""], False, False, False), 0) self.assertTrue(np.all(np.isnan( - map_by_substring(self.arr, [], False, False)))) + map_by_substring(self.arr, [], False, False, False)))) def test_map_by_substring_with_map_values(self): np.testing.assert_equal( map_by_substring(self.arr, ["abc", "a", "bc", ""], case_sensitive=True, match_beginning=False, + regular_expressions=False, map_values=None), [0, 1, 2, 0, 3]) np.testing.assert_equal( map_by_substring(self.arr, ["abc", "a", "bc", ""], case_sensitive=True, match_beginning=False, + regular_expressions=False, map_values=[0, 1, 2, 3]), [0, 1, 2, 0, 3]) np.testing.assert_equal( map_by_substring(self.arr, ["abc", "a", "bc", ""], case_sensitive=True, match_beginning=False, + regular_expressions=False, map_values=[1, 0, 3, 2]), [1, 0, 3, 1, 2]) np.testing.assert_equal( map_by_substring(self.arr, ["abc", "a", "bc", ""], case_sensitive=True, match_beginning=False, + regular_expressions=False, map_values=[1, 1, 0, 0]), [1, 1, 0, 1, 0]) + def test_map_by_regular_expression(self): + # arr: ["abcd", "aa", "bcd", "rabc", "x"]) + np.testing.assert_equal( + map_by_substring(self.arr, + ["a.*C", "a", "b.*c", ""], + case_sensitive=True, match_beginning=False, + regular_expressions=True), + [1, 1, 2, 1, 3]) + np.testing.assert_equal( + map_by_substring(self.arr, + ["a.*C", "a", "b.*c", ""], + case_sensitive=False, match_beginning=False, + regular_expressions=True), + [0, 1, 2, 0, 3]) + np.testing.assert_equal( + map_by_substring(self.arr, + ["a.*C", "a", "b.*c", ""], + case_sensitive=False, match_beginning=False, + regular_expressions=True, + map_values=[1, 0, 3, 2]), + [1, 0, 3, 1, 2]) + @staticmethod def test_unique_in_order_mapping(): u, m = unique_in_order_mapping([]) @@ -105,11 +136,13 @@ def test_value_from_string_substring(self): with patch('Orange.widgets.data.owcreateclass.map_by_substring') as mbs: trans.transform(self.arr) - a, patterns, case_sensitive, match_beginning, map_values = mbs.call_args[0] + a, patterns, case_sensitive, match_beginning, \ + regular_expressions, map_values = mbs.call_args[0] np.testing.assert_equal(a, self.arr) self.assertEqual(patterns, self.patterns) self.assertFalse(case_sensitive) self.assertFalse(match_beginning) + self.assertFalse(regular_expressions) self.assertIsNone(map_values) trans.transform(arr2) @@ -125,16 +158,27 @@ def test_value_string_substring_flags(self): with patch('Orange.widgets.data.owcreateclass.map_by_substring') as mbs: trans.case_sensitive = True trans.transform(self.arr) - case_sensitive, match_beginning = mbs.call_args[0][-3:-1] + case_sensitive, match_beginning, regular_expressions = mbs.call_args[0][-4:-1] self.assertTrue(case_sensitive) self.assertFalse(match_beginning) + self.assertFalse(regular_expressions) trans.case_sensitive = False trans.match_beginning = True trans.transform(self.arr) - case_sensitive, match_beginning = mbs.call_args[0][-3:-1] + case_sensitive, match_beginning, regular_expressions = mbs.call_args[0][-4:-1] self.assertFalse(case_sensitive) self.assertTrue(match_beginning) + self.assertFalse(regular_expressions) + + trans.case_sensitive = False + trans.match_beginning = False + trans.regular_expressions = True + trans.transform(self.arr) + case_sensitive, match_beginning, regular_expressions = mbs.call_args[0][-4:-1] + self.assertFalse(case_sensitive) + self.assertFalse(match_beginning) + self.assertTrue(regular_expressions) def test_value_from_discrete_substring(self): trans = ValueFromDiscreteSubstring( @@ -146,40 +190,58 @@ def test_value_from_discrete_substring_flags(self): DiscreteVariable("x", values=self.arr), self.patterns) with patch('Orange.widgets.data.owcreateclass.map_by_substring') as mbs: trans.case_sensitive = True - a, patterns, case_sensitive, match_beginning, map_values = mbs.call_args[0] + a, patterns, case_sensitive, match_beginning, \ + regular_expressions, map_values = mbs.call_args[0] np.testing.assert_equal(a, self.arr) self.assertEqual(patterns, self.patterns) self.assertTrue(case_sensitive) self.assertFalse(match_beginning) + self.assertFalse(regular_expressions) self.assertIsNone(map_values) trans.case_sensitive = False trans.match_beginning = True - a, patterns, case_sensitive, match_beginning, map_values = mbs.call_args[0] + a, patterns, case_sensitive, match_beginning, \ + regular_expressions, map_values = mbs.call_args[0] np.testing.assert_equal(a, self.arr) self.assertEqual(patterns, self.patterns) self.assertFalse(case_sensitive) self.assertTrue(match_beginning) + self.assertFalse(regular_expressions) self.assertIsNone(map_values) arr2 = self.arr[::-1] trans.variable = DiscreteVariable("x", values=arr2) - a, patterns, case_sensitive, match_beginning, map_values = mbs.call_args[0] + a, patterns, case_sensitive, match_beginning, \ + regular_expressions, map_values = mbs.call_args[0] np.testing.assert_equal(a, arr2) self.assertEqual(patterns, self.patterns) self.assertFalse(case_sensitive) self.assertTrue(match_beginning) + self.assertFalse(regular_expressions) self.assertIsNone(map_values) patt2 = self.patterns[::-1] trans.patterns = patt2 - a, patterns, case_sensitive, match_beginning, map_values = mbs.call_args[0] + a, patterns, case_sensitive, match_beginning, \ + regular_expressions, map_values = mbs.call_args[0] np.testing.assert_equal(a, arr2) self.assertEqual(patterns, patt2) self.assertFalse(case_sensitive) self.assertTrue(match_beginning) + self.assertFalse(regular_expressions) self.assertIsNone(map_values) + trans.case_sensitive = False + trans.match_beginning = False + trans.regular_expressions = True + trans.patterns = patt2 + a, patterns, case_sensitive, match_beginning, \ + regular_expressions, map_values = mbs.call_args[0] + self.assertFalse(case_sensitive) + self.assertFalse(match_beginning) + self.assertTrue(regular_expressions) + def test_valuefromstringsubstring_equality(self): str1 = StringVariable("d1") str1a = StringVariable("d1") @@ -211,6 +273,15 @@ def test_valuefromstringsubstring_equality(self): self.assertNotEqual(t1, t1a) self.assertNotEqual(hash(t1), hash(t1a)) + t1 = ValueFromStringSubstring(str1, ["abc", "def"], True, False, None) + t1a = ValueFromStringSubstring(str1a, ["abc", "def"], True, False, np.array([1, 2])) + self.assertNotEqual(t1, t1a) + self.assertNotEqual(hash(t1), hash(t1a)) + + t1 = ValueFromStringSubstring(str1, ["abc", "def"], True, False, None, True) + t1a = ValueFromStringSubstring(str1a, ["abc", "def"], True, False, None, False) + self.assertNotEqual(t1, t1a) + self.assertNotEqual(hash(t1), hash(t1a)) def test_valuefromsdiscretesubstring_equality(self): str1 = DiscreteVariable("d1", values=("abc", "ghi")) @@ -256,6 +327,7 @@ def _set_attr(self, attr, widget=None): attr_combo.activated.emit(idx) def _check_counts(self, expected): + self.assertEqual(len(self.widget.counts), len(expected)) for countrow, expectedrow in zip(self.widget.counts, expected): for count, exp in zip(countrow, expectedrow): self.assertEqual(count.text(), exp) @@ -305,12 +377,50 @@ def test_string_data(self): widget.apply() outdata = self.get_output(self.widget.Outputs.data) - classes = outdata.get_column_view("class")[0] - attr = outdata.get_column_view("name")[0].astype(str) + classes = outdata.get_column("class") + attr = outdata.get_column("name").astype(str) has_a = np.char.find(attr, "a") != -1 np.testing.assert_equal(classes[has_a], 0) np.testing.assert_equal(classes[~has_a], 1) + def test_check_patterns(self): + widget = self.widget + widget.line_edits[0][1].setText("[a") + + widget.regular_expressions = False + self.assertIsNone(widget.invalid_patterns()) + + widget.regular_expressions = True + self.assertEqual(widget.invalid_patterns(), "[a") + + def test_check_re_counts(self): + widget = self.widget + self.send_signal(self.widget.Inputs.data, self.zoo) + + widget.line_edits[0][1].setText("a.*a") + widget.line_edits[1][1].setText("b") + widget.add_row() + + self._check_counts([["0", ""], ["34", ""], ["67", ""]]) + + widget.controls.regular_expressions.click() + self._check_counts([["45", ""], ["30", "+ 4"], ["26", ""]]) + widget.apply() + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + + widget.line_edits[1][1].setText("b[") + self._check_counts([["", ""], ["", ""], ["", ""]]) + self.assertTrue(widget.Error.invalid_regular_expression.is_shown()) + self.assertIn("b[", str(widget.Error.invalid_regular_expression)) + widget.apply() + self.assertIsNone(self.get_output(self.widget.Outputs.data)) + + widget.line_edits[1][1].setText("b") + self._check_counts([["45", ""], ["30", "+ 4"], ["26", ""]]) + self.assertFalse(widget.Error.invalid_regular_expression.is_shown()) + widget.apply() + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + def _set_repeated(self): widget = self.widget widget.line_edits[0][0].setText("repeated") @@ -385,8 +495,8 @@ def _check_thal(self): widget.apply() outdata = self.get_output(self.widget.Outputs.data) self.assertEqual(outdata.domain.class_var.values, ("Cls1", "Cls2")) - classes = outdata.get_column_view("class")[0] - attr = outdata.get_column_view("thal")[0] + classes = outdata.get_column("class") + attr = outdata.get_column("thal") thal = self.heart.domain["thal"] reversable = np.equal(attr, thal.values.index("reversable defect")) fixed = np.equal(attr, thal.values.index("fixed defect")) @@ -394,7 +504,7 @@ def _check_thal(self): np.testing.assert_equal(classes[fixed], 1) self.assertTrue(np.all(np.isnan(classes[~(reversable | fixed)]))) - def test_flow_and_context_handling(self): + def test_flow(self): widget = self.widget self.send_signal(self.widget.Inputs.data, self.heart) self._test_default_rules() @@ -402,7 +512,7 @@ def test_flow_and_context_handling(self): widget.apply() outdata = self.get_output(self.widget.Outputs.data) self.assertEqual(outdata.domain.class_var.values, ("C1", )) - classes = outdata.get_column_view("class")[0] + classes = outdata.get_column("class") np.testing.assert_equal(classes, 0) thal = self.heart.domain["thal"] @@ -426,8 +536,8 @@ def test_flow_and_context_handling(self): widget.apply() outdata = self.get_output(self.widget.Outputs.data) self.assertEqual(outdata.domain.class_var.values, ("C1", "C2")) - classes = outdata.get_column_view("class")[0] - attr = outdata.get_column_view("gender")[0] + classes = outdata.get_column("class") + attr = outdata.get_column("gender") female = np.equal(attr, gender.values.index("female")) np.testing.assert_equal(classes[female], 0) # pylint: disable=invalid-unary-operand-type @@ -436,27 +546,6 @@ def test_flow_and_context_handling(self): self._set_attr(thal) self._check_thal() - prev_rules = widget.rules - self.send_signal(self.widget.Inputs.data, self.zoo) - self.assertIsNot(widget.rules, prev_rules) - - self.send_signal(self.widget.Inputs.data, self.heart) - self._check_thal() - - # Check that sending None as data does not ruin the context, and that - # the empty context does not match the true one later - self.send_signal(self.widget.Inputs.data, None) - self.assertIsNot(widget.rules, prev_rules) - - self.send_signal(self.widget.Inputs.data, self.heart) - self._check_thal() - - self.send_signal(self.widget.Inputs.data, self.no_attributes) - self.assertIsNot(widget.rules, prev_rules) - - self.send_signal(self.widget.Inputs.data, self.heart) - self._check_thal() - def test_add_remove_lines(self): widget = self.widget self.send_signal(self.widget.Inputs.data, self.heart) @@ -482,7 +571,7 @@ def test_add_remove_lines(self): widget.remove_buttons[1].click() self._check_counts([["117", ""], ["166", "+ 117"], ["18", "+ 117"], - ["", ""], ["", ""]]) + ["", ""]]) self.assertEqual([lab.text() for _, lab in widget.line_edits], ["eversa", "a", "c", "b"]) @@ -504,16 +593,22 @@ def _transformer_flags(): widget.apply() outdata = self.get_output(self.widget.Outputs.data) transformer = outdata.domain.class_var.compute_value - return transformer.case_sensitive, transformer.match_beginning + return (transformer.case_sensitive, + transformer.match_beginning, + transformer.regular_expressions) widget = self.widget self.send_signal(self.widget.Inputs.data, self.heart) - self.assertEqual(_transformer_flags(), (False, False)) + self.assertEqual(_transformer_flags(), (False, False, False)) widget.controls.case_sensitive.click() - self.assertEqual(_transformer_flags(), (True, False)) + self.assertEqual(_transformer_flags(), (True, False, False)) widget.controls.case_sensitive.click() widget.controls.match_beginning.click() - self.assertEqual(_transformer_flags(), (False, True)) + self.assertEqual(_transformer_flags(), (False, True, False)) + widget.controls.regular_expressions.click() + # match_beginning is set to True, but a False is passed because + # we have regular expressions + self.assertEqual(_transformer_flags(), (False, False, True)) def test_report(self): """Report does not crash""" @@ -541,7 +636,7 @@ def test_bad_class_name(self): def assertError(class_name, class_name_empty, class_name_duplicated, is_out): widget.class_name = class_name widget.apply() - output = self.get_output("Data") + output = self.get_output() self.assertEqual(widget.Error.class_name_empty.is_shown(), class_name_empty) self.assertEqual(widget.Error.class_name_duplicated.is_shown(), class_name_duplicated) self.assertEqual(output is not None, is_out) @@ -575,6 +670,33 @@ def test_same_class(self): self.get_output(widget2.Outputs.data, widget=widget2).domain.class_var ) + def test_migrate_settings_1_2(self): + settings = {"__version__": 1, "context_settings": [Context( + values= { + 'attribute': ('eggs', 101), # not a default selection + 'case_sensitive': (True, -2), + 'class_name': ('myclass', -2), + 'match_beginning': (True, -2), + 'regular_expressions': (True, -2), + 'rules': ({'type': [['cam', 'am'], ['cer', 'er'], ['', '']], + 'eggs': [['de', 'e1'], ['', '']]}, + -2), + '__version__': 1}, + attributes = {'hair': 1, 'feathers': 1, 'eggs': 1, 'type': 1}, + metas = {'name': 3})]} + w = self.create_widget(OWCreateClass, stored_settings=settings) + self.send_signal(w.Inputs.data, self.zoo, widget=w) + self.assertEqual(w.attribute, self.zoo.domain["eggs"]) + self.assertEqual(w.active_rules, [['de', 'e1'], ['', '']]) + self.assertEqual(w.class_name, "myclass") + self.assertTrue(w.case_sensitive) + self.assertTrue(w.match_beginning) + self.assertTrue(w.regular_expressions) + + w.attribute = self.zoo.domain["type"] + self.assertEqual(w.active_rules, [['cam', 'am'], ['cer', 'er'], ['', '']]) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owcreateinstance.py b/Orange/widgets/data/tests/test_owcreateinstance.py index a0fa57d6d11..ffaaf282cc7 100644 --- a/Orange/widgets/data/tests/test_owcreateinstance.py +++ b/Orange/widgets/data/tests/test_owcreateinstance.py @@ -54,6 +54,8 @@ def test_output_append_data(self): self.assertIn("Source ID", [m.name for m in output.domain.metas]) self.assertTupleEqual(output.domain.metas[0].values, ("iris", "created")) + self.assertDictEqual(output.domain.metas[0].attributes, + {"__source_widget": OWCreateInstance}) def _get_init_buttons(self, widget=None): if not widget: @@ -64,7 +66,7 @@ def test_initialize_buttons(self): self.widget.controls.append_to_data.setChecked(False) self.send_signal(self.widget.Inputs.data, self.data) self.send_signal(self.widget.Inputs.reference, self.data[:1]) - output = self.get_output(self.widget.Outputs.data) + output = self.get_output(self.widget.Outputs.data).copy() buttons = self._get_init_buttons() @@ -78,7 +80,8 @@ def test_initialize_buttons(self): buttons[1].click() # Mean output_mean = self.get_output(self.widget.Outputs.data) - output.X = np.round(np.mean(self.data.X, axis=0), 1).reshape(1, 4) + with output.unlocked(): + output.X = np.round(np.mean(self.data.X, axis=0), 1).reshape(1, 4) self.assert_table_equal(output_mean, output) buttons[2].click() # Random @@ -97,15 +100,15 @@ def test_initialize_buttons(self): self.assert_table_equal(output_random, output) def test_initialize_buttons_commit_once(self): - self.widget.commit = self.widget.unconditional_commit = Mock() + self.widget.commit.deferred = self.widget.commit.now = Mock() self.send_signal(self.widget.Inputs.data, self.data) self.send_signal(self.widget.Inputs.reference, self.data[:1]) - self.widget.unconditional_commit.assert_called_once() + self.widget.commit.now.assert_called_once() - self.widget.commit.reset_mock() + self.widget.commit.now.reset_mock() buttons = self._get_init_buttons() buttons[3].click() # Input - self.widget.commit.assert_called_once() + self.widget.commit.deferred.assert_called_once() def test_table(self): self.send_signal(self.widget.Inputs.data, self.data) @@ -149,18 +152,18 @@ def test_missing_values(self): def test_missing_values_reference(self): reference = self.data[:1].copy() - reference[:] = np.nan + with reference.unlocked(): + reference[:] = np.nan self.send_signal(self.widget.Inputs.data, self.data) self.send_signal(self.widget.Inputs.reference, reference) - output1 = self.get_output(self.widget.Outputs.data) - buttons = self._get_init_buttons() - buttons[3].click() # Input + self._get_init_buttons()[3].click() # Input output2 = self.get_output(self.widget.Outputs.data) - self.assert_table_equal(output1, output2) + np.testing.assert_array_equal(output2.X[-1], np.full(4, np.nan)) def test_saved_workflow(self): data = self.data - data.X[:, 0] = np.nan + with data.unlocked(): + data.X[:, 0] = np.nan self.send_signal(self.widget.Inputs.data, data) buttons = self._get_init_buttons() buttons[2].click() # Random @@ -172,18 +175,35 @@ def test_saved_workflow(self): output2 = self.get_output(widget.Outputs.data) self.assert_table_equal(output1, output2) + def test_saved_workflow_missing_values(self): + data = self.data + with data.unlocked(): + data.X[0, 0] = np.nan + data.Y[0] = np.nan + self.send_signal(self.widget.Inputs.data, self.data) + self.send_signal(self.widget.Inputs.reference, data[:1]) + + self._get_init_buttons()[3].click() # Input + output1 = self.get_output(self.widget.Outputs.data) + + settings = self.widget.settingsHandler.pack_data(self.widget) + widget = self.create_widget(OWCreateInstance, stored_settings=settings) + self.send_signal(widget.Inputs.data, data, widget=widget) + output2 = self.get_output(widget.Outputs.data) + self.assert_table_equal(output1, output2) + def test_commit_once(self): - self.widget.commit = self.widget.unconditional_commit = Mock() + self.widget.commit.now = self.widget.commit.deferred = Mock() self.send_signal(self.widget.Inputs.data, self.data) - self.widget.unconditional_commit.assert_called_once() + self.widget.commit.now.assert_called_once() - self.widget.commit.reset_mock() + self.widget.commit.now.reset_mock() self.send_signal(self.widget.Inputs.data, None) - self.widget.commit.assert_called_once() + self.widget.commit.deferred.assert_called_once() - self.widget.commit.reset_mock() + self.widget.commit.deferred.reset_mock() self.send_signal(self.widget.Inputs.data, self.data) - self.widget.commit.assert_called_once() + self.widget.commit.deferred.assert_called_once() def test_context_menu(self): self.send_signal(self.widget.Inputs.data, self.data) @@ -210,6 +230,47 @@ def test_sparse(self): self.send_signal(self.widget.Inputs.data, data) self.send_signal(self.widget.Inputs.reference, data) + def test_cascade_widgets(self): + self.send_signal(self.widget.Inputs.data, self.data) + output = self.get_output(self.widget.Outputs.data) + + widget = self.create_widget(OWCreateInstance) + self.send_signal(widget.Inputs.data, output, widget=widget) + output = self.get_output(widget.Outputs.data, widget=widget) + self.assertEqual(len(output), 152) + self.assertEqual(len(output.domain.metas), 1) + self.assertEqual(output.domain.metas[0].name, "Source ID") + self.assertTrue(all(output.metas[:150, 0] == 0)) + self.assertTrue(all(output.metas[150:, 0] == 1)) + + def test_cascade_widgets_attributes(self): + data = self.data.copy() + data.domain.attributes[0].attributes = \ + {"__source_widget": OWCreateInstance} + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(len(output), 151) + self.assertEqual(len(output.domain.variables), 5) + self.assertEqual(len(output.domain.metas), 0) + + def test_cascade_widgets_class_vars(self): + data = self.data.copy() + data.domain.class_var.attributes = \ + {"__source_widget": OWCreateInstance} + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(len(output), 151) + self.assertEqual(len(output.domain.variables), 5) + self.assertEqual(len(output.domain.metas), 0) + + domain = Domain(data.domain.variables[:3], data.domain.variables[3:]) + data = data.transform(domain) + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(len(output), 151) + self.assertEqual(len(output.domain.variables), 5) + self.assertEqual(len(output.domain.metas), 0) + class TestDiscreteVariableEditor(GuiTest): @classmethod @@ -220,7 +281,7 @@ def setUpClass(cls): def setUp(self): self.callback = Mock() self.editor = DiscreteVariableEditor( - self.parent, ["Foo", "Bar"], self.callback + self.parent, ("Foo", "Bar"), self.callback ) def test_init(self): @@ -242,6 +303,18 @@ def test_set_value(self): self.assertEqual(self.editor._combo.currentText(), "Bar") self.callback.assert_called_once() + def test_edit_missing_value(self): + self.editor._combo.setCurrentText("?") + self.assertTrue(np.isnan(self.editor.value)) + self.assertEqual(self.editor._combo.currentText(), "?") + self.callback.assert_called_once() + + def test_set_missing_value(self): + self.editor.value = 2 + self.assertTrue(np.isnan(self.editor.value)) + self.assertEqual(self.editor._combo.currentText(), "?") + self.callback.assert_called_once() + class TestContinuousVariableEditor(GuiTest): @classmethod @@ -252,7 +325,7 @@ def setUpClass(cls): def setUp(self): self.callback = Mock() data = Table("iris") - values = data.get_column_view(data.domain[0])[0] + values = data.get_column(data.domain[0]) self.min_value = np.min(values) self.max_value = np.max(values) self.editor = ContinuousVariableEditor( @@ -321,6 +394,12 @@ def test_set_value(self): self.assertEqual(self.editor.value, value) self.callback.assert_called_once() + def test_set_missing_value(self): + self.editor.value = np.nan + self.assertEqual(self.editor._slider.value(), self.min_value * 10) + self.assertFalse(np.isfinite(self.editor._spin.value())) + self.assertFalse(np.isfinite(self.editor.value)) + def test_missing_values(self): var = ContinuousVariable("var") self.assertRaises(ValueError, ContinuousVariableEditor, self.parent, @@ -370,6 +449,10 @@ def test_set_value(self): self.callback.assert_called_once() +def _datetime(y, m, d) -> QDateTime: + return QDateTime(QDate(y, m, d), QTime(0, 0)) + + class TestTimeVariableEditor(GuiTest): @classmethod def setUpClass(cls): @@ -384,13 +467,12 @@ def setUp(self): def test_init(self): self.assertEqual(self.editor.value, 0) - self.assertEqual(self.editor._edit.dateTime(), - QDateTime(QDate(1970, 1, 1))) + self.assertEqual(self.editor._edit.dateTime(), _datetime(1970, 1, 1)) self.callback.assert_not_called() def test_edit(self): """ Edit datetimeedit by user. """ - datetime = QDateTime(QDate(2001, 9, 9)) + datetime = _datetime(2001, 9, 9,) self.editor._edit.setDateTime(datetime) self.assertEqual(self.editor.value, 999993600) self.assertEqual(self.editor._edit.dateTime(), datetime) @@ -400,8 +482,7 @@ def test_set_value(self): """ Programmatically set datetimeedit value. """ value = 999993600 self.editor.value = value - self.assertEqual(self.editor._edit.dateTime(), - QDateTime(QDate(2001, 9, 9))) + self.assertEqual(self.editor._edit.dateTime(), _datetime(2001, 9, 9)) self.assertEqual(self.editor.value, value) self.callback.assert_called_once() @@ -412,8 +493,7 @@ def test_have_date_have_time(self): callback ) self.assertEqual(editor.value, 0) - self.assertEqual(self.editor._edit.dateTime(), - QDateTime(QDate(1970, 1, 1), QTime(0, 0, 0))) + self.assertEqual(self.editor._edit.dateTime(), _datetime(1970, 1, 1)) self.callback.assert_not_called() datetime = QDateTime(QDate(2001, 9, 9), QTime(1, 2, 3)) @@ -428,8 +508,7 @@ def test_have_time(self): self.parent, TimeVariable("var", have_time=1), callback ) self.assertEqual(editor.value, 0) - self.assertEqual(self.editor._edit.dateTime(), - QDateTime(QDate(1970, 1, 1), QTime(0, 0, 0))) + self.assertEqual(self.editor._edit.dateTime(), _datetime(1970, 1, 1)) self.callback.assert_not_called() datetime = QDateTime(QDate(1900, 1, 1), QTime(1, 2, 3)) @@ -442,8 +521,7 @@ def test_no_date_no_time(self): callback = Mock() editor = TimeVariableEditor(self.parent, TimeVariable("var"), callback) self.assertEqual(editor.value, 0) - self.assertEqual(self.editor._edit.dateTime(), - QDateTime(QDate(1970, 1, 1), QTime(0, 0, 0))) + self.assertEqual(self.editor._edit.dateTime(), _datetime(1970, 1, 1)) self.callback.assert_not_called() datetime = QDateTime(QDate(2001, 9, 9), QTime(1, 2, 3)) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index 44adc1e4931..4e65b9bba71 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -1,4 +1,5 @@ -# pylint: disable=no-self-use,protected-access,invalid-name,arguments-differ +# pylint: disable=protected-access,invalid-name,arguments-differ +import tempfile import unittest from unittest import mock from contextlib import ExitStack, contextmanager @@ -10,9 +11,10 @@ from typing import Type, TypeVar, Optional import numpy as np +import pandas as pd from numpy.testing import assert_array_equal -from AnyQt.QtCore import QSettings, Qt +from AnyQt.QtCore import QSettings, Qt, QUrl from AnyQt.QtGui import QIcon from AnyQt.QtWidgets import QFileDialog from AnyQt.QtTest import QSignalSpy @@ -26,7 +28,7 @@ from Orange.widgets.tests.base import WidgetTest, GuiTest from Orange.widgets.data import owcsvimport from Orange.widgets.data.owcsvimport import ( - OWCSVFileImport, pandas_to_table, ColumnType, RowSpec, + OWCSVFileImport, pandas_to_table, ColumnType, RowSpec, ImportItem, ) from Orange.widgets.utils.pathutils import PathItem, samepath from Orange.widgets.utils.settings import QSettings_writeArray @@ -112,7 +114,7 @@ def test_restore(self): item = w.current_item() self.assertTrue(samepath(item.path(), path)) self.assertEqual(item.options(), self.data_regions_options) - out = self.get_output("Data", w) + out = self.get_output(w.Outputs.data) self._check_data_regions(out) self.assertEqual(out.name, "data-regions") @@ -144,7 +146,7 @@ def test_restore_from_local(self): "local settings item must be recorded in _session_items_v2 when " "activated", ) - self._check_data_regions(self.get_output("Data", w)) + self._check_data_regions(self.get_output(w.Outputs.data)) data_csv_types_options = owcsvimport.Options( encoding="ascii", dialect=csv.excel_tab(), @@ -168,7 +170,7 @@ def test_type_guessing(self): ) widget.commit() self.wait_until_finished(widget) - output = self.get_output("Data", widget) + output = self.get_output(widget.Outputs.data) domain = output.domain self.assertIsInstance(domain["time"], TimeVariable) @@ -201,7 +203,7 @@ def test_discrete_values_sort(self): ) widget.commit() self.wait_until_finished(widget) - output = self.get_output("Data", widget) + output = self.get_output(widget.Outputs.data) self.assertTupleEqual(('1', '3', '4', '5', '12'), output.domain.attributes[1].values) def test_backward_compatibility(self): @@ -221,7 +223,7 @@ def test_backward_compatibility(self): ) widget.commit() self.wait_until_finished(widget) - output = self.get_output("Data", widget) + output = self.get_output(widget.Outputs.data) domain = output.domain self.assertIsInstance(domain["time"], StringVariable) @@ -281,6 +283,34 @@ def test_browse_prefix_parent(self): mb.assert_called() self.assertIsNone(widget.current_item()) + @staticmethod + @contextmanager + def activate_recent_and_get_dialog( + widget: OWCSVFileImport, recent_index: int = 0 + ) -> QFileDialog: + """ + Activate the recent item (which MUST be missing on FS) and + yield the QFileDialog which is shown. + """ + browse_dialog = widget._browse_dialog + with mock.patch.object(widget, "_browse_dialog") as r: + dlg = browse_dialog() + # segfaults in tests when using 'sheet' dialog on macos when parent + # is destroyed (before the dialog fully hides - animation)? + dlg.setParent(None) + # calling selectFile when using native (macOS) dialog does not have + # an effect - at least not immediately; + dlg.setOption(QFileDialog.DontUseNativeDialog) + r.return_value = dlg + with mock.patch.object(dlg, "open") as r: + widget.activate_recent(recent_index) + r.assert_called() + try: + yield dlg + finally: + dlg.deleteLater() + return + def test_browse_for_missing(self): missing = os.path.dirname(__file__) + "/this file does not exist.csv" widget = self.create_widget( @@ -290,19 +320,14 @@ def test_browse_for_missing(self): ] } ) - widget.activate_recent(0) - dlg = widget.findChild(QFileDialog) - assert dlg is not None - # calling selectFile when using native (macOS) dialog does not have - # an effect - at least not immediately; - dlg.setOption(QFileDialog.DontUseNativeDialog) - dlg.selectFile(self.data_regions_path) - dlg.accept() - cur = widget.current_item() - self.assertTrue(samepath(self.data_regions_path, cur.path())) - self.assertEqual( - self.data_regions_options.as_dict(), cur.options().as_dict() - ) + with self.activate_recent_and_get_dialog(widget) as dlg: + dlg.selectFile(self.data_regions_path) + dlg.accept() + cur = widget.current_item() + self.assertTrue(samepath(self.data_regions_path, cur.path())) + self.assertEqual( + self.data_regions_options.as_dict(), cur.options().as_dict() + ) def test_browse_for_missing_prefixed(self): path = self.data_regions_path @@ -316,21 +341,16 @@ def test_browse_for_missing_prefixed(self): }, env={"basedir": basedir} ) - widget.activate_recent(0) - dlg = widget.findChild(QFileDialog) - assert dlg is not None - # calling selectFile when using native (macOS) dialog does not have - # an effect - at least not immediately; - dlg.setOption(QFileDialog.DontUseNativeDialog) - dlg.selectFile(path) - dlg.accept() - cur = widget.current_item() - self.assertTrue(samepath(path, cur.path())) - self.assertEqual( - cur.varPath(), PathItem.VarPath("basedir", "data-regions.tab")) - self.assertEqual( - self.data_regions_options.as_dict(), cur.options().as_dict() - ) + with self.activate_recent_and_get_dialog(widget) as dlg: + dlg.selectFile(path) + dlg.accept() + cur = widget.current_item() + self.assertTrue(samepath(path, cur.path())) + self.assertEqual( + cur.varPath(), PathItem.VarPath("basedir", "data-regions.tab")) + self.assertEqual( + self.data_regions_options.as_dict(), cur.options().as_dict() + ) def test_browse_for_missing_prefixed_parent(self): path = self.data_regions_path @@ -346,18 +366,100 @@ def test_browse_for_missing_prefixed_parent(self): env={"basedir": basedir} ) mb = widget._path_must_be_relative_mb = mock.Mock() - widget.activate_recent(0) - dlg = widget.findChild(QFileDialog) - assert dlg is not None - # calling selectFile when using native (macOS) dialog does not have - # an effect - at least not immediately; - dlg.setOption(QFileDialog.DontUseNativeDialog) - dlg.selectFile(path) - dlg.accept() - mb.assert_called() + with self.activate_recent_and_get_dialog(widget) as dlg: + dlg.selectFile(path) + dlg.accept() + mb.assert_called() + cur = widget.current_item() + self.assertEqual(item[0], cur.varPath()) + self.assertEqual(item[1].as_dict(), cur.options().as_dict()) + + def test_save_state_infers_basedir_relative(self): + # A file chosen via the regular (non-prefixed) browse action should be + # persisted as a VarPath when it lives inside the workflow's basedir, + # so that moving the workflow together with its data resolves + # automatically (see GH-5155). + path = self.data_regions_path + basedir = os.path.dirname(path) + widget = self.widget + widget.workflowEnv = lambda: {"basedir": basedir} + widget.workflowEnvChanged("basedir", basedir, "") + with self._browse_setup(widget, path): + widget.browse() cur = widget.current_item() - self.assertEqual(item[0], cur.varPath()) - self.assertEqual(item[1].as_dict(), cur.options().as_dict()) + self.assertIsInstance(cur.varPath(), PathItem.AbsPath) + + widget._saveState() + self.assertEqual(len(widget._session_items_v2), 1) + stored, _ = widget._session_items_v2[0] + self.assertEqual( + stored, + PathItem.VarPath("basedir", "data-regions.tab").as_dict(), + ) + + def test_save_state_keeps_abspath_outside_basedir(self): + # Files that live outside the workflow's basedir remain absolute. + path = self.data_regions_path + basedir = tempfile.mkdtemp() + widget = self.widget + widget.workflowEnv = lambda: {"basedir": basedir} + widget.workflowEnvChanged("basedir", basedir, "") + with self._browse_setup(widget, path): + widget.browse() + + widget._saveState() + self.assertEqual(len(widget._session_items_v2), 1) + stored, _ = widget._session_items_v2[0] + self.assertEqual(stored, PathItem.AbsPath(path).as_dict()) + + def test_activate_import_dialog(self): + path = self.data_regions_path + item = ImportItem.fromPath(path) + self.widget.import_items_model.appendRow(ImportItem.fromPath(path)) + opts = item.options() + self.assertIsNone(opts) + with mock.patch.object(owcsvimport.CSVImportDialog, "show"): + self.widget.import_options_button.click() + dlg = self.widget.findChild(owcsvimport.CSVImportDialog) + dlg.accept() + item_ = self.widget.current_item() + self.assertEqual(item.path(), item_.path()) + self.assertIsNotNone(item_.options()) + + def test_drop_file(self): + self.assertFalse(self.widget.canDropUrl(QUrl("https://aa-bb.com"))) + url = QUrl.fromLocalFile(self.data_regions_path) + self.assertTrue(self.widget.canDropUrl(url)) + + with mock.patch.object(owcsvimport.CSVImportDialog, "show"): + self.widget.handleDroppedUrl(url) + dlg = self.widget.findChild(owcsvimport.CSVImportDialog) + dlg.reject() + item = self.widget.current_item() + self.assertIsNone(item, "Rejecting the dialog should not record the recent file") + + with mock.patch.object(owcsvimport.CSVImportDialog, "show"): + self.widget.handleDroppedUrl(url) + dlg = self.widget.findChild(owcsvimport.CSVImportDialog) + dlg.accept() + item = self.widget.current_item() + self.assertEqual(item.path(), url.toLocalFile()) + out = self.get_output(self.widget.Outputs.data) + self.assertEqual(len(out.domain), 3) + + def test_long_data(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "test.csv") + nums = np.tile([-3, -2, -1, 0, 1, 2], 100000) + pd.DataFrame( + {"A": np.hstack((nums, ["ABC"], nums))} + ).to_csv(path, index=False) + + with self._browse_setup(self.widget, path): + self.widget.browse() + out = self.get_output(self.widget.Outputs.data) + self.assertIsInstance(out.domain.attributes[0], DiscreteVariable) + self.assertTupleEqual((6 * 100000 * 2 + 1, 1), out.X.shape) class TestImportDialog(GuiTest): @@ -446,9 +548,10 @@ def test_load_csv(self): ) df = owcsvimport.load_csv(io.BytesIO(contents), opts) self.assertEqual(df.shape, (3, 5)) + resolution = "ns" if pd.__version__ < "3.0" else "s" self.assertSequenceEqual( list(df.dtypes), - [np.dtype("M8[ns]"), np.dtype(float), np.dtype(object), + [np.dtype(f"M8[{resolution}]"), np.dtype(float), np.dtype(object), "category", np.dtype(float)], ) opts = owcsvimport.Options( @@ -542,6 +645,24 @@ class Dialect(csv.excel): df = owcsvimport.load_csv(io.BytesIO(contents), opts) assert_array_equal(df.values, np.array([[3.21, 3.37], [4.13, 1000.142]])) + def test_tz_local(self): + contents = ( + b'1970-01-01T00:00:00Z\n' + b'1970-01-01T00:00:00+0100\n' + b'1999-12-31T00:00:00Z' + ) + opts = owcsvimport.Options( + encoding="ascii", + columntypes=[ + (range(0, 1), ColumnType.Time), + ], + rowspec=[] + ) + df = owcsvimport.load_csv(io.BytesIO(contents), opts) + tb = pandas_to_table(df) + self.assertEqual(tb.X[0, 0], 0.0) + self.assertEqual(tb.X[1, 0], -3600.0) + def test_open_compressed(self): content = 'abc' for ext in ["txt", "gz", "bz2", "xz", "zip"]: diff --git a/Orange/widgets/data/tests/test_owdatainfo.py b/Orange/widgets/data/tests/test_owdatainfo.py index c188f0abaee..724f8058b6e 100644 --- a/Orange/widgets/data/tests/test_owdatainfo.py +++ b/Orange/widgets/data/tests/test_owdatainfo.py @@ -1,6 +1,11 @@ -# Test methods with long descriptive names can omit docstrings -# pylint: disable=missing-docstring,unsubscriptable-object -from Orange.data import Table +import unittest +from unittest.mock import patch + +import numpy as np +from scipy import sparse as sp + +from Orange.data import \ + Table, Domain, ContinuousVariable, DiscreteVariable, StringVariable from Orange.widgets.data.owdatainfo import OWDataInfo from Orange.widgets.tests.base import WidgetTest @@ -10,18 +15,79 @@ def setUp(self): self.widget = self.create_widget(OWDataInfo) def test_data(self): - """No crash on iris""" - data = Table("iris") - self.send_signal(self.widget.Inputs.data, data) + # I guess we don't want to test specific output tests, just different + # combinations that must not crash + a, b, c = (DiscreteVariable(n) for n in "abc") + x, y, z = (ContinuousVariable(n) for n in "xyz") + m, n = (StringVariable(n) for n in "mn") + meta_s = np.array([["foo", "bar", ""]]).T + meta_c = np.array([[3.14, np.nan, np.nan]]).T + metadata = np.hstack((meta_s, meta_c)) + self.widget.send_report() + for attrs, classes, metas, metad in (((a, b, c), (), (), None), + ((a, b, c, x), (y,), (), None), + ((a, b, c), (y, x), (m, ), meta_s), + ((a, b, c), (y, ), (x, ), meta_c), + ((a, b), (y, x, c), (m, ), meta_s), + ((a, ), (b, c), (m, ), meta_s), + ((a, b, x), (c, ), (m, y), metadata), + ((), (c, ), (m, y), metadata)): + data = Table.from_numpy( + Domain(attrs, classes, metas), + np.zeros((3, len(attrs))), + np.zeros((3, len(classes))), + metad) + data.attributes = {"att 1": 1, "att 2": True, "att 3": 3} + if metas: + data.name = "name" + self.send_signal(self.widget.Inputs.data, data) + self.widget.send_report() + self.send_signal(self.widget.Inputs.data, None) + self.widget.send_report() - def test_empty_data(self): - """No crash on empty data""" - data = Table("iris") - self.send_signal(self.widget.Inputs.data, - Table.from_domain(data.domain)) + data.attributes = {"foo": "bar"} + self.send_signal(self.widget.Inputs.data, None) + self.widget.send_report() - def test_data_attributes(self): - """No crash on data attributes of different types""" - data = Table("iris") - data.attributes = {"att 1": 1, "att 2": True, "att 3": 3} + def test_sparse(self): + x, y, z, u, w = (ContinuousVariable(n) for n in "xyzuw") + data = Table.from_numpy( + Domain([x, y], z, [u, w]), + sp.csc_matrix(np.random.randint(0, 1, (5, 2))), + sp.csc_matrix(np.random.randint(0, 1, (5, 1))), + sp.csc_matrix(np.random.randint(0, 1, (5, 2)))) self.send_signal(self.widget.Inputs.data, data) + self.widget.send_report() + + def test_sql(self): + class SqlTable(Table): + connection_params = {"foo": "bar"} + + class Thread: + def __init__(self, target): + self.target = target + + def start(self): + self.target() + + w = self.widget + + domain = Domain([ContinuousVariable("y")]) + + with patch("Orange.widgets.data.owdatainfo.SqlTable", new=SqlTable), \ + patch("threading.Thread", new=Thread), \ + patch.object(self.widget, "_p_size", wraps=self.widget._p_size) as p_size: + + self.send_signal(w.Inputs.data, Table.from_numpy(domain, [[42]])) + p_size.assert_called_once() + p_size.reset_mock() + + d = SqlTable.from_numpy(domain, [[42]]) + self.send_signal(w.Inputs.data, d) + self.assertEqual(p_size.call_count, 2) + self.assertEqual(p_size.call_args_list[0], ((d, ),)) + self.assertEqual(p_size.call_args_list[1], ((d, ),)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/data/tests/test_owdatasampler.py b/Orange/widgets/data/tests/test_owdatasampler.py index c59ba560014..ab2d291854b 100644 --- a/Orange/widgets/data/tests/test_owdatasampler.py +++ b/Orange/widgets/data/tests/test_owdatasampler.py @@ -21,31 +21,32 @@ def test_error_message(self): """ Check if error message appears and then disappears when data is removed from input""" self.widget.controls.sampling_type.buttons[2].click() - self.send_signal("Data", self.iris) + self.send_signal(self.iris) self.assertFalse(self.widget.Error.too_many_folds.is_shown()) - self.send_signal("Data", self.iris[:5]) + self.send_signal(self.iris[:5]) self.assertTrue(self.widget.Error.too_many_folds.is_shown()) - self.send_signal("Data", None) + self.send_signal(None) self.assertFalse(self.widget.Error.too_many_folds.is_shown()) - self.send_signal("Data", Table.from_domain(self.iris.domain)) + self.send_signal(Table.from_domain(self.iris.domain)) self.assertTrue(self.widget.Error.no_data.is_shown()) def test_stratified_on_unbalanced_data(self): unbalanced_data = self.iris[:51] self.widget.controls.stratify.setChecked(True) - self.send_signal("Data", unbalanced_data) + self.send_signal(unbalanced_data) self.assertTrue(self.widget.Warning.could_not_stratify.is_shown()) def test_bootstrap(self): self.select_sampling_type(self.widget.Bootstrap) - self.send_signal("Data", self.iris) + self.send_signal(self.iris) in_input = set(self.iris.ids) - sample = self.get_output("Data Sample") + sample = self.get_output(self.widget.Outputs.data_sample) in_sample = set(sample.ids) - in_remaining = set(self.get_output("Remaining Data").ids) + in_remaining = set( + self.get_output(self.widget.Outputs.remaining_data).ids) # Bootstrap should sample len(input) instances self.assertEqual(len(sample), len(self.iris)) @@ -66,7 +67,7 @@ def select_sampling_type(self, sampling_type): def test_no_intersection_in_outputs(self): """ Check whether outputs intersect and whether length of outputs sums to length of original data""" - self.send_signal("Data", self.iris) + self.send_signal(self.iris) w = self.widget sampling_types = [w.FixedProportion, w.FixedSize, w.CrossValidation] @@ -78,43 +79,43 @@ def test_no_intersection_in_outputs(self): self.select_sampling_type(sampling_type) self.widget.commit() - sample = self.get_output("Data Sample") - other = self.get_output("Remaining Data") + sample = self.get_output(self.widget.Outputs.data_sample) + other = self.get_output(self.widget.Outputs.remaining_data) self.assertEqual(len(self.iris), len(sample) + len(other)) self.assertNoIntersection(sample, other) def test_bigger_size_with_replacement(self): """Allow bigger output without replacement.""" - self.send_signal('Data', self.iris[:2]) + self.send_signal(self.iris[:2]) sample_size = self.set_fixed_sample_size(3, with_replacement=True) self.assertEqual(3, sample_size, 'Should be able to set a bigger size ' 'with replacement') def test_bigger_size_without_replacement(self): """Lower output samples to match input's without replacement.""" - self.send_signal('Data', self.iris[:2]) + self.send_signal(self.iris[:2]) sample_size = self.set_fixed_sample_size(3) self.assertEqual(2, sample_size) def test_bigger_output_warning(self): """Should warn when sample size is bigger than input.""" - self.send_signal('Data', self.iris[:2]) + self.send_signal(self.iris[:2]) self.set_fixed_sample_size(3, with_replacement=True) self.assertTrue(self.widget.Warning.bigger_sample.is_shown()) def test_shuffling(self): - self.send_signal('Data', self.iris) + self.send_signal(self.iris) self.set_fixed_sample_size(150) self.assertFalse(self.widget.Warning.bigger_sample.is_shown()) - sample = self.get_output("Data Sample") + sample = self.get_output(self.widget.Outputs.data_sample) self.assertTrue((self.iris.ids != sample.ids).any()) self.assertEqual(set(self.iris.ids), set(sample.ids)) self.select_sampling_type(self.widget.FixedProportion) self.widget.sampleSizePercentage = 100 self.widget.commit() - sample = self.get_output("Data Sample") + sample = self.get_output(self.widget.Outputs.data_sample) self.assertTrue((self.iris.ids != sample.ids).any()) self.assertEqual(set(self.iris.ids), set(sample.ids)) @@ -131,6 +132,13 @@ def set_fixed_sample_size(self, sample_size, with_replacement=False): self.widget.commit() return self.widget.sampleSizeSpin.value() + def set_fixed_proportion(self, proportion): + """Set fixed sample proportion. + """ + self.select_sampling_type(self.widget.FixedProportion) + self.widget.sampleSizePercentageSlider.setValue(proportion) + self.widget.commit() + def assertNoIntersection(self, sample, other): self.assertFalse(bool(set(sample.ids) & set(other.ids))) @@ -170,6 +178,26 @@ def test_cv_output_migration(self): self.assertEqual(len(self.get_output(w.Outputs.data_sample)), 15) self.assertEqual(len(self.get_output(w.Outputs.remaining_data)), 135) + def test_empty_sample(self): + w = self.widget + self.send_signal(w.Inputs.data, self.iris) + + self.set_fixed_sample_size(150) + self.assertEqual(len(self.get_output(w.Outputs.data_sample)), 150) + self.assertEqual(len(self.get_output(w.Outputs.remaining_data)), 0) + + self.set_fixed_sample_size(0) + self.assertEqual(len(self.get_output(w.Outputs.data_sample)), 0) + self.assertEqual(len(self.get_output(w.Outputs.remaining_data)), 150) + + self.set_fixed_proportion(100) + self.assertEqual(len(self.get_output(w.Outputs.data_sample)), 150) + self.assertEqual(len(self.get_output(w.Outputs.remaining_data)), 0) + + self.set_fixed_proportion(0) + self.assertEqual(len(self.get_output(w.Outputs.data_sample)), 0) + self.assertEqual(len(self.get_output(w.Outputs.remaining_data)), 150) + def test_send_report(self): w = self.widget self.send_signal(w.Inputs.data, self.iris) diff --git a/Orange/widgets/data/tests/test_owdatasets.py b/Orange/widgets/data/tests/test_owdatasets.py index 344b3946ad4..ab95958b266 100644 --- a/Orange/widgets/data/tests/test_owdatasets.py +++ b/Orange/widgets/data/tests/test_owdatasets.py @@ -1,15 +1,24 @@ +import time import unittest from unittest.mock import patch, Mock import requests -from AnyQt.QtCore import QItemSelectionModel +from AnyQt.QtCore import QItemSelectionModel, Qt -from Orange.widgets.data.owdatasets import OWDataSets +from Orange.widgets.data.owdatasets import OWDataSets, Namespace as DSNamespace, \ + GENERAL_DOMAIN, ALL_DOMAINS from Orange.widgets.tests.base import WidgetTest class TestOWDataSets(WidgetTest): + def setUp(self): + # Most tests check the iniitialization of widget under different + # conditions, therefore mocks are needed prior to calling createWidget. + # Inherited methods will set self.widget; here we set it to None to + # avoid lint errors. + self.widget = None + @patch("Orange.widgets.data.owdatasets.list_remote", Mock(side_effect=requests.exceptions.ConnectionError)) @patch("Orange.widgets.data.owdatasets.list_local", @@ -25,6 +34,7 @@ def test_no_internet_connection(self): @patch("Orange.widgets.data.owdatasets.list_local", Mock(return_value={('core', 'foo.tab'): {}})) @patch("Orange.widgets.data.owdatasets.log", Mock()) + @WidgetTest.skipNonEnglish def test_only_local(self): w = self.create_widget(OWDataSets) # type: OWDataSets self.wait_until_stop_blocking(w) @@ -34,19 +44,229 @@ def test_only_local(self): @patch("Orange.widgets.data.owdatasets.list_remote", Mock(side_effect=requests.exceptions.ConnectionError)) @patch("Orange.widgets.data.owdatasets.list_local", - Mock(return_value={('core', 'foo.tab'): {}, - ('core', 'bar.tab'): {}})) + Mock(return_value={('core', 'foo.tab'): {"language": "English"}, + ('core', 'bar.tab'): {"language": "Slovenščina"}})) @patch("Orange.widgets.data.owdatasets.log", Mock()) def test_filtering(self): w = self.create_widget(OWDataSets) # type: OWDataSets + model = w.view.model() + model.setLanguage(None) self.wait_until_stop_blocking(w) - self.assertEqual(w.view.model().rowCount(), 2) + self.assertEqual(model.rowCount(), 2) w.filterLineEdit.setText("foo") - self.assertEqual(w.view.model().rowCount(), 1) + self.assertEqual(model.rowCount(), 1) w.filterLineEdit.setText("baz") - self.assertEqual(w.view.model().rowCount(), 0) + self.assertEqual(model.rowCount(), 0) w.filterLineEdit.setText("") - self.assertEqual(w.view.model().rowCount(), 2) + self.assertEqual(model.rowCount(), 2) + + model.setLanguage("Slovenščina") + self.assertEqual(model.rowCount(), 1) + self.assertEqual(model.index(0, 0).data(Qt.UserRole).title, "bar.tab") + + model.setLanguage("English") + self.assertEqual(model.rowCount(), 1) + self.assertEqual(model.index(0, 0).data(Qt.UserRole).title, "foo.tab") + + model.setLanguage(None) + self.assertEqual(model.rowCount(), 2) + + @patch("Orange.widgets.data.owdatasets.list_remote", + Mock(side_effect=requests.exceptions.ConnectionError)) + @patch("Orange.widgets.data.owdatasets.list_local", + Mock(return_value={('core', 'foo.tab'): {"domain": None}, + ('edu', 'bar.tab'): {"domain": "edu"}})) + @patch("Orange.widgets.data.owdatasets.log", Mock()) + def test_filtering_by_domain(self): + w = self.create_widget(OWDataSets) # type: OWDataSets + model = w.view.model() + model.setDomain(GENERAL_DOMAIN) + self.wait_until_stop_blocking(w) + self.assertEqual(model.rowCount(), 1) + + model.setDomain(ALL_DOMAINS) + self.wait_until_stop_blocking(w) + self.assertEqual(model.rowCount(), 2) + + model.setDomain("edu") + self.assertEqual(model.rowCount(), 1) + self.assertEqual(model.index(0, 0).data(Qt.UserRole).title, "bar.tab") + + model.setDomain("baz") + self.assertEqual(model.rowCount(), 0) + + @patch("Orange.widgets.data.owdatasets.list_local", + Mock(return_value={('core', 'foo.tab'): {"domain": None}, + ('core', 'bar.tab'): {"domain": "edu"}})) + @patch("Orange.widgets.data.owdatasets.log", Mock()) + @patch("Orange.widgets.data.owdatasets.OWDataSets.commit", Mock()) + def test_change_domain(self): + def wait_and_return(_): + time.sleep(0.2) + return {('core', 'foo.tab'): {"domain": "edu"}, + ('core', 'bar.tab'): {"domain": "edu"}} + with patch("Orange.widgets.data.owdatasets.list_remote", + new=wait_and_return): + self.widget = w = self.create_widget(OWDataSets, + stored_settings={"selected_id": "bar.tab", + "domain": "edu"}) + self.wait_until_stop_blocking() + self.assertEqual(w.selected_id, "bar.tab") + self.assertEqual(w.domain_combo.currentText(), "edu") + + self.widget = w = self.create_widget(OWDataSets, + stored_settings={"selected_id": "foo.tab", + "domain": "(core)"}) + self.wait_until_stop_blocking() + self.assertEqual(w.selected_id, "foo.tab") + self.assertEqual(w.domain_combo.currentText(), "edu") + + self.widget = w = self.create_widget(OWDataSets, + stored_settings={"selected_id": "bar.tab", + "domain": "(core)"}) + self.wait_until_stop_blocking() + self.assertEqual(w.selected_id, "bar.tab") + self.assertEqual(w.domain_combo.currentText(), "edu") + + def __titles(self, widget): + model = widget.view.model() + return { + model.index(row, 0).data(Qt.UserRole).title + for row in range(model.rowCount())} + + @patch("Orange.widgets.data.owdatasets.list_remote", + Mock(side_effect=requests.exceptions.ConnectionError)) + @patch("Orange.widgets.data.owdatasets.list_local", + Mock(return_value={ + ('core', 'foo.tab'): {"title": "an unlisted data set", + "publication_status": DSNamespace.UNLISTED}, + ('core', 'bar.tab'): {"title": "a published data set", + "publication_status": DSNamespace.PUBLISHED}, + ('core', 'baz.tab'): {"title": "an unp unp", + "publication_status": DSNamespace.PUBLISHED} + })) + @patch("Orange.widgets.data.owdatasets.log", Mock()) + def test_filtering_unlisted(self): + def titles(): + return self.__titles(w) + + w = self.create_widget(OWDataSets) # type: OWDataSets + model = w.view.model() + self.assertEqual(titles(), {"a published data set", "an unp unp"}) + + model.setFilterFixedString("unp") + self.assertEqual(titles(), {"an unp unp"}) + + model.setFilterFixedString("an U") + self.assertEqual(titles(), {"an unlisted data set", "an unp unp"}) + + model.setFilterFixedString("") + self.assertEqual(titles(), {"a published data set", "an unp unp"}) + + model.setFilterFixedString(None) + self.assertEqual(titles(), {"a published data set", "an unp unp"}) + + @patch("Orange.widgets.data.owdatasets.list_remote", + Mock(return_value={('core', 'foo.tab'): {"title": "Foo data set", + "language": "English"}, + ('core', 'bar.tab'): {"title": "Bar data set", + "domain": "Testing"}, + ('core', 'bax.tab'): {"title": "Bax data set", + "language": "Slovenščina"} + })) + @patch("Orange.widgets.data.owdatasets.list_local", + Mock(return_value={})) + @patch("Orange.widgets.data.owdatasets.OWDataSets.commit", Mock()) + def test_filter_overrides_language_and_domain(self): + w = self.create_widget(OWDataSets) # type: OWDataSets + self.wait_until_stop_blocking(w) + w.language_combo.setCurrentText("Slovenščina") + w.language_combo.activated.emit(w.language_combo.currentIndex()) + w.domain_combo.setCurrentText(w.GENERAL_DOMAIN_LABEL) + w.domain_combo.activated.emit(w.domain_combo.currentIndex()) + + self.assertEqual(self.__titles(w), {"Bax data set"}) + + w.filterLineEdit.setText("data ") + self.assertEqual(self.__titles(w), {"Foo data set", + "Bar data set", + "Bax data set"}) + self.assertEqual(w.language_combo.currentText(), w.ALL_LANGUAGES) + self.assertFalse(w.language_combo.isEnabled()) + self.assertEqual(w.domain_combo.currentText(), w.ALL_DOMAINS_LABEL) + self.assertFalse(w.domain_combo.isEnabled()) + + w.filterLineEdit.setText("da") + self.assertEqual(self.__titles(w), {"Bax data set"}) + self.assertEqual(w.language_combo.currentText(), "Slovenščina") + self.assertTrue(w.language_combo.isEnabled()) + self.assertEqual(w.domain_combo.currentText(), w.GENERAL_DOMAIN_LABEL) + self.assertTrue(w.domain_combo.isEnabled()) + + + w.filterLineEdit.setText("bar d") + self.assertEqual(self.__titles(w), {"Bar data set"}) + + w.filterLineEdit.setText("bax d") + self.assertEqual(self.__titles(w), {"Bax data set"}) + + w.language_combo.setCurrentText("English") + w.language_combo.activated.emit(2) + self.assertEqual(self.__titles(w), {"Bax data set"}) + + settings = w.settingsHandler.pack_data(w) + + w2 = self.create_widget(OWDataSets, stored_settings=settings) + self.wait_until_stop_blocking(w2) + self.assertEqual(w2.language_combo.currentText(), "English") + self.assertEqual(self.__titles(w2), {"Foo data set"}) + + w.selected_id = "bax.tab" + settings = w.settingsHandler.pack_data(w) + w2 = self.create_widget(OWDataSets, stored_settings=settings) + self.wait_until_stop_blocking(w2) + self.assertEqual(w2.language_combo.currentText(), w2.ALL_LANGUAGES) + self.assertFalse(w2.language_combo.isEnabled()) + self.assertEqual(w2.filterLineEdit.text(), "bax d") + self.assertEqual(self.__titles(w2), {"Bax data set"}) + + + @patch("Orange.widgets.data.owdatasets.list_remote", + Mock(return_value={('core', 'foo.tab'): {"language": "English"}, + ('core', 'bar.tab'): {"language": "Slovenščina"}})) + @patch("Orange.widgets.data.owdatasets.list_local", + Mock(return_value={})) + def test_remember_language(self): + w = self.create_widget(OWDataSets) # type: OWDataSets + self.wait_until_stop_blocking(w) + w.language_combo.setCurrentText("Slovenščina") + w.language_combo.activated.emit(w.language_combo.currentIndex()) + settings = w.settingsHandler.pack_data(w) + + w2 = self.create_widget(OWDataSets, stored_settings=settings) + self.wait_until_stop_blocking(w2) + self.assertEqual(w2.language_combo.currentText(), "Slovenščina") + + settings["language"] = "Klingon" + w2 = self.create_widget(OWDataSets, stored_settings=settings) + self.wait_until_stop_blocking(w2) + self.assertEqual(w2.language_combo.currentText(), "Klingon") + + @patch("Orange.widgets.data.owdatasets.list_remote", + Mock(return_value={('core', 'foo.tab'): {"language": "English"}, + ('core', 'bar.tab'): {"language": "Slovenščina"}})) + @patch("Orange.widgets.data.owdatasets.list_local", + Mock(return_value={})) + def test_remember_all_languages(self): + w = self.create_widget(OWDataSets) # type: OWDataSets + self.wait_until_stop_blocking(w) + w.language_combo.setCurrentText(w.ALL_LANGUAGES) + w.language_combo.activated.emit(w.language_combo.currentIndex()) + settings = w.settingsHandler.pack_data(w) + + w2 = self.create_widget(OWDataSets, stored_settings=settings) + self.wait_until_stop_blocking(w2) + self.assertEqual(w2.language_combo.currentText(), w2.ALL_LANGUAGES) @patch("Orange.widgets.data.owdatasets.list_remote", Mock(return_value={('core', 'iris.tab'): {}})) @@ -54,6 +274,7 @@ def test_filtering(self): Mock(return_value={})) @patch("Orange.widgets.data.owdatasets.ensure_local", Mock(return_value="iris.tab")) + @WidgetTest.skipNonEnglish def test_download_iris(self): w = self.create_widget(OWDataSets) # type: OWDataSets self.wait_until_stop_blocking(w) @@ -61,6 +282,7 @@ def test_download_iris(self): sel_type = QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows w.view.selectionModel().select(w.view.model().index(0, 0), sel_type) w.commit() + self.assertEqual(w.selected_id, "iris.tab") iris = self.get_output(w.Outputs.data, w) self.assertEqual(len(iris), 150) @@ -70,11 +292,33 @@ def test_download_iris(self): Mock(return_value={('dir1', 'dir2', 'foo.tab'): {}, ('bar.tab',): {}})) @patch("Orange.widgets.data.owdatasets.log", Mock()) + @WidgetTest.skipNonEnglish def test_dir_depth(self): w = self.create_widget(OWDataSets) # type: OWDataSets self.wait_until_stop_blocking(w) self.assertEqual(w.view.model().rowCount(), 2) + def test_migrate_selected_id(self): + settings = {} + OWDataSets.migrate_settings(settings, 0) + self.assertNotIn("selected_id", settings) + + settings = {"selected_id": None} + OWDataSets.migrate_settings(settings, 0) + self.assertEqual(settings["selected_id"], None) + + settings = {"selected_id": "dir1\\bar"} + OWDataSets.migrate_settings(settings, 0) + self.assertEqual(settings["selected_id"], "bar") + + settings = {"selected_id": "dir1/bar"} + OWDataSets.migrate_settings(settings, 0) + self.assertEqual(settings["selected_id"], "bar") + + settings = {"selected_id": "bar"} + OWDataSets.migrate_settings(settings, 0) + self.assertEqual(settings["selected_id"], "bar") + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owdiscretize.py b/Orange/widgets/data/tests/test_owdiscretize.py index c6ce6766c57..19bbc04fcad 100644 --- a/Orange/widgets/data/tests/test_owdiscretize.py +++ b/Orange/widgets/data/tests/test_owdiscretize.py @@ -1,178 +1,603 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring,unsubscriptable-object,protected-access import unittest +from functools import partial +from unittest.mock import patch, Mock -from AnyQt.QtCore import Qt, QPoint +import numpy as np + +from AnyQt.QtCore import QPoint, Qt, QModelIndex from AnyQt.QtWidgets import QWidget, QApplication, QStyleOptionViewItem +from AnyQt.QtGui import QIcon + +from orangewidget.settings import Context -from Orange.data import Table, DiscreteVariable -from Orange.widgets.data.owdiscretize import OWDiscretize, Default, EqualFreq, \ - Remove, Leave, Custom, IncreasingNumbersListValidator, DiscDelegate, MDL, \ - EqualWidth, DState, show_tip -from Orange.widgets.tests.base import WidgetTest -from Orange.widgets.tests.base import GuiTest -from Orange.widgets.utils.itemmodels import select_row, VariableListModel +from Orange.data import Table, ContinuousVariable, TimeVariable, Domain +from Orange.preprocess.discretize import TooManyIntervals +from Orange.widgets.data.owdiscretize import OWDiscretize, \ + IncreasingNumbersListValidator, VarHint, Methods, DefaultKey, \ + _fixed_width_discretization, _fixed_time_width_discretization, \ + _custom_discretization, variable_key, Options, DefaultHint, \ + _mdl_discretization, ListViewSearch, format_desc, DefaultDiscModel, \ + DiscDomainModel, DiscDesc +from Orange.widgets.tests.base import WidgetTest, GuiTest +from Orange.widgets.utils.itemmodels import select_rows -class TestOWDiscretize(WidgetTest): +class DataMixin: + def prepare_data(self): + self.domain = Domain([ContinuousVariable("x"), + ContinuousVariable("y"), + ContinuousVariable("z"), + TimeVariable("t"), + TimeVariable("u")]) + self.data = Table.from_numpy(self.domain, np.arange(20).reshape(4, 5)) + self.var_hints = { + DefaultKey: VarHint(Methods.Keep, ()), + ("x", False): VarHint(Methods.EqualFreq, (3, )), + ("y", False): VarHint(Methods.Keep, ()), + ("z", False): VarHint(Methods.Remove, ()), + ("t", True): VarHint(Methods.Binning, (2, )) + } + # Copy the following line to tests, for reference: + # Def: Keep, x: EqFreq 3, y: Keep, z: Remove, t (time): Bin 2, u (time): + +class TestOWDiscretize(WidgetTest, DataMixin): def setUp(self): super().setUp() + self.prepare_data() self.widget = self.create_widget(OWDiscretize) + def test_empty_data(self): data = Table("iris") widget = self.widget self.send_signal(self.widget.Inputs.data, Table.from_domain(data.domain)) - for m in (OWDiscretize.Leave, OWDiscretize.MDL, OWDiscretize.EqualFreq, - OWDiscretize.EqualWidth, OWDiscretize.Remove, - OWDiscretize.Custom): - widget.default_method = m - widget.unconditional_commit() + for m in range(len(Methods)): + widget.var_hints = {DefaultKey: VarHint(m, ())} + widget.commit.now() self.assertIsNotNone(self.get_output(widget.Outputs.data)) - def test_select_method(self): - widget = self.widget - data = Table("iris")[::5] - self.send_signal(self.widget.Inputs.data, data) - - model = widget.varmodel - view = widget.varview - defbg = widget.default_button_group - varbg = widget.variable_button_group - self.assertSequenceEqual(list(model), data.domain.attributes) - defbg.button(OWDiscretize.EqualFreq).click() - self.assertEqual(widget.default_method, OWDiscretize.EqualFreq) - self.assertTrue( - all(isinstance(m, Default) and isinstance(m.method, EqualFreq) - for m in map(widget.method_for_index, - range(len(data.domain.attributes))))) - - # change method for first variable - select_row(view, 0) - varbg.button(OWDiscretize.Remove).click() - met = widget.method_for_index(0) - self.assertIsInstance(met, Remove) - - # select a second var - selmodel = view.selectionModel() - selmodel.select(model.index(2), selmodel.Select) - # the current checked button must unset - self.assertEqual(varbg.checkedId(), -1) - - varbg.button(OWDiscretize.Leave).click() - self.assertIsInstance(widget.method_for_index(0), Leave) - self.assertIsInstance(widget.method_for_index(2), Leave) - # reset both back to default - varbg.button(OWDiscretize.Default).click() - self.assertIsInstance(widget.method_for_index(0), Default) - self.assertIsInstance(widget.method_for_index(2), Default) - - def test_migration(self): - w = self.create_widget(OWDiscretize, stored_settings={ - "default_method": 0 - }) - self.assertEqual(w.default_method, OWDiscretize.Leave) - - def test_manual_cuts_edit(self): - widget = self.widget - data = Table("iris")[::5] - self.send_signal(self.widget.Inputs.data, data) - view = widget.varview - varbg = widget.variable_button_group - widget.set_default_method(OWDiscretize.Custom) - widget.default_cutpoints = (0, 2, 4) - ledit = widget.manual_cuts_edit - self.assertEqual(ledit.text(), "0, 2, 4") - ledit.setText("3, 4, 5") - ledit.editingFinished.emit() - self.assertEqual(widget.default_cutpoints, (3, 4, 5)) - self.assertEqual(widget._current_default_method(), Custom((3, 4, 5))) - self.assertTrue( - all(widget.method_for_index(i) == Default(Custom((3, 4, 5))) - for i in range(len(data.domain.attributes))) - ) - select_row(view, 0) - varbg.button(OWDiscretize.Custom).click() - ledit = widget.manual_cuts_specific - ledit.setText("1, 2, 3") - ledit.editingFinished.emit() - self.assertEqual(widget.method_for_index(0), Custom((1, 2, 3))) - ledit.setText("") - ledit.editingFinished.emit() - self.assertEqual(widget.method_for_index(0), Custom(())) - - def test_manual_cuts_copy(self): - widget = self.widget - data = Table("iris")[::5] - self.send_signal(self.widget.Inputs.data, data) - view = widget.varview - select_row(view, 0) - varbg = widget.variable_button_group - varbg.button(OWDiscretize.EqualWidth).click() - v = widget.discretized_var(0) - points = tuple(v.compute_value.points) - cc_button = widget.copy_current_to_manual_button - cc_button.click() - self.assertEqual(widget.method_for_index(0), Custom(points)) - self.assertEqual(varbg.checkedId(), OWDiscretize.Custom) - def test_report(self): - widget = self.widget - data = Table("iris")[::5] - self.send_signal(widget.Inputs.data, data) - widget.send_report() + data = Table("brown-selected") + + w = self.create_widget( + OWDiscretize, + {"var_hints": + {None: VarHint(Methods.EqualFreq, (3,)), + ('alpha 0', False): VarHint(Methods.Keep, ()), + ('alpha 7', False): VarHint(Methods.Remove, ()), + ('alpha 14', False): VarHint(Methods.Binning, (2, )), + ('alpha 21', False): VarHint(Methods.FixedWidth, ("0.05", )), + ('alpha 28', False): VarHint(Methods.EqualFreq, (4, )), + ('alpha 35', False): VarHint(Methods.MDL, ()), + ('alpha 42', False): VarHint(Methods.Custom, ("0, 0.125", )), + ('alpha 49', False): VarHint(Methods.MDL, ())}, + "__version__": 3}) + self.send_signal(w.Inputs.data, data) + + self.widget.send_report() + + def test_all(self): + data = Table("brown-selected") + + w = self.create_widget( + OWDiscretize, + {"var_hints": + {None: VarHint(Methods.EqualFreq, (3,)), + ('alpha 0', False): VarHint(Methods.Keep, ()), + ('alpha 7', False): VarHint(Methods.Remove, ()), + ('alpha 14', False): VarHint(Methods.Binning, (2, )), + ('alpha 21', False): VarHint(Methods.FixedWidth, ("0.05", )), + ('alpha 28', False): VarHint(Methods.EqualFreq, (4, )), + ('alpha 35', False): VarHint(Methods.MDL, ()), + ('alpha 42', False): VarHint(Methods.Custom, ("0, 0.125", )), + ('alpha 49', False): VarHint(Methods.MDL, ())}, + "__version__": 3}) + + self.send_signal(w.Inputs.data, data) + + self.assertTrue(w.button_group.button(Methods.MDL).isEnabled()) + self.assertEqual(w.varview.default_view.model().hint, + VarHint(Methods.EqualFreq, (3, ))) + + out = self.get_output(w.Outputs.data) + dom = out.domain + self.assertIsInstance(dom["alpha 0"], ContinuousVariable) + self.assertNotIn("alpha 7", dom) + self.assertEqual(dom["alpha 14"].values, ('< 0', '≥ 0')) + self.assertEqual(dom["alpha 21"].values, + ('< -0.15', "-0.15 - -0.10", "-0.10 - -0.05", + "-0.05 - 0.00", "0.00 - 0.05", "0.05 - 0.10", + '≥ 0.10')) + self.assertEqual(len(dom["alpha 28"].values), 4) + self.assertNotIn("alpha 35", dom) # removed by MDL + self.assertEqual(dom["alpha 42"].values, ('< 0', '0 - 0.125', '≥ 0.125')) + self.assertEqual(len(dom["alpha 49"].values), 2) + + self.send_signal(w.Inputs.data, None) + self.assertIsNone(self.get_output(w.Outputs.data)) + self.assertIsNone(w.data) + self.assertEqual(w.discretized_vars, {}) + self.assertEqual(len(w.varview.model()), 0) + + self.send_signal(w.Inputs.data, data) + self.assertIsNotNone(self.get_output(w.Outputs.data)) + w.button_group.button(Methods.MDL).setChecked(True) + self.assertTrue(w.button_group.button(Methods.MDL).isEnabled()) + self.assertTrue(w.button_group.button(Methods.MDL).isChecked()) + + self.send_signal(w.Inputs.data, data[:, 0]) + self.assertFalse(w.button_group.button(Methods.MDL).isEnabled()) + self.assertFalse(w.button_group.button(Methods.MDL).isChecked()) + + self.send_signal(w.Inputs.data, data) + self.assertTrue(w.button_group.button(Methods.MDL).isEnabled()) + + def test_get_values(self): + w = self.widget + + w.binning_spin.setValue(5) + w.width_line.setText("6") + w.width_time_line.setText("7") + w.width_time_unit.setCurrentIndex(1) + w.freq_spin.setValue(8) + w.width_spin.setValue(9) + w.threshold_line.setText("1, 2, 3, 4, 5") + + self.assertEqual(w._get_values(Methods.Keep), ()) + self.assertEqual(w._get_values(Methods.Remove), ()) + self.assertEqual(w._get_values(Methods.Binning), (5, )) + self.assertEqual(w._get_values(Methods.FixedWidth), ("6", )) + self.assertEqual(w._get_values(Methods.FixedWidthTime), ("7", 1)) + self.assertEqual(w._get_values(Methods.EqualFreq), (8, )) + self.assertEqual(w._get_values(Methods.EqualWidth), (9, )) + self.assertEqual(w._get_values(Methods.MDL), ()) + self.assertEqual(w._get_values(Methods.Custom), ("1, 2, 3, 4, 5", )) + + def test_set_values(self): + w = self.widget + + w._set_values(Methods.Keep, ()) + w._set_values(Methods.Remove, ()) + w._set_values(Methods.Binning, (5,)) + w._set_values(Methods.FixedWidth, ("6",)) + w._set_values(Methods.FixedWidthTime, ("7", 1)) + w._set_values(Methods.EqualFreq, (8,)) + w._set_values(Methods.EqualWidth, (9,)) + w._set_values(Methods.MDL, ()) + w._set_values(Methods.Custom, ("1, 2, 3, 4, 5",)) + + self.assertEqual(w.binning_spin.value(), 5) + self.assertEqual(w.width_line.text(), "6") + self.assertEqual(w.width_time_line.text(), "7") + self.assertEqual(w.width_time_unit.currentIndex(), 1) + self.assertEqual(w.freq_spin.value(), 8) + self.assertEqual(w.width_spin.value(), 9) + self.assertEqual(w.threshold_line.text(), "1, 2, 3, 4, 5") + + def test_varkeys_for_selection(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data) + select_rows(w.varview, (0, 4)) + self.assertEqual(w.varkeys_for_selection(), [("x", False), ("u", True)]) + + def test_change_selection_update_interface(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data) + w.var_hints = { + DefaultKey: DefaultHint, + ("x", False): VarHint(Methods.FixedWidth, ("10", )), + ("y", False): VarHint(Methods.FixedWidth, ("10", )), + ("z", False): VarHint(Methods.FixedWidth, ("5", )), + ("t", False): VarHint(Methods.Binning, (5, )) + } + + select_rows(w.varview, (0, 1)) + self.assertTrue(w.button_group.button(Methods.FixedWidth).isChecked()) + self.assertTrue(w.button_group.button(Methods.FixedWidth).isEnabled()) + self.assertFalse(w.button_group.button(Methods.FixedWidthTime).isEnabled()) + self.assertTrue(w.button_group.button(Methods.Custom).isEnabled()) + self.assertTrue(w.copy_to_custom.isEnabled()) + self.assertEqual(w.width_line.text(), "10") + + select_rows(w.varview, (1, 2)) + self.assertFalse(w.button_group.button(Methods.FixedWidth).isChecked()) + self.assertTrue(w.button_group.button(Methods.FixedWidth).isEnabled()) + self.assertFalse(w.button_group.button(Methods.FixedWidthTime).isEnabled()) + self.assertTrue(w.button_group.button(Methods.Custom).isEnabled()) + self.assertTrue(w.copy_to_custom.isEnabled()) + + select_rows(w.varview, (2, 4)) + self.assertFalse(w.button_group.button(Methods.FixedWidth).isChecked()) + self.assertFalse(w.button_group.button(Methods.FixedWidth).isEnabled()) + self.assertFalse(w.button_group.button(Methods.FixedWidthTime).isEnabled()) + self.assertFalse(w.button_group.button(Methods.Custom).isEnabled()) + + select_rows(w.varview, (3, 4)) + self.assertFalse(w.button_group.button(Methods.FixedWidth).isChecked()) + self.assertFalse(w.button_group.button(Methods.FixedWidth).isEnabled()) + self.assertTrue(w.button_group.button(Methods.FixedWidthTime).isEnabled()) + self.assertFalse(w.button_group.button(Methods.Custom).isEnabled()) + self.assertFalse(w.copy_to_custom.isEnabled()) + + select_rows(w.varview.default_view, (0, )) + self.assertEqual(len(w.varview.selectionModel().selectedIndexes()), 0) + self.assertFalse(w.button_group.button(Methods.FixedWidth).isChecked()) + self.assertTrue(w.button_group.button(Methods.FixedWidth).isEnabled()) + self.assertTrue(w.button_group.button(Methods.FixedWidthTime).isEnabled()) + self.assertTrue(w.button_group.button(Methods.Custom).isEnabled()) + self.assertFalse(w.copy_to_custom.isEnabled()) + self.assertFalse(w.button_group.button(Methods.Default).isEnabled()) + w._check_button(Methods.FixedWidth, True) + self.assertTrue(w.button_group.button(Methods.FixedWidth).isChecked()) + + select_rows(w.varview, (3, )) + self.assertEqual(len(w.varview.default_view.selectionModel().selectedIndexes()), 0) + self.assertFalse(w.button_group.button(Methods.FixedWidth).isChecked()) + self.assertFalse(w.button_group.button(Methods.FixedWidth).isEnabled()) + self.assertTrue(w.button_group.button(Methods.FixedWidthTime).isEnabled()) + self.assertFalse(w.button_group.button(Methods.Custom).isEnabled()) + self.assertTrue(w.button_group.button(Methods.Default).isEnabled()) + + def test_update_hints(self): + w = self.widget + update_disc = w._update_discretizations + w._update_discretizations = Mock() + w.width_line.setText("10") + self.send_signal(w.Inputs.data, self.data) + w.var_hints = { + DefaultKey: DefaultHint, + ("x", False): VarHint(Methods.EqualFreq, (3, )), + ("y", False): VarHint(Methods.EqualFreq, (3, )), + ("z", False): VarHint(Methods.EqualFreq, (4, )), + ("t", True): VarHint(Methods.Binning, (5, )) + } + update_disc() + self.assertEqual(len(w.discretized_vars), 5) + + select_rows(w.varview, (0, )) + w.button_group.button(Methods.Default).click() + self.assertNotIn(("x", False), w.var_hints) + # Check that "x" is invalidated + self.assertEqual(len(w.discretized_vars), 4) + self.assertNotIn(("x", False), w.discretized_vars) + update_disc() + self.assertEqual(len(w.discretized_vars), 5) + self.assertIn(("x", False), w.discretized_vars) + + select_rows(w.varview, (0, 1)) + w.button_group.button(Methods.FixedWidth).click() + self.assertEqual(w.var_hints[("x", False)], + VarHint(Methods.FixedWidth, ("10", ))) + self.assertEqual(w.var_hints[("y", False)], + VarHint(Methods.FixedWidth, ("10", ))) + # Check that "x" and "y" are invalidated + self.assertEqual(len(w.discretized_vars), 3) + self.assertNotIn(("x", False), w.discretized_vars) + self.assertNotIn(("y", False), w.discretized_vars) + update_disc() + self.assertEqual(len(w.discretized_vars), 5) + self.assertIn(("x", False), w.discretized_vars) + self.assertIn(("y", False), w.discretized_vars) + + w.width_line.setText("5") + self.assertEqual(w.var_hints[("x", False)], + VarHint(Methods.FixedWidth, ("5", ))) + self.assertEqual(w.var_hints[("y", False)], + VarHint(Methods.FixedWidth, ("5", ))) + # Check that "x" and "y" are invalidated + self.assertEqual(len(w.discretized_vars), 3) + self.assertNotIn(("x", False), w.discretized_vars) + self.assertNotIn(("y", False), w.discretized_vars) + update_disc() + self.assertEqual(len(w.discretized_vars), 5) + self.assertIn(("x", False), w.discretized_vars) + self.assertIn(("y", False), w.discretized_vars) + + select_rows(w.varview.default_view, (0, )) + w.button_group.button(Methods.FixedWidth).click() + self.assertEqual(len(w.discretized_vars), 4) + self.assertNotIn(("u", True), w.discretized_vars) + update_disc() + self.assertEqual(len(w.discretized_vars), 5) + self.assertIn(("u", True), w.discretized_vars) + + def test_discretize_var(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data) + + x = self.data.domain["x"] + t = self.data.domain["t"] + + s, dvar = w._discretize_var(x, VarHint(Methods.FixedWidthTime, ("10", 0))) + self.assertIn("keep", s) + self.assertIs(dvar, x) + + s, dvar = w._discretize_var(t, VarHint(Methods.FixedWidth, ("10", ))) + self.assertIn("keep", s) + self.assertIs(dvar, t) + + try: + Options[42] = Mock() + + # Errored + # Unit test - mocked function + Options[42].function = lambda *_: "foo error" + s, dvar = w._discretize_var(t, VarHint(42, ())) + self.assertIn("foo error", s) + self.assertIsNone(dvar) + # Real error + s, dvar = w._discretize_var(t, VarHint(Methods.MDL, ())) + self.assertIn("<", s) + self.assertIsNone(dvar) + + # Removed attribute + Options[42].function = lambda *_: None + s, dvar = w._discretize_var(t, VarHint(42, ())) + self.assertEqual("", s) + self.assertIsNone(dvar) + # Really removed + s, dvar = w._discretize_var(t, VarHint(Methods.Remove, ())) + self.assertEqual("", s) + self.assertIsNone(dvar) + + # No intervals + var = Mock(compute_value=Mock(points=[])) + Options[42].function = lambda *_: var + s, dvar = w._discretize_var(t, VarHint(42, ())) + self.assertIn("removed", s) + self.assertIsNone(dvar) + s, dvar = w._discretize_var(x, VarHint(Methods.FixedWidth, ("1000", ))) + self.assertIn("removed", s) + self.assertIsNone(dvar) + + # All fine + var = Mock(compute_value=Mock(points=[1, 2, 3])) + Options[42].function = lambda *_: var + s, dvar = w._discretize_var(t, VarHint(42, ())) + self.assertIn("1, 2, 3", s) + self.assertIs(dvar, var) + s, dvar = w._discretize_var(x, VarHint(Methods.EqualWidth, (3, ))) + self.assertEqual(dvar.compute_value.points, [5, 10]) + + finally: + del Options[42] + + def test_update_discretizations(self): + w = self.widget + # Def: Keep, x: EqFreq 3, y: Keep, z: Remove, t (time): Bin 2, u (time): + w.var_hints = self.var_hints + y, t, u = map(self.domain.__getitem__, "ytu") + + # no data: do nothing, but don't crash + w._update_discretizations() + + self.send_signal(w.Inputs.data, self.data) + d = w.discretized_vars + self.assertEqual(len(d), 5) + self.assertEqual(len(d[("x", False)].values), 3) + self.assertIs(d[("y", False)], y) + self.assertIsNone(d[("z", False)]) + self.assertIsNot(d[("t", True)], t) + self.assertIsNotNone(d[("t", True)], t) + self.assertIs(d[("u", True)], u) + + d[("t", True)] = t + del d[("x", False)] + del d[("u", True)] + w._update_discretizations() + self.assertEqual(len(d[("x", False)].values), 3) + self.assertIs(d[("t", True)], t) + self.assertIs(d[("u", True)], u) + + w.var_hints[None] = VarHint(Methods.Remove, ()) + del d[("u", True)] + w._update_discretizations() + self.assertIsNone(d[("u", True)]) + + def test_copy_to_manual(self): + w = self.widget + w.var_hints = { DefaultKey: VarHint(Methods.EqualFreq, (5, )) } + self.send_signal(w.Inputs.data, self.data) + w.button_group.button(Methods.MDL).setChecked(True) + + select_rows(w.varview, (0, 2)) + self.assertTrue(w.copy_to_custom.isEnabled()) + w.copy_to_custom.click() + self.assertFalse(any(w.button_group.button(i).isChecked() + for i in Methods)) + self.assertEqual(w.var_hints[("x", False)], + VarHint(Methods.Custom, ('2.5, 7.5, 12.5', ))) + self.assertEqual(w.var_hints[("z", False)], + VarHint(Methods.Custom, ('4.5, 9.5, 14.5', ))) + self.assertNotIn(("y", False), w.var_hints) + + select_rows(w.varview, (1, )) + self.assertTrue(w.copy_to_custom.isEnabled()) + w.copy_to_custom.click() + self.assertTrue(w.button_group.button(Methods.Custom).isChecked()) + self.assertEqual(w.var_hints[("x", False)], + VarHint(Methods.Custom, ('2.5, 7.5, 12.5', ))) + self.assertEqual(w.var_hints[("z", False)], + VarHint(Methods.Custom, ('4.5, 9.5, 14.5', ))) + self.assertEqual(w.var_hints[("y", False)], + VarHint(Methods.Custom, ('3.5, 8.5, 13.5', ))) + self.assertEqual(w.threshold_line.text(), '3.5, 8.5, 13.5') + + select_rows(w.varview, (1, 4)) + w.copy_to_custom.click() + self.assertNotIn(("u", False), w.var_hints) + + def test_migration_2_3(self): + # Obsolete, don't want to cause confusion by public import + # pylint: disable=import-outside-toplevel + from Orange.widgets.data.owdiscretize import \ + Default, EqualFreq, Leave, Custom, MDL, EqualWidth, DState + context_values = { + 'saved_var_states': + ({(2, 'age'): DState(method=Leave()), + (2, 'rest SBP'): DState(method=EqualWidth(k=4)), + (2, 'cholesterol'): DState(method=EqualFreq(k=6)), + (4, 'max HR'): DState( + method=Custom(points=(1.0, 2.0, 3.0))), + (2, 'ST by exercise'): DState(method=MDL()), + (2, 'major vessels colored'): + DState(method=Default(method=EqualFreq(k=3)))}, -2), + '__version__': 2} + + settings = {'autosend': True, 'controlAreaVisible': True, + 'default_cutpoints': (), 'default_k': 3, + 'default_method_name': 'EqualFreq', + '__version__': 2, + "context_settings": [Context(values=context_values)]} + + OWDiscretize.migrate_settings(settings, 2) + self.assertNotIn("default_method_name", settings) + self.assertNotIn("default_k", settings) + self.assertNotIn("default_cutpoints", settings) + self.assertNotIn("context_settings", settings) + self.assertEqual( + settings["var_hints"], + {None: VarHint(Methods.EqualFreq, (3,)), + ('ST by exercise', False): VarHint(Methods.MDL, ()), + ('age', False): VarHint(Methods.Keep, ()), + ('cholesterol', False): VarHint(Methods.EqualFreq, (6,)), + ('max HR', True): VarHint(Methods.Custom, (('1, 2, 3'),)), + ('rest SBP', False): VarHint(Methods.EqualWidth, (4,))}) class TestValidator(unittest.TestCase): def test_validate(self): v = IncreasingNumbersListValidator() - self.assertEqual(v.validate("", 0), (v.Acceptable, '', 0)) + self.assertEqual(v.validate("", 0), (v.Intermediate, '', 0)) self.assertEqual(v.validate("1", 1), (v.Acceptable, '1', 1)) - self.assertEqual(v.validate(",", 0), (v.Acceptable, ',', 0)) + self.assertEqual(v.validate(",", 0), (v.Intermediate, ',', 0)) self.assertEqual(v.validate("-", 0), (v.Intermediate, '-', 0)) - self.assertEqual(v.validate("1,,", 1), (v.Acceptable, '1,,', 1)) - self.assertEqual(v.validate("1,a,", 1), (v.Invalid, '1,a,', 1)) + self.assertEqual(v.validate("1,,", 1), (v.Intermediate, '1,,', 1)) + self.assertEqual(v.validate("1,a,", 1), (v.Invalid, '1,a,', 3)) self.assertEqual(v.validate("a", 1), (v.Invalid, 'a', 1)) self.assertEqual(v.validate("1,1", 0), (v.Intermediate, '1,1', 0)) self.assertEqual(v.validate("1,12", 0), (v.Acceptable, '1,12', 0)) - def test_fixup(self): - v = IncreasingNumbersListValidator() - self.assertEqual(v.fixup(""), "") - self.assertEqual(v.fixup("1,,2"), "1, 2") - self.assertEqual(v.fixup("1,,"), "1") - self.assertEqual(v.fixup("1,"), "1") - self.assertEqual(v.fixup(",1"), "1") - self.assertEqual(v.fixup(","), "") + self.assertEqual(v.validate("1, 2 ", 5), (v.Intermediate, "1, 2, ", 6)) -class TestDelegate(GuiTest): +class TestModels(WidgetTest, DataMixin): + def setUp(self): + self.prepare_data() + self.widget = self.create_widget(OWDiscretize) + def test_delegate(self): - cases = ( - (DState(Default(Leave()), None, None), ""), - (DState(Leave(), None, None), "(leave)"), - (DState(MDL(), [1], None), "(entropy)"), - (DState(MDL(), [], None), ""), - (DState(EqualFreq(2), [1], None), "(equal frequency k=2)"), - (DState(EqualWidth(2), [1], None), "(equal width k=2)"), - (DState(Remove(), None, None), "(removed)"), - (DState(Custom([1]), None, None), "(custom)"), + self.prepare_data() + w = self.widget + w.var_hints = self.var_hints + # Def: Keep, x: EqFreq 3, y: Keep, z: Remove, t (time): Bin 2, u (time): + self.send_signal(w.Inputs.data, self.data) + + model = w.varview.model() + delegate: ListViewSearch.DiscDelegate = w.varview.itemDelegate() + option = QStyleOptionViewItem() + delegate.initStyleOption(option, model.index(0)) + self.assertTrue(option.font.bold()) + + option = QStyleOptionViewItem() + delegate.initStyleOption(option, model.index(4)) + self.assertFalse(option.font.bold()) + + def test_layout(self): + # Not much to test, just don't crash + self.widget.varview.updateGeometries() + + def test_model(self): + self.prepare_data() + w = self.widget + w.var_hints = self.var_hints + # Def: Keep, x: EqFreq 3, y: Keep, z: Remove, t (time): Bin 2, u (time): + self.send_signal(w.Inputs.data, self.data) + + model = w.varview.model() + display = model.index(0).data() + self.assertIn("x", display) + self.assertIn("freq", display) + self.assertIn("3", display) + self.assertIn( + str(w.discretized_vars[("x", False)].compute_value.points[0])[:3], + display) + + tooltip = model.index(0).data(Qt.ToolTipRole) + self.assertIn("x", tooltip) + self.assertIn( + str(w.discretized_vars[("x", False)].compute_value.points[0])[:3], + tooltip) + + display = model.index(1).data() + self.assertIn("y", display) + self.assertIn("keep", display) + + self.assertIsNone(model.index(1).data(Qt.ToolTipRole)) + + w.var_hints[("x", False)] = VarHint(Methods.EqualWidth, (7, )) + del w.discretized_vars[("x", False)] + w._update_discretizations() + display = model.index(0).data() + self.assertIn("x", display) + self.assertIn("width", display) + self.assertIn("3", display) + self.assertIn( + str(w.discretized_vars[("x", False)].compute_value.points[0])[:3], + display) + + +class TestDiscModel(GuiTest, DataMixin): + def setUp(self) -> None: + super().setUp() + self.prepare_data() + + def test_model(self): + model = DiscDomainModel() + model.set_domain(self.domain) + index = model.index(0) + self.assertEqual(index.data(Qt.DisplayRole), "x") + self.assertIn("x", index.data(Qt.ToolTipRole), "x") + model.setData( + index, + DiscDesc( + VarHint(Methods.EqualFreq, (3, )), "1, 2", ("1", "2")), + Qt.UserRole ) - delegate = DiscDelegate() - var = DiscreteVariable("C", ("a", "b")) - model = VariableListModel() - model.append(var) - for state, text in cases: - model.setData(model.index(0), state, Qt.UserRole) - option = QStyleOptionViewItem() - delegate.initStyleOption(option, model.index(0)) - self.assertIn(text, option.text) - - -class TestShowTip(GuiTest): + self.assertTrue(index.data(Qt.DisplayRole).startswith("x: ")) + self.assertIn("2", index.data(Qt.ToolTipRole)) + + +class TestDefaultDiscModel(GuiTest): + def test_counts(self): + model = DefaultDiscModel() + self.assertEqual(model.rowCount(QModelIndex()), 1) + self.assertEqual(model.rowCount(model.index(0)), 0) + + self.assertEqual(model.columnCount(QModelIndex()), 1) + self.assertEqual(model.columnCount(model.index(0)), 0) + + def test_data(self): + model = DefaultDiscModel() + self.assertIn(format_desc(DefaultHint), model.index(0).data()) + self.assertIsInstance(model.index(0).data(Qt.DecorationRole), QIcon) + self.assertIsInstance(model.index(0).data(Qt.ToolTipRole), str) + + hint = VarHint(Methods.FixedWidth, ("314", )) + model.setData(model.index(0), hint, Qt.UserRole) + self.assertIn(format_desc(hint), model.index(0).data()) + self.assertIsInstance(model.index(0).data(Qt.DecorationRole), QIcon) + self.assertIsInstance(model.index(0).data(Qt.ToolTipRole), str) + + + +class TestUtils(GuiTest): def test_show_tip(self): w = QWidget() + show_tip = IncreasingNumbersListValidator.show_tip show_tip(w, QPoint(100, 100), "Ha Ha") app = QApplication.instance() windows = app.topLevelWidgets() @@ -185,3 +610,94 @@ def test_show_tip(self): self.assertTrue(label.text() == "Ha") show_tip(w, QPoint(100, 100), "") self.assertFalse(label.isVisible()) + + def test_format_desc(self): + self.assertEqual(format_desc(VarHint(Methods.MDL, ())), + Options[Methods.MDL].short_desc) + self.assertEqual(format_desc(VarHint(Methods.EqualWidth, ("10", ))), + Options[Methods.EqualWidth].short_desc.format(10)) + self.assertEqual(format_desc(None), + Options[Methods.Default].short_desc) + + fwt = Methods.FixedWidthTime + desc = Options[fwt].short_desc.format + self.assertEqual(format_desc(VarHint(fwt, ("1", 0))), desc("1", "year")) + self.assertEqual(format_desc(VarHint(fwt, ("2", 0))), desc("2", "years")) + self.assertEqual(format_desc(VarHint(fwt, ("1", 2))), desc("1", "day")) + self.assertEqual(format_desc(VarHint(fwt, ("2", 2))), desc("2", "days")) + self.assertEqual(format_desc(VarHint(fwt, ("x", 2))), desc("x", "day(s)")) + self.assertEqual(format_desc(VarHint(fwt, ("", 2))), desc("", "day(s)")) + + def test_fixed_width_disc(self): + fw = partial(_fixed_width_discretization, None, None) + for arg in ("", "5.3.1", "abc", "-5", "0"): + self.assertIsInstance(fw(arg), str) + + with patch("Orange.preprocess.discretize.FixedWidth") as disc: + self.assertNotIsInstance(fw("5.13"), str) + disc.assert_called_with(5.13, 2) + + self.assertNotIsInstance(fw("5"), str) + disc.assert_called_with(5, 0) + + with patch("Orange.preprocess.discretize.FixedWidth", + side_effect=TooManyIntervals): + self.assertIsInstance(fw("42"), str) + + def test_fixed_time_width_disc(self): + ftw = partial(_fixed_time_width_discretization, None, None) + + for arg in ("", "5.3.1", "5.3", "abc", "-5", "0"): + self.assertIsInstance(ftw(arg, 1), str) + + with patch("Orange.preprocess.discretize.FixedTimeWidth") as disc: + self.assertNotIsInstance(ftw("5", 2), str) + disc.assert_called_with(5, 2) + + self.assertNotIsInstance(ftw("5", 3), str) + disc.assert_called_with(35, 2) + + self.assertNotIsInstance(ftw("5", 4), str) + disc.assert_called_with(5, 3) + + with patch("Orange.preprocess.discretize.FixedTimeWidth", + side_effect=TooManyIntervals): + self.assertIsInstance(ftw("42", 3), str) + + def test_custom_discretization(self): + cd = partial(_custom_discretization, None, None) + + for arg in ("", "4 5", "2, 1, 5", "1, foo, 13"): + self.assertIsInstance(cd(arg), str) + + with patch("Orange.preprocess.discretize.Discretizer." + "create_discretized_var") as disc: + cd("1, 1.25, 1.5, 4") + disc.assert_called_with(None, [1, 1.25, 1.5, 4]) + + def test_mdl_discretization(self): + mdl = _mdl_discretization + data = Table("iris")[::10] + var = data.domain[0] + with patch("Orange.preprocess.discretize.EntropyMDL") as mdldisc: + mdl(data, var) + mdldisc.return_value.assert_called_with(data, var) + mdldisc.reset_mock() + + data = data[:, :4] + self.assertIsInstance(mdl(data, var), str) + mdldisc.assert_not_called() + + data = data.transform(Domain(data.domain[:3], data.domain[3])) + self.assertIsInstance(mdl(data, var), str) + mdldisc.assert_not_called() + + def test_var_key(self): + self.assertEqual(variable_key(ContinuousVariable("foo")), + ("foo", False)) + self.assertEqual(variable_key(TimeVariable("bar")), + ("bar", True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/data/tests/test_oweditdomain.py b/Orange/widgets/data/tests/test_oweditdomain.py index 4622e16d6d1..5c0912e0584 100644 --- a/Orange/widgets/data/tests/test_oweditdomain.py +++ b/Orange/widgets/data/tests/test_oweditdomain.py @@ -2,22 +2,23 @@ # pylint: disable=all import pickle import unittest -from itertools import product +from datetime import datetime, timezone +from functools import partial +from itertools import product, chain from unittest import TestCase from unittest.mock import Mock, patch import numpy as np from numpy.testing import assert_array_equal -import pandas as pd -from AnyQt.QtCore import QItemSelectionModel, Qt, QItemSelection +from AnyQt.QtCore import QItemSelectionModel, Qt, QItemSelection, QPoint +from AnyQt.QtGui import QPalette, QColor, QHelpEvent from AnyQt.QtWidgets import QAction, QComboBox, QLineEdit, \ - QStyleOptionViewItem, QDialog, QMenu + QStyleOptionViewItem, QDialog, QMenu, QToolTip, QListView from AnyQt.QtTest import QTest, QSignalSpy -from Orange.widgets.utils import colorpalettes +from orangewidget.settings import Context from orangewidget.tests.utils import simulate -from orangewidget.utils.itemmodels import PyListModel from Orange.data import ( ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable, @@ -33,12 +34,16 @@ AsString, AsCategorical, AsContinuous, AsTime, table_column_data, ReinterpretVariableEditor, CategoricalVector, VariableEditDelegate, TransformRole, - RealVector, TimeVector, StringVector, make_dict_mapper, DictMissingConst, - LookupMappingTransform, as_float_or_nan, column_str_repr, time_parse, - GroupItemsDialog) + RealVector, TimeVector, StringVector, make_dict_mapper, + LookupMappingTransform, as_float_or_nan, column_str_repr, + GroupItemsDialog, VariableListModel, StrpTime, RestoreOriginal, BaseEditor, + RestoreWarningRole, TimeUnit +) from Orange.widgets.data.owcolor import OWColor, ColorRole from Orange.widgets.tests.base import WidgetTest, GuiTest from Orange.widgets.tests.utils import contextMenu +from Orange.widgets.utils.itemmodels import select_row +from Orange.widgets.utils import colorpalettes from Orange.tests import test_filename, assert_array_nanequal MArray = np.ma.MaskedArray @@ -46,26 +51,26 @@ class TestReport(TestCase): def test_rename(self): - var = Real("X", (-1, ""), (), False) + var = Real("X", (-1, ""), ()) tr = Rename("Y") val = report_transform(var, [tr]) self.assertIn("X", val) self.assertIn("Y", val) def test_annotate(self): - var = Real("X", (-1, ""), (("a", "1"), ("b", "z")), False) + var = Real("X", (-1, ""), (("a", "1"), ("b", "z"))) tr = Annotate((("a", "2"), ("j", "z"))) r = report_transform(var, [tr]) self.assertIn("a", r) self.assertIn("b", r) def test_unlinke(self): - var = Real("X", (-1, ""), (("a", "1"), ("b", "z")), True) + var = Real("X", (-1, ""), (("a", "1"), ("b", "z"))) r = report_transform(var, [Unlink()]) self.assertIn("unlinked", r) def test_categories_mapping(self): - var = Categorical("C", ("a", "b", "c"), (), False) + var = Categorical("C", ("a", "b", "c"), ()) tr = CategoriesMapping( (("a", "aa"), ("b", None), @@ -79,7 +84,7 @@ def test_categories_mapping(self): self.assertIn("", r) def test_categorical_merge_mapping(self): - var = Categorical("C", ("a", "b1", "b2"), (), False) + var = Categorical("C", ("a", "b1", "b2"), ()) tr = CategoriesMapping( (("a", "a"), ("b1", "b"), @@ -90,12 +95,19 @@ def test_categorical_merge_mapping(self): self.assertIn('b', r) def test_reinterpret(self): - var = String("T", (), False) + var = String("T", ()) for tr in (AsContinuous(), AsCategorical(), AsTime()): t = report_transform(var, [tr]) self.assertIn("→ (", t) +def enter_text(widget: QLineEdit, text: str): + widget.selectAll() + QTest.keyClick(widget, Qt.Key.Key_Delete) + QTest.keyClicks(widget, text) + QTest.keyClick(widget, Qt.Key.Key_Return) + + class TestOWEditDomain(WidgetTest): def setUp(self): self.widget = self.create_widget(OWEditDomain) @@ -178,14 +190,14 @@ def test_output_data(self): def test_input_from_owcolor(self): """Check widget's data sent from OWColor widget""" owcolor = self.create_widget(OWColor) - self.send_signal("Data", self.iris, widget=owcolor) + self.send_signal(owcolor.Inputs.data, self.iris) disc_model = owcolor.disc_model disc_model.setData(disc_model.index(0, 1), (1, 2, 3), ColorRole) cont_model = owcolor.cont_model palette = list(colorpalettes.ContinuousPalettes.values())[-1] cont_model.setData(cont_model.index(1, 1), palette, ColorRole) - owcolor_output = self.get_output("Data", owcolor) - self.send_signal("Data", owcolor_output) + owcolor_output = self.get_output(owcolor.Outputs.data) + self.send_signal(owcolor_output) self.assertEqual(self.widget.data, owcolor_output) np.testing.assert_equal(self.widget.data.domain.class_var.colors[0], (1, 2, 3)) @@ -253,13 +265,6 @@ def test_duplicate_names(self): self.widget.domain_view.setCurrentIndex(idx) editor = self.widget.findChild(ContinuousVariableEditor) - def enter_text(widget, text): - # type: (QLineEdit, str) -> None - widget.selectAll() - QTest.keyClick(widget, Qt.Key_Delete) - QTest.keyClicks(widget, text) - QTest.keyClick(widget, Qt.Key_Return) - enter_text(editor.name_edit, "iris") self.widget.commit() self.assertTrue(self.widget.Error.duplicate_var_name.is_shown()) @@ -272,7 +277,7 @@ def enter_text(widget, text): output = self.get_output(self.widget.Outputs.data) self.assertIsInstance(output, Table) - def test_unlink(self): + def test_unlink_inherited(self): var0, var1, var2 = [ContinuousVariable("x", compute_value=Mock()), ContinuousVariable("y", compute_value=Mock()), ContinuousVariable("z")] @@ -284,7 +289,6 @@ def test_unlink(self): for i in range(3): self.widget.domain_view.setCurrentIndex(index(i)) editor = self.widget.findChild(ContinuousVariableEditor) - self.assertIs(editor.unlink_var_cb.isEnabled(), i < 2) editor._set_unlink(i == 1) self.widget.commit() @@ -298,6 +302,31 @@ def test_unlink(self): self.assertIsNone(out1.compute_value) self.assertIsNone(out2.compute_value) + def test_unlink_forward(self): + var0, var1, var2, var3 = [ContinuousVariable("x", compute_value=Mock()), + ContinuousVariable("y", compute_value=Mock()), + ContinuousVariable("z"), + ContinuousVariable("w")] + domain = Domain([var0, var1, var2, var3], None) + table = Table.from_numpy(domain, np.zeros((5, 4)), np.zeros((5, 0))) + self.send_signal(self.widget.Inputs.data, table) + + index = self.widget.domain_view.model().index + for i in [0, 2, 3]: + self.widget.domain_view.setCurrentIndex(index(i)) + editor = self.widget.findChild(ContinuousVariableEditor) + editor.name_edit.setText(f"v{i}") + editor.on_name_changed() + editor._set_unlink(i != 3) + + self.widget.commit() + out = self.get_output(self.widget.Outputs.data) + out0, out1, out2, out3 = out.domain.variables + self.assertIsNone(out0.compute_value) + self.assertIsNotNone(out1.compute_value) + self.assertIsNone(out2.compute_value) + self.assertIsNotNone(out3.compute_value) + def test_time_variable_preservation(self): """Test if time variables preserve format specific attributes""" table = Table(test_filename("datasets/cyber-security-breaches.tab")) @@ -314,17 +343,45 @@ def test_time_variable_preservation(self): output = self.get_output(self.widget.Outputs.data) self.assertEqual(str(table[0, 4]), str(output[0, 4])) + def test_custom_format(self): + time_variable = StringVariable("Date") + data = [ + ["2024-001"], + ["2024-032"], + ["2024-150"], + ["2024-365"] + ] + table = Table.from_list(Domain([], metas=[time_variable]), data) + self.send_signal(self.widget.Inputs.data, table) + index = self.widget.variables_view.model().index + self.widget.variables_view.setCurrentIndex(index(0)) + + editor = self.widget.findChild(VariableEditor) + tc = editor.layout().currentWidget().findChild(QComboBox, + name="type-combo") + # time variable editor + simulate.combobox_activate_item(tc, Time, Qt.ItemDataRole.UserRole) + le = editor.layout().currentWidget().findChild(QLineEdit, name="custom-format-line-edit") + enter_text(le, "%Y-%j") + + self.widget.commit() + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(table.metas[0, 0], "2024-001") + self.assertEqual(output.metas[0, 0], + datetime.strptime("2024-001", + "%Y-%j").replace( + tzinfo=timezone.utc).timestamp()) + def test_restore(self): iris = self.iris viris = ( "Categorical", - ("iris", ("Iris-setosa", "Iris-versicolor", "Iris-virginica"), (), - False) + ("iris", ("Iris-setosa", "Iris-versicolor", "Iris-virginica"), ()) ) w = self.widget def restore(state): - w._domain_change_store = state + w._domain_change_hints = state w._restore() model = w.variables_model @@ -337,13 +394,361 @@ def restore(state): tr = model.data(model.index(4), TransformRole) self.assertEqual(tr, [AsString(), Rename("Z")]) + restore({viris: [("CategoriesMapping", ([("Iris-setosa", "setosa"), + ("Iris-versicolor", "versicolor"), + ("Iris-virginica", "virginica")],)), + ("Rename", ("Species",))]}) + tr = model.data(model.index(4), TransformRole) + self.assertEqual(tr, [CategoriesMapping([("Iris-setosa", "setosa"), + ("Iris-versicolor", "versicolor"), + ("Iris-virginica", "virginica")]), + Rename("Species")]) + + viris_1 = ("Categorical", ("iris", ("A", "B"), ())) + restore({viris_1: [("Rename", ("K",),), + ("CategoriesMapping", ([("A", "AA"), ("B", "BB")],))]}) + self.assertTrue(w.Warning.cat_mapping_does_not_apply.is_shown()) + w.commit() + output = self.get_output(w.Outputs.data) + self.assertEqual(output.domain.class_var.name, "K") + self.assertEqual(output.domain.class_var.values, + ("Iris-setosa", "Iris-versicolor", "Iris-virginica")) + + restore({viris_1: [("Rename", ("K",),), + ("CategoriesMapping", ([("A", "AA")],))]}) + self.assertTrue(w.Warning.cat_mapping_does_not_apply.is_shown()) + w.reset_all() + self.assertFalse(w.Warning.cat_mapping_does_not_apply.is_shown()) + + select_row(w.variables_view, 4) + w.reset_selected() + self.assertFalse(w.Warning.cat_mapping_does_not_apply.is_shown()) + + restore({viris: [("Rename", ("A")), ("NonexistantTransform", ("AA",))]}) + tr = model.data(model.index(4), TransformRole) + self.assertEqual(tr, [Rename("A")]) + self.assertTrue(w.Warning.transform_restore_failed.is_shown()) + + def test_reset_selected(self): + w = self.widget + model = w.domain_view.model() + sel_model = w.domain_view.selectionModel() + + self.send_signal(self.iris) + model.setData(model.index(1, 0), [Rename("foo")], TransformRole) + model.setData(model.index(2, 0), [AsCategorical()], TransformRole) + model.setData(model.index(3, 0), [Rename("bar")], TransformRole) + w.commit() + out = self.get_output() + self.assertEqual([var.name for var in out.domain.attributes], + ["sepal length", "foo", "petal length", "bar"]) + self.assertIsInstance(out.domain[2], DiscreteVariable) + + sel_model.select(model.index(0, 0), QItemSelectionModel.Select) + sel_model.select(model.index(2, 0), QItemSelectionModel.Select) + sel_model.select(model.index(3, 0), QItemSelectionModel.Select) + w.reset_selected() + w.commit() + out = self.get_output() + self.assertEqual([var.name for var in out.domain.attributes], + ["sepal length", "foo", "petal length", "petal width"]) + self.assertIsInstance(out.domain[2], ContinuousVariable) + + @patch("Orange.widgets.data.oweditdomain.ReinterpretVariableEditor.set_data") + def test_selection_sets_data(self, set_data): + w = self.widget + model = w.domain_view.model() + sel_model = w.domain_view.selectionModel() + tr = (Rename("x"), ) + + iris = self.iris + + self.send_signal(iris) + model.setData(model.index(1, 0), tr, TransformRole) + + sel_model.select(model.index(1, 0), QItemSelectionModel.ClearAndSelect) + args, kwargs = set_data.call_args + self.assertEqual(len(args), 1) + self.assertEqual(len(args[0]), 1) + self.assertEqual(args[0][0].vtype.name, iris.domain[1].name) + self.assertEqual(kwargs["transforms"], [tr]) + + sel_model.select(model.index(2, 0), QItemSelectionModel.Select) + args, kwargs = set_data.call_args + self.assertEqual(len(args), 1) + self.assertEqual(len(args[0]), 2) + self.assertEqual(args[0][0].vtype.name, iris.domain[1].name) + self.assertEqual(args[0][1].vtype.name, iris.domain[2].name) + self.assertEqual(kwargs["transforms"], [tr, ()]) + + def test_selection_after_new_data(self): + w = self.widget + model = w.domain_view.model() + sel_model = w.domain_view.selectionModel() + iris = self.iris + attrs = iris.domain.attributes + + self.send_signal(iris.transform(Domain(attrs[:3]))) + sel_model.select(model.index(1, 0), QItemSelectionModel.ClearAndSelect) + sel_model.select(model.index(2, 0), QItemSelectionModel.Select) + # Select #1 and #2, out of attributes 0, 1, 2 + self.assertEqual(w.selected_var_indices(), [1, 2]) + + # Send attributes 1, 2, 3; #0 and #1 must be selected + self.send_signal(iris.transform(Domain(attrs[1:]))) + self.assertEqual(w.selected_var_indices(), [0, 1]) + + # Now send 0 and 2; only #1 (2) must be selected + self.send_signal(iris.transform(Domain([attrs[0], attrs[2]]))) + self.assertEqual(w.selected_var_indices(), [1]) + + # Send 0 and 3, first must be selected by default + self.send_signal(iris.transform(Domain([attrs[0], attrs[3]]))) + self.assertEqual(w.selected_var_indices(), [0]) + + # Send 1 and 2; first is selected by default + self.send_signal(iris.transform(Domain([attrs[1], attrs[2]]))) + self.assertEqual(w.selected_var_indices(), [0]) + + def test_hint_keeping(self): + editor: ContinuousVariableEditor = self.widget.findChild(ContinuousVariableEditor) + name_edit = editor.name_edit + model = self.widget.domain_view.model() + + def rename(fr, to): + for idx in range(fr, to): + self.widget.domain_view.setCurrentIndex(model.index(idx)) + cur_text = name_edit.text() + if cur_text[0] != "x": + name_edit.setText("x" + cur_text) + editor.on_name_changed() + + def data(fr, to): + return Table.from_numpy(Domain(vars[fr:to]), + np.zeros((1, to - fr))) + + + vars = [ContinuousVariable(f"v{i}") for i in range(1020)] + self.send_signal(data(0, 5)) + rename(2, 4) + + self.send_signal(None) + self.assertIsNone(self.get_output()) + + self.send_signal(data(3, 7)) + outp = self.get_output() + self.assertEqual([var.name for var in outp.domain.attributes], + ["xv3", "v4", "v5", "v6"]) + + self.send_signal(data(0, 5)) + outp = self.get_output() + self.assertEqual([var.name for var in outp.domain.attributes], + ["v0", "v1", "xv2", "xv3", "v4"]) + + self.send_signal(data(3, 7)) + outp = self.get_output() + self.assertEqual([var.name for var in outp.domain.attributes], + ["xv3", "v4", "v5", "v6"]) + + # This is too large: widget should retain just hints related to + # the current data + self.send_signal(data(3, 1020)) + outp = self.get_output() + self.assertEqual([var.name for var in outp.domain.attributes[:4]], + ["xv3", "v4", "v5", "v6"]) + rename(5, 1017) + self.widget.commit() + outp = self.get_output() + self.assertEqual([var.name for var in outp.domain.attributes[-3:]], + ["xv1017", "xv1018", "xv1019"]) + + self.send_signal(None) + self.assertIsNone(self.get_output()) + + # Tests that hints for the current data are kept + # - including the earliest (v3) and latest (v1019) + self.send_signal(data(3, 1020)) + outp = self.get_output() + self.assertEqual([var.name for var in outp.domain.attributes[:4]], + ["xv3", "v4", "v5", "v6"]) + self.assertEqual([var.name for var in outp.domain.attributes[-3:]], + ["xv1017", "xv1018", "xv1019"]) + + # Tests that older hints are dropped: v2 should be lost + self.send_signal(data(0, 5)) + outp = self.get_output() + self.assertEqual([var.name for var in outp.domain.attributes], + ["v0", "v1", "v2", "xv3", "v4"]) + + def test_migrate_settings_hints_2_to_4(self): + settings = { + '__version__': 2, + 'context_settings': + [Context(values={ + '_domain_change_store': ( + {('Categorical', ('a', ('mir1', 'mir4', 'mir2'), (), False)): + [('Rename', ('disease mir',))], + ('Categorical', ('b', ('mir4', 'mir1', 'mir2'), (), False)): + [('Rename', ('disease mirs',))] + }, + -2), + '_merge_dialog_settings': ({}, -4), + '_selected_item': (('1', 0), -2), + 'output_table_name': ('boo', -2), + '__version__': 2}), + Context(values={ + '_domain_change_store': ( + {('Categorical', ('b', ('mir4', 'mir1', 'mir2'), (), False)): + [('Rename', ('disease bmir',))], + ('Categorical', ('c', ('mir4', 'mir1', 'mir2'), (), False)): + [('Rename', ('disease mirs',))] + }, + -2), + '_merge_dialog_settings': ({}, -4), + '_selected_item': (('1', 0), -2), + 'output_table_name': ('far', -2), + '__version__': 2}), + ]} + migrated_hints = { + ('Categorical', ('b', ('mir4', 'mir1', 'mir2'), ())): + [('Rename', ('disease bmir',))], + ('Categorical', ('c', ('mir4', 'mir1', 'mir2'), ())): + [('Rename', ('disease mirs',))], + ('Categorical', ('a', ('mir1', 'mir4', 'mir2'), ())): + [('Rename', ('disease mir',))], + } + widget = self.create_widget(OWEditDomain, stored_settings=settings) + self.assertEqual(widget._domain_change_hints, migrated_hints) + # order matters + self.assertEqual(list(widget._domain_change_hints), list(migrated_hints)) + self.assertEqual(widget.output_table_name, "far") + + def test_migrate_settings_2_to_4_realworld(self): + settings = { + 'controlAreaVisible': True, + '__version__': 2, + 'context_settings': [Context( + values={ + '_domain_change_store': + ({('Real', ('sepal length', (1, 'f'), (), False)): + [('AsString', ())], + ('Real', ('sepal width', (1, 'f'), (), False)): + [('AsTime', ()), ('StrpTime', ('Detect automatically', None, 1, 1))], + ('Real', ('petal width', (1, 'f'), (), False)): + [('Annotate', ((('a', 'b'),),))]}, -2), + '_merge_dialog_settings': ({}, -4), + '_selected_item': (('petal width', 2), -2), + 'output_table_name': ('', -2), + '__version__': 2}, + attributes={'sepal length': 2, 'sepal width': 2, + 'petal length': 2, 'petal width': 2, 'iris': 1}, + metas={} + )] + } + widget = self.create_widget(OWEditDomain, stored_settings=settings) + self.assertEqual( + widget._domain_change_hints, + {('Real', ('sepal length', (1, 'f'), ())): + [('AsString', ())], + ('Real', ('sepal width', (1, 'f'), ())): + [('AsTime', (('StrpTime', + ('Detect automatically', None, 1, 1)),))], + ('Real', ('petal width', (1, 'f'), ())): + [('Annotate', ((('a', 'b'),),))]} + ) + + def test_migrate_settings_name_2_to_3(self): + settings = { + '__version__': 2, + 'context_settings': + [Context(values={ + '_domain_change_store': ({}, -2), + 'output_table_name': ('boo', -2), + '__version__': 2}), + Context(values={ + '_domain_change_store': ({}, -2), + 'output_table_name': ('far', -2), + '__version__': 2}), + Context(values={ + '_domain_change_store': ({}, -2), + 'output_table_name': ('', -2), + '__version__': 2}), + Context(values={ + '_domain_change_store': ({}, -2), + '__version__': 2}) + ] + } + widget = self.create_widget(OWEditDomain, stored_settings=settings) + self.assertEqual(widget.output_table_name, "far") + + def test_migrate_settings_3_to_4(self): + settings = { + '_domain_change_hints': { + ('Real', ('age', (0, 'f'), (), False)): + [('Unlink', ())], + ('Categorical', ('gender', ('female', 'male'), (), False)): + [('CategoriesMapping', ([('female', 'woman'), ('male', 'man')],))], + ('Categorical', ('chest pain', ('asymptomatic', 'atypical ang', + 'non-anginal', 'typical ang'), (), False)): + [('AsString', ())]}, + '_merge_dialog_settings': {}, + 'controlAreaVisible': True} + + widget = self.create_widget(OWEditDomain, stored_settings=settings) + self.assertEqual( + widget._domain_change_hints, + {('Real', ('age', (0, 'f'), ())): + [('Unlink', ())], + ('Categorical', ('gender', ('female', 'male'), ())): + [('CategoriesMapping', + ([('female', 'woman'), ('male', 'man')],))], + ('Categorical', ('chest pain', ('asymptomatic', 'atypical ang', + 'non-anginal', 'typical ang'), ())): + [('AsString', ())]} + ) + + def test_migrate_settings_4_to_5(self): + settings = { + '__version__': 4, + '_domain_change_hints': { + ('String', (), False): + [('AsTime', ()), ('StrpTime', ('2021', ('%Y',), 1, 0))], + }, + '_merge_dialog_settings': {}, + 'controlAreaVisible': True} + widget = self.create_widget(OWEditDomain, stored_settings=settings) + self.assertEqual( + widget._domain_change_hints, + {('String', (), False): + [('AsTime', (('StrpTime', ('2021', ('%Y',), 1, 0)),))], + }, + ) + +def transform_eq(first, second) -> bool: + """ + Specialize equality for transforms to simplify tests: + * AsTime(None) == AsTime(DefaultStrpTime) + * AsTime(None) == AsTime(DefaultTimeUnit) + """ + if isinstance(first, AsTime) and isinstance(second, AsTime): + p1, p2 = first.param, second.param + if (p1 is None) ^ (p2 is None): + param = p1 or p2 + return param == DefaultStrpTime or param == DefaultTimeUnit + return first == second + class TestEditors(GuiTest): + + def assertTransformsEqual(self, first, second): + if not all(transform_eq(t1, t1) for t1, t2 in zip(first, second)): + self.assertSequenceEqual(first, second) + def test_variable_editor(self): w = VariableEditor() self.assertEqual(w.get_data(), (None, [])) - v = String("S", (("A", "1"), ("B", "b")), False) + v = String("S", (("A", "1"), ("B", "b"))) w.set_data(v, []) self.assertEqual(w.name_edit.text(), v.name) @@ -368,7 +773,7 @@ def test_continuous_editor(self): w = ContinuousVariableEditor() self.assertEqual(w.get_data(), (None, [])) - v = Real("X", (-1, ""), (("A", "1"), ("B", "b")), False) + v = Real("X", (-1, ""), (("A", "1"), ("B", "b"))) w.set_data(v, []) self.assertEqual(w.name_edit.text(), v.name) @@ -383,7 +788,7 @@ def test_discrete_editor(self): w = DiscreteVariableEditor() self.assertEqual(w.get_data(), (None, [])) - v = Categorical("C", ("a", "b", "c"), (("A", "1"), ("B", "b")), False) + v = Categorical("C", ("a", "b", "c"), (("A", "1"), ("B", "b"))) values = [0, 0, 0, 1, 1, 2] w.set_data_categorical(v, values) @@ -435,7 +840,7 @@ def test_discrete_editor(self): def test_discrete_editor_add_remove_action(self): w = DiscreteVariableEditor() v = Categorical("C", ("a", "b", "c"), - (("A", "1"), ("B", "b")), False) + (("A", "1"), ("B", "b"))) values = [0, 0, 0, 1, 1, 2] w.set_data_categorical(v, values) action_add = w.add_new_item @@ -483,7 +888,7 @@ def test_discrete_editor_merge_action(self): """ w = DiscreteVariableEditor() v = Categorical("C", ("a", "b", "c"), - (("A", "1"), ("B", "b")), False) + (("A", "1"), ("B", "b"))) w.set_data_categorical( v, [0, 0, 0, 1, 1, 2], @@ -513,7 +918,7 @@ def test_discrete_editor_merge_action(self): def test_discrete_editor_rename_selected_items_action(self): w = DiscreteVariableEditor() v = Categorical("C", ("a", "b", "c"), - (("A", "1"), ("B", "b")), False) + (("A", "1"), ("B", "b"))) w.set_data_categorical(v, []) action = w.rename_selected_items view = w.values_edit @@ -540,7 +945,7 @@ def test_discrete_editor_rename_selected_items_action(self): def test_discrete_editor_context_menu(self): w = DiscreteVariableEditor() v = Categorical("C", ("a", "b", "c"), - (("A", "1"), ("B", "b")), False) + (("A", "1"), ("B", "b"))) w.set_data_categorical(v, []) view = w.values_edit model = view.model() @@ -558,7 +963,7 @@ def test_time_editor(self): w = TimeVariableEditor() self.assertEqual(w.get_data(), (None, [])) - v = Time("T", (("A", "1"), ("B", "b")), False) + v = Time("T", (("A", "1"), ("B", "b"))) w.set_data(v,) self.assertEqual(w.name_edit.text(), v.name) @@ -571,78 +976,240 @@ def test_time_editor(self): DataVectors = [ CategoricalVector( - Categorical("A", ("a", "aa"), (), False), lambda: + Categorical("A", ("a", "aa"), ()), lambda: MArray([0, 1, 2], mask=[False, False, True]) ), RealVector( - Real("B", (6, "f"), (), False), lambda: + Real("B", (6, "f"), ()), lambda: MArray([0.1, 0.2, 0.3], mask=[True, False, True]) ), TimeVector( - Time("T", (), False), lambda: + Time("T", ()), lambda: MArray([0, 100, 200], dtype="M8[us]", mask=[True, False, True]) ), StringVector( - String("S", (), False), lambda: + String("S", ()), lambda: MArray(["0", "1", "2"], dtype=object, mask=[True, False, True]) ), ] ReinterpretTransforms = { - Categorical: AsCategorical, Real: AsContinuous, Time: AsTime, - String: AsString + Categorical: [AsCategorical], Real: [AsContinuous], + Time: [AsTime], + String: [AsString] } def test_reinterpret_editor(self): w = ReinterpretVariableEditor() - self.assertEqual(w.get_data(), (None, [])) + self.assertEqual(w.get_data(), ((None, ), ([], ))) data = self.DataVectors[0] - w.set_data(data, ) - self.assertEqual(w.get_data(), (data.vtype, [])) - w.set_data(data, [Rename("Z")]) - self.assertEqual(w.get_data(), (data.vtype, [Rename("Z")])) + w.set_data((data, )) + self.assertEqual(w.get_data(), ((data.vtype, ), ([], ))) + w.set_data((data, ), ([Rename("Z")], )) + self.assertEqual(w.get_data(), ((data.vtype, ), ([Rename("Z")], ))) for vec, tr in product(self.DataVectors, self.ReinterpretTransforms.values()): - w.set_data(vec, [tr()]) + w.set_data((vec, ), ([t() for t in tr], )) v, tr_ = w.get_data() - self.assertEqual(v, vec.vtype) - if not tr_: - self.assertEqual(tr, self.ReinterpretTransforms[type(v)]) + self.assertEqual(*v, vec.vtype) + if not tr_[0]: + self.assertEqual(tr, self.ReinterpretTransforms[type(*v)]) else: - self.assertEqual(tr_, [tr()]) + self.assertTransformsEqual(*tr_, [t() for t in tr]) def test_reinterpret_editor_simulate(self): w = ReinterpretVariableEditor() - tc = w.findChild(QComboBox, name="type-combo") def cb(): var, tr = w.get_data() + var, tr = var[0], tr[0] type_ = tc.currentData() if type_ is not type(var): - self.assertEqual(tr, [self.ReinterpretTransforms[type_](), Rename("Z")]) + self.assertTransformsEqual( + tr, [t() for t in self.ReinterpretTransforms[type_]] + [Rename("Z")] + ) else: self.assertEqual(tr, [Rename("Z")]) for vec in self.DataVectors: - w.set_data(vec, [Rename("Z")]) + w.set_data((vec, ), ([Rename("Z")], )) + tc = w.layout().currentWidget().findChild(QComboBox, + name="type-combo") simulate.combobox_run_through_all(tc, callback=cb) + def test_multiple_editor_init(self): + w = ReinterpretVariableEditor() + w.set_data(self.DataVectors, [()] * 4) + cw = w.layout().currentWidget() + tc = cw.findChild(QComboBox, name="type-combo") + self.assertIs(type(cw), BaseEditor) + self.assertEqual(tc.count(), 6) + + w.set_data(self.DataVectors[:1], [()]) + cw = w.layout().currentWidget() + tc = cw.findChild(QComboBox, name="type-combo") + self.assertIsNot(type(cw), BaseEditor) + self.assertEqual(tc.count(), 4) + + def test_reinterpret_set_data_multiple_transforms(self): + w = ReinterpretVariableEditor() + + w.set_data((Mock(), ) * 4, + [[AsContinuous()] for _ in range(4)]) + cw = w.layout().currentWidget() + self.assertIs(type(cw), BaseEditor) + tc = cw.findChild(QComboBox, name="type-combo") + + self.assertIsInstance( + w.__dict__["_ReinterpretVariableEditor__transform"], + AsContinuous) + self.assertEqual(tc.currentData(), Real) + + w.set_data((Mock(), ) * 3, + [[AsContinuous(), Rename("x")], + [AsContinuous(), Rename("y")], + [AsContinuous()] + ] + ) + self.assertIsInstance( + w.__dict__["_ReinterpretVariableEditor__transform"], + AsContinuous) + self.assertEqual(tc.currentData(), Real) + + w.set_data((Mock(), ) * 3, + [[AsContinuous(), Rename("x")], + [Rename("y")], + [AsContinuous()] + ] + ) + self.assertIsNone(w.__dict__["_ReinterpretVariableEditor__transform"]) + self.assertIsNone(tc.currentData()) + + w.set_data((Mock(), ) * 3, + [[AsContinuous(), Rename("x")], + [], + [AsContinuous()] + ] + ) + self.assertIsNone(w.__dict__["_ReinterpretVariableEditor__transform"]) + self.assertIsNone(tc.currentData()) + + w.set_data((Mock(),) * 3, + [[AsContinuous(), Rename("x")], + [AsTime()], + [AsContinuous()] + ] + ) + self.assertIsNone(w.__dict__["_ReinterpretVariableEditor__transform"]) + self.assertIsNone(tc.currentData()) + + def test_reinterpret_multiple(self): + def cb(): + for var, tr, v in zip(*w.get_data(), "SPQR"): + type_ = tc.currentData() + if type_ is not type(var) \ + and type_ not in (RestoreOriginal, None): + self.assertSequenceEqual( + tr, [t() for t in self.ReinterpretTransforms[type_][:1]] + + [Rename(v)], + f"type: {type_}" + ) + else: + self.assertSequenceEqual(tr, (Rename(v), ), f"type: {type_}") + + w = ReinterpretVariableEditor() + w.set_data(self.DataVectors, tuple([Rename(c)] for c in "SPQR")) + tc = w.layout().currentWidget().findChild(QComboBox, name="type-combo") + simulate.combobox_run_through_all(tc, callback=cb) + + def test_reinterpret_remove_specific(self): + def cb(): + for var, tr, v in zip(*w.get_data(), "SPQR"): + type_ = tc.currentData() + if type_ is not type(var) \ + and type_ not in (RestoreOriginal, None): + self.assertSequenceEqual( + tr, [t() for t in self.ReinterpretTransforms[type_][:1]] + + [Rename(v)], + f"type: {type_}" + ) + else: + self.assertSequenceEqual(tr, (Rename(v), ), f"type: {type_}") + + w = ReinterpretVariableEditor() + transforms = ( + [CategoriesMapping([("a", "b")])], + [AsCategorical(), Rename("xx")], + [AsCategorical(), CategoriesMapping([("c", "d")])]) + w.set_data(self.DataVectors[:3], transforms) + tc = w.layout().currentWidget().findChild(QComboBox, name="type-combo") + + tc.setCurrentIndex(0) # Categorical + tc.activated[int].emit(0) + self.assertSequenceEqual(w.get_data()[1], transforms) + + tc.setCurrentIndex(1) # Numeric + tc.activated[int].emit(1) + self.assertEqual(w.get_data()[1], + [[AsContinuous()], + [Rename("xx")], + [AsContinuous()]]) + + tc.setCurrentIndex(4) # Restore original + tc.activated[int].emit(4) + self.assertEqual(w.get_data()[1], + [[CategoriesMapping([("a", "b")])], + [Rename("xx")], + []]) + + tc.setCurrentIndex(5) # None + tc.activated[int].emit(5) + self.assertSequenceEqual(w.get_data()[1], transforms) + + # We don't have this situation, but simulate a situation in which the + # target type has the same (specific) transformation + with patch.dict(w.Specific, {Real: (CategoriesMapping, )}): + tc.setCurrentIndex(1) # Numeric + tc.activated[int].emit(1) + self.assertSequenceEqual( + w.get_data()[1], + ([AsContinuous(), CategoriesMapping([("a", "b")])], + [Rename("xx")], + [AsContinuous(), CategoriesMapping([("c", "d")])] + ) + ) + + def test_reinterpret_multiple_keep_and_restore(self): + w = ReinterpretVariableEditor() + transforms = tuple([AsString(), Rename(c)] for c in "SPQR") + w.set_data(self.DataVectors, tuple([AsString(), Rename(c)] for c in "SPQR")) + tc = w.layout().currentWidget().findChild(QComboBox, name="type-combo") + + tc.setCurrentIndex(4) # Restore original + tc.activated[int].emit(4) + self.assertSequenceEqual( + [list(tr) for tr in w.get_data()[1]], + [[Rename(c)] for c in "SPQR"]) + + tc.setCurrentIndex(5) # Keep + tc.activated[int].emit(5) + self.assertSequenceEqual( + [list(tr) for tr in w.get_data()[1]], + transforms) + def test_unlink(self): w = ContinuousVariableEditor() cbox = w.unlink_var_cb self.assertEqual(w.get_data(), (None, [])) - v = Real("X", (-1, ""), (("A", "1"), ("B", "b")), False) + v = Real("X", (-1, ""), (("A", "1"), ("B", "b"))) w.set_data(v, []) - self.assertFalse(cbox.isEnabled()) - v = Real("X", (-1, ""), (("A", "1"), ("B", "b")), True) + v = Real("X", (-1, ""), (("A", "1"), ("B", "b"))) w.set_data(v, [Unlink()]) - self.assertTrue(cbox.isEnabled()) self.assertTrue(cbox.isChecked()) - v = Real("X", (-1, ""), (("A", "1"), ("B", "b")), True) + v = Real("X", (-1, ""), (("A", "1"), ("B", "b"))) w.set_data(v, []) - self.assertTrue(cbox.isEnabled()) self.assertFalse(cbox.isChecked()) cbox.setChecked(True) @@ -664,33 +1231,98 @@ def test_unlink(self): w._set_unlink(False) self.assertFalse(cbox.isChecked()) + def test_reinterpret_time_format_restore(self): + w = ReinterpretVariableEditor() + transforms = [AsTime(StrpTime(None, ("%Y",), 1, 0)), + Rename("Time"),] + w.set_data([self.DataVectors[3]], [transforms]) + _, [t] = w.get_data() + self.assertEqual(t, transforms) + + def test_reinterpret_time_unit_restore(self): + w = ReinterpretVariableEditor() + transforms = [AsTime(TimeUnit("Year", "Y0")), + Rename("Time"), ] + w.set_data([self.DataVectors[1]], [transforms]) + _, [t] = w.get_data() + self.assertEqual(t, transforms) + +class TestModels(GuiTest): + def test_variable_model(self): + model = VariableListModel() + self.assertEqual(model.effective_name(model.index(-1, -1)), None) + + def data(row, role): + return model.data(model.index(row,), role) + + def set_data(row, data, role): + model.setData(model.index(row), data, role) + + model[:] = [ + RealVector(Real("A", (3, "g"), ()), lambda: MArray([])), + RealVector(Real("B", (3, "g"), ()), lambda: MArray([])), + ] + self.assertEqual(data(0, Qt.DisplayRole), "A") + self.assertEqual(data(1, Qt.DisplayRole), "B") + self.assertEqual(model.effective_name(model.index(1)), "B") + set_data(1, [Rename("A")], TransformRole) + self.assertEqual(model.effective_name(model.index(1)), "A") + self.assertEqual(data(0, MultiplicityRole), 2) + self.assertEqual(data(1, MultiplicityRole), 2) + set_data(1, [], TransformRole) + self.assertEqual(data(0, MultiplicityRole), 1) + self.assertEqual(data(1, MultiplicityRole), 1) + class TestDelegates(GuiTest): def test_delegate(self): - model = PyListModel([None]) + model = VariableListModel([None, None]) - def set_item(v: dict): - model.setItemData(model.index(0), v) + def set_item(row: int, v: dict): + model.setItemData(model.index(row), v) - def get_style_option() -> QStyleOptionViewItem: + def get_style_option(row: int) -> QStyleOptionViewItem: opt = QStyleOptionViewItem() - delegate.initStyleOption(opt, model.index(0)) + delegate.initStyleOption(opt, model.index(row)) return opt - set_item({Qt.EditRole: Categorical("a", (), (), False)}) + set_item(0, {Qt.EditRole: Categorical("a", (), ())}) delegate = VariableEditDelegate() - opt = get_style_option() + opt = get_style_option(0) self.assertEqual(opt.text, "a") self.assertFalse(opt.font.italic()) - set_item({TransformRole: [Rename("b")]}) - opt = get_style_option() + set_item(0, {TransformRole: [Rename("b")]}) + opt = get_style_option(0) self.assertEqual(opt.text, "a \N{RIGHTWARDS ARROW} b") self.assertTrue(opt.font.italic()) - set_item({TransformRole: [AsString()]}) - opt = get_style_option() + set_item(0, {TransformRole: [AsString()]}) + opt = get_style_option(0) self.assertIn("reinterpreted", opt.text) self.assertTrue(opt.font.italic()) + set_item(1, { + Qt.EditRole: String("b", ()), + TransformRole: [Rename("a")] + }) + opt = get_style_option(1) + self.assertEqual(opt.palette.color(QPalette.Text), QColor(Qt.red)) + view = QListView() + with patch.object(QToolTip, "showText") as p: + delegate.helpEvent( + QHelpEvent(QHelpEvent.ToolTip, QPoint(0, 0), QPoint(0, 0)), + view, opt, model.index(1), + ) + p.assert_called_once() + + set_item(1, { + TransformRole: Rename("bb"), + RestoreWarningRole: ("bb", "aa"), + }) + opt = get_style_option(1) + self.assertIn( + opt.palette.color(QPalette.Text), + (QColor(Qt.yellow), QColor(255, 148, 11)) + ) class TestTransforms(TestCase): @@ -770,6 +1402,9 @@ def _assertLookupEquals(self, first, second): self.assertIs(first.variable, second.variable) assert_array_equal(first.lookup_table, second.lookup_table) +DefaultStrpTime = StrpTime("Detect automatically", None, 1, 1) +DefaultTimeUnit = TimeUnit("Default", "s") + class TestReinterpretTransforms(TestCase): @classmethod @@ -817,7 +1452,7 @@ def test_as_string(self): ["a", "2", "0.25", "00:03:00"], ["b", "1", "1.25", "00:06:00"], ["c", "0", "0.2", "00:12:00"], - ["b", "0", "0.0", "00:00:00"], + ["b", "0", "0", "00:00:00"], ], dtype=object) ) @@ -843,11 +1478,11 @@ def test_as_discrete(self): ) self.assertEqual(tdomain["A"].values, ("a", "b", "c")) self.assertEqual(tdomain["B"].values, ("0", "1", "2")) - self.assertEqual(tdomain["C"].values, ("0.0", "0.2", "0.25", "1.25")) + self.assertEqual(tdomain["C"].values, ("0", "0.2", "0.25", "1.25")) self.assertEqual( tdomain["D"].values, - ("1970-01-01 00:00:00", "1970-01-01 00:03:00", - "1970-01-01 00:06:00", "1970-01-01 00:12:00") + ("00:00:00", "00:03:00", + "00:06:00", "00:12:00") ) def test_as_continuous(self): @@ -871,24 +1506,45 @@ def test_as_continuous(self): ) def test_as_time(self): - table = self.data - domain = table.domain + # this test only test type of format that can be string, continuous and discrete + # correctness of time formats is already tested in TimeVariable module + d = TimeVariable("_").parse_exact_iso + times = ( + ["07.02.2022", "18.04.2021"], # date only + ["07.02.2022 01:02:03", "18.04.2021 01:02:03"], # datetime + # datetime with timezone + ["2021-02-08 01:02:03+01:00", "2021-02-07 01:02:03+01:00"], + ["010203", "010204"], # time + ["02-07", "04-18"], + ) + formats = [ + "25.11.2021", "25.11.2021 00:00:00", "2021-11-25 00:00:00", "000000", "11-25" + ] + expected = [ + [d("2022-02-07"), d("2021-04-18")], + [d("2022-02-07 01:02:03"), d("2021-04-18 01:02:03")], + [d("2021-02-08 01:02:03+0100"), d("2021-02-07 01:02:03+0100")], + [d("01:02:03"), d("01:02:04")], + [d("1900-02-07"), d("1900-04-18")], + ] + variables = [StringVariable(f"s{i}") for i in range(len(times))] + variables += [DiscreteVariable(f"d{i}", values=t) for i, t in enumerate(times)] + domain = Domain([], metas=variables) + metas = [t for t in times] + [list(range(len(x))) for x in times] + table = Table(domain, np.empty((len(times[0]), 0)), metas=np.array(metas).transpose()) - tr = AsTime() + # tr = AsTime() dtr = [] - for v in domain.variables: + for v, f in zip(domain.metas, chain(formats, formats)): + strp = StrpTime(f, *TimeVariable.ADDITIONAL_FORMATS[f]) + tr = AsTime(strp) vtr = apply_reinterpret(v, tr, table_column_data(table, v)) dtr.append(vtr) - ttable = table.transform(Domain(dtr)) + ttable = table.transform(Domain([], metas=dtr)) assert_array_equal( - ttable.X, - np.array([ - [np.nan, np.nan, 0.25, 180], - [np.nan, np.nan, 1.25, 360], - [np.nan, np.nan, 0.20, 720], - [np.nan, np.nan, 0.00, 000], - ], dtype=float) + ttable.metas, + np.array(list(chain(expected, expected)), dtype=float).transpose() ) def test_reinterpret_string(self): @@ -896,9 +1552,13 @@ def test_reinterpret_string(self): domain = table.domain tvars = [] for v in domain.metas: - for i, tr in enumerate([AsContinuous(), AsCategorical(), AsTime(), AsString()]): - tr = apply_reinterpret(v, tr, table_column_data(table, v)).renamed(f'{v.name}_{i}') - tvars.append(tr) + for i, tr in enumerate( + [AsContinuous(), AsCategorical(), AsTime(DefaultStrpTime), AsString()] + ): + vtr = apply_reinterpret(v, tr, table_column_data(table, v)).renamed( + f"{v.name}_{i}" + ) + tvars.append(vtr) tdomain = Domain([], metas=tvars) ttable = table.transform(tdomain) assert_array_nanequal( @@ -942,6 +1602,17 @@ def test_null_transform(self): v = apply_transform(domain.metas[0],table, []) self.assertIs(v, domain.metas[0]) + def test_to_time_variable(self): + table = self.data + tr = AsTime(None) + dtr = [] + for v in table.domain: + vtr = apply_reinterpret(v, tr, table_column_data(table, v)) + dtr.append(vtr) + ttable = table.transform(Domain([], metas=dtr)) + for var in ttable.domain: + self.assertTrue(var.have_date or var.have_time) + class TestUtils(TestCase): def test_mapper(self): @@ -964,13 +1635,6 @@ def test_mapper(self): self.assertIs(r, r_) assert_array_equal(r, [1, 1, 2]) - def test_dict_missing(self): - d = DictMissingConst("<->", {1: 1, 2: 2}) - self.assertEqual(d[1], 1) - self.assertEqual(d[-1], "<->") - # must be sufficiently different from defaultdict to warrant existence - self.assertEqual(d, DictMissingConst("<->", {1: 1, 2: 2})) - def test_as_float_or_nan(self): a = np.array(["a", "1.1", ".2", "NaN"], object) r = as_float_or_nan(a) @@ -998,25 +1662,12 @@ def test_column_str_repr(self): d = column_str_repr(v, np.array([0., np.nan, 1.0])) assert_array_equal(d, ["00:00:00", "?", "00:00:01"]) - def test_time_parse(self): - """parsing additional datetimes by pandas""" - date = ["1/22/20", "1/23/20", "1/24/20"] - # we use privet method, check if still exists - assert hasattr(pd.DatetimeIndex, '_is_dates_only') - - tval, values = time_parse(date) - - self.assertTrue(tval.have_date) - self.assertFalse(tval.have_time) - self.assertListEqual(list(values), - [1579651200.0, 1579737600.0, 1579824000.0]) - class TestLookupMappingTransform(TestCase): def setUp(self) -> None: self.lookup = LookupMappingTransform( StringVariable("S"), - DictMissingConst(np.nan, {"": np.nan, "a": 0, "b": 1}), + {"": np.nan, "a": 0, "b": 1}, dtype=float, ) @@ -1038,9 +1689,9 @@ def test_equality(self): v2 = DiscreteVariable("v1", values=tuple("abc")) v3 = DiscreteVariable("v3", values=tuple("abc")) - map1 = DictMissingConst(np.nan, {"a": 2, "b": 0, "c": 1}) - map2 = DictMissingConst(np.nan, {"a": 2, "b": 0, "c": 1}) - map3 = DictMissingConst(np.nan, {"a": 2, "b": 0, "c": 1}) + map1 = {"a": 2, "b": 0, "c": 1} + map2 = {"a": 2, "b": 0, "c": 1} + map3 = {"a": 2, "b": 0, "c": 1} t1 = LookupMappingTransform(v1, map1, float) t1a = LookupMappingTransform(v2, map2, float) @@ -1052,15 +1703,15 @@ def test_equality(self): self.assertEqual(hash(t1), hash(t1a)) self.assertNotEqual(hash(t1), hash(t2)) - map1a = DictMissingConst(np.nan, {"a": 2, "b": 1, "c": 0}) + map1a = {"a": 2, "b": 1, "c": 0} t1 = LookupMappingTransform(v1, map1, float) t1a = LookupMappingTransform(v1, map1a, float) self.assertNotEqual(t1, t1a) self.assertNotEqual(hash(t1), hash(t1a)) - map1a = DictMissingConst(2, {"a": 2, "b": 0, "c": 1}) + map1a = {"a": 2, "b": 0, "c": 1} t1 = LookupMappingTransform(v1, map1, float) - t1a = LookupMappingTransform(v1, map1a, float) + t1a = LookupMappingTransform(v1, map1a, float, unknown=2) self.assertNotEqual(t1, t1a) self.assertNotEqual(hash(t1), hash(t1a)) @@ -1073,7 +1724,7 @@ def test_equality(self): class TestGroupLessFrequentItemsDialog(GuiTest): def setUp(self) -> None: self.v = Categorical("C", ("a", "b", "c"), - (("A", "1"), ("B", "b")), False) + (("A", "1"), ("B", "b"))) self.data = [0, 0, 0, 1, 1, 2] def test_dialog_open(self): @@ -1179,4 +1830,3 @@ def _test_correctness(): if __name__ == '__main__': unittest.main() - diff --git a/Orange/widgets/data/tests/test_owfeatureconstructor.py b/Orange/widgets/data/tests/test_owfeatureconstructor.py index 7ec9bd147b3..fe72bff5fab 100644 --- a/Orange/widgets/data/tests/test_owfeatureconstructor.py +++ b/Orange/widgets/data/tests/test_owfeatureconstructor.py @@ -1,26 +1,27 @@ # pylint: disable=unsubscriptable-object import unittest import ast -import sys import math import pickle import copy +from unittest.mock import patch, Mock import numpy as np +from scipy import sparse as sp + +from orangewidget.settings import Context from Orange.data import (Table, Domain, StringVariable, ContinuousVariable, DiscreteVariable, TimeVariable) from Orange.widgets.tests.base import WidgetTest -from Orange.widgets.utils import vartype from Orange.widgets.utils.itemmodels import PyListModel +from Orange.widgets.utils.concurrent import TaskState from Orange.widgets.data.owfeatureconstructor import ( DiscreteDescriptor, ContinuousDescriptor, StringDescriptor, construct_variables, OWFeatureConstructor, - FeatureEditor, DiscreteFeatureEditor, FeatureConstructorHandler, - DateTimeDescriptor) - -from Orange.widgets.data.owfeatureconstructor import ( - freevars, validate_exp, FeatureFunc + FeatureEditor, DiscreteFeatureEditor, + DateTimeDescriptor, StringFeatureEditor, freevars, validate_exp, + FeatureFunc, run ) @@ -35,7 +36,7 @@ def test_construct_variables_discrete(self): [DiscreteDescriptor(name=name, expression=expression, values=values, ordered=True)] ) - data = data.transform(Domain(list(data.domain.attributes) + + data = data.transform(Domain(data.domain.attributes + construct_variables(desc, data), data.domain.class_vars, data.domain.metas)) @@ -53,7 +54,7 @@ def test_construct_variables_discrete_no_values(self): [DiscreteDescriptor(name=name, expression=expression, values=values, ordered=False)] ) - data = data.transform(Domain(list(data.domain.attributes) + + data = data.transform(Domain(data.domain.attributes + construct_variables(desc, data), data.domain.class_vars, data.domain.metas)) @@ -72,7 +73,7 @@ def test_construct_variables_continuous(self): [ContinuousDescriptor(name=name, expression=expression, number_of_decimals=2)] ) - data = data.transform(Domain(list(data.domain.attributes) + + data = data.transform(Domain(data.domain.attributes + construct_variables(featuremodel, data), data.domain.class_vars, data.domain.metas)) @@ -88,13 +89,13 @@ def test_construct_variables_datetime(self): featuremodel = PyListModel( [DateTimeDescriptor(name=name, expression=expression)] ) - data = data.transform(Domain(list(data.domain.attributes) + + data = data.transform(Domain(data.domain.attributes + construct_variables(featuremodel, data), data.domain.class_vars, data.domain.metas)) self.assertTrue(isinstance(data.domain[name], TimeVariable)) for row in data: - self.assertEqual("2019-07-{:02}".format(int(row["MEDV"] / 3)), + self.assertEqual(f"2019-07-{int(row['MEDV'] / 3):02}", str(row["Date"])[:10]) def test_construct_variables_string(self): @@ -106,7 +107,7 @@ def test_construct_variables_string(self): ) data = data.transform(Domain(data.domain.attributes, data.domain.class_vars, - list(data.domain.metas) + + data.domain.metas + construct_variables(desc, data))) self.assertTrue(isinstance(data.domain[name], StringVariable)) for i in range(3): @@ -122,6 +123,7 @@ def test_construct_numeric_names(): desc = PyListModel( [ContinuousDescriptor(name="S", expression="_0_1 + _1", + meta=False, number_of_decimals=3)] ) nv = construct_variables(desc, data) @@ -129,6 +131,21 @@ def test_construct_numeric_names(): np.testing.assert_array_equal(ndata.X[:, 0], data.X[:, :2].sum(axis=1)) + def test_construct_placement(self): + domain = Domain([ContinuousVariable(x) for x in "ab"]) + data = Table.from_numpy(domain, np.arange(4).reshape(2, 2)) + desc = [ContinuousDescriptor("x", "a + b", 1, False), + ContinuousDescriptor("y", "a + b", 1, True), + StringDescriptor("z", "a + b", True), + ContinuousDescriptor("a", "a + 1", 1, False), + ContinuousDescriptor("b", "a + 1", 1, True), + ] + res = run(data, desc, False, TaskState()) + self.assertEqual([var.name for var in res.data.domain.attributes], + ["a", "x"]) + self.assertEqual([var.name for var in res.data.domain.metas], + ["b", "y", "z"]) + @staticmethod def test_unicode_normalization(): micro = "\u00b5" @@ -145,6 +162,20 @@ def test_unicode_normalization(): construct_variables(desc, data))) np.testing.assert_equal(data.X, data.metas) + @staticmethod + def test_transform_sparse(): + domain = Domain([ContinuousVariable("A")]) + desc = [ + ContinuousDescriptor(name="X", expression="A", number_of_decimals=2) + ] + X = sp.csc_matrix(np.arange(5).reshape(5, 1)) + data = Table.from_numpy(domain, X) + data_ = data.transform(Domain(data.domain.attributes, + [], + construct_variables(desc, data))) + np.testing.assert_equal(data.get_column(0), data_.get_column(0) + ) + class TestTools(unittest.TestCase): def test_free_vars(self): @@ -171,13 +202,15 @@ def freevars_(source, env=None): self.assertEqual(freevars_("a + b", ["a", "b"]), []) self.assertEqual(freevars_("a[b]"), ["a", "b"]) self.assertEqual(freevars_("a[b]", ["a", "b"]), []) + self.assertEqual(freevars_("a[b:3]", ["a", "b"]), []) + self.assertEqual(freevars_("a[b:c:d]", ["a", "b", "c", "d"]), []) + self.assertEqual(freevars_("f(x, *a)", ["f"]), ["x", "a"]) self.assertEqual(freevars_("f(x, *a, y=1)", ["f"]), ["x", "a"]) self.assertEqual(freevars_("f(x, *a, y=1, **k)", ["f"]), ["x", "a", "k"]) - if sys.version_info >= (3, 5): - self.assertEqual(freevars_("f(*a, *b, k=c, **d, **e)", ["f"]), - ["a", "b", "c", "d", "e"]) + self.assertEqual(freevars_("f(*a, *b, k=c, **d, **e)", ["f"]), + ["a", "b", "c", "d", "e"]) self.assertEqual(freevars_("True"), []) self.assertEqual(freevars_("'True'"), []) @@ -196,20 +229,34 @@ def freevars_(source, env=None): self.assertEqual(freevars_("{a, b}"), ["a", "b"]) self.assertEqual(freevars_("0 if abs(a) < 0.1 else b", ["abs"]), ["a", "b"]) + self.assertEqual(freevars_("lambda: a", []), ["a"]) + self.assertEqual(freevars_("lambda: a", ["a"]), []) self.assertEqual(freevars_("lambda a: b + 1"), ["b"]) self.assertEqual(freevars_("lambda a: b + 1", ["b"]), []) self.assertEqual(freevars_("lambda a: a + 1"), []) self.assertEqual(freevars_("(lambda a: a + 1)(a)"), ["a"]) self.assertEqual(freevars_("lambda a, *arg: arg + (a,)"), []) self.assertEqual(freevars_("lambda a, *arg, **kwargs: arg + (a,)"), []) - + self.assertEqual(freevars_("lambda a: a + c", []), ["c"]) + self.assertEqual(freevars_("lambda a: a + c", ["c"]), []) + self.assertEqual(freevars_("lambda a, b=k: a + c", []), ["k", "c"]) + self.assertEqual(freevars_("lambda *a, b=k: a + c", []), ["k", "c"]) + self.assertEqual(freevars_("lambda a,/, b=k: a + c", []), ["k", "c"]) + self.assertEqual(freevars_("lambda a,/, b=k, **kwg: a + c and kwg", []), + ["k", "c"]) self.assertEqual(freevars_("[a for a in b]"), ["b"]) + self.assertEqual(freevars_("[a for a, k in b]"), ["b"]) + self.assertEqual(freevars_("[(a, j) for a in b]"), ["j", "b"]) + self.assertEqual(freevars_("[a for k in b for a in k]"), ["b"]) + self.assertEqual(freevars_("[a for k in b if k for a in k if a]"), + ["b"]) + self.assertEqual(freevars_("[a for k in b if kk for a in k if aa]"), + ["b", "kk", "aa"]) self.assertEqual(freevars_("[1 + a for c in b if c]"), ["a", "b"]) self.assertEqual(freevars_("{a for _ in [] if b}"), ["a", "b"]) self.assertEqual(freevars_("{a for _ in [] if b}", ["a", "b"]), []) def test_validate_exp(self): - stmt = ast.parse("1", mode="single") with self.assertRaises(ValueError): validate_exp(stmt) @@ -242,16 +289,10 @@ def validate_(source): self.assertTrue(validate_("[]")) with self.assertRaises(ValueError): - validate_("[a for a in s]") - - with self.assertRaises(ValueError): - validate_("(a for a in s)") - - with self.assertRaises(ValueError): - validate_("{a for a in s}") + validate_("[i async for i in s]") with self.assertRaises(ValueError): - validate_("{a:1 for a in s}") + validate_("(i async for i in s)") class FeatureFuncTest(unittest.TestCase): @@ -273,7 +314,7 @@ def test_reconstruct(self): def test_repr(self): self.assertEqual(repr(FeatureFunc("a + 1", [("a", 2)])), - "FeatureFunc('a + 1', [('a', 2)], {}, None)") + "FeatureFunc('a + 1', [('a', 2)], {}, None, False, None)") def test_call(self): iris = Table("iris") @@ -288,7 +329,7 @@ def test_string_casting(self): f = FeatureFunc("name[0]", [("name", zoo.domain["name"])]) r = f(zoo) - self.assertEqual(r, [x[0] for x in zoo.metas[:, 0]]) + self.assertEqual(list(r), [x[0] for x in zoo.metas[:, 0]]) self.assertEqual(f(zoo[0]), str(zoo[0, "name"])[0]) def test_missing_variable(self): @@ -302,11 +343,18 @@ def test_missing_variable(self): self.assertTrue(np.all(np.isnan(r))) self.assertTrue(np.isnan(f(data2[0]))) + def test_time_str(self): + data = Table.from_numpy(Domain([TimeVariable("T", have_date=True)]), [[0], [0]]) + f = FeatureFunc("str(T)", [("T", data.domain[0])]) + c = f(data) + self.assertEqual(list(c), ["1970-01-01", "1970-01-01"]) + def test_invalid_expression_variable(self): iris = Table("iris") f = FeatureFunc("1 / petal_length", [("petal_length", iris.domain["petal length"])]) - iris[0]["petal length"] = 0 + with iris.unlocked(): + iris[0]["petal length"] = 0 f.mask_exceptions = False self.assertRaises(Exception, f, iris) @@ -320,6 +368,14 @@ def test_invalid_expression_variable(self): self.assertTrue(np.isnan(f(iris[0]))) self.assertFalse(np.isnan(f(iris[1]))) + def test_hash_eq(self): + iris = Table("iris") + f = FeatureFunc("1 / petal_length", + [("petal_length", iris.domain["petal length"])]) + g = copy.deepcopy(f) + self.assertEqual(f, g) + self.assertEqual(hash(f), hash(g)) + class OWFeatureConstructorTests(WidgetTest): def setUp(self): @@ -343,7 +399,20 @@ def test_error_invalid_expression(self): self.widget.apply() self.assertTrue(self.widget.Error.invalid_expressions.is_shown()) - def test_renaming_duplicate_vars(self): + def test_transform_error(self): + data = Table("iris")[::5] + self.send_signal(self.widget.Inputs.data, data) + self.widget.addFeature(ContinuousDescriptor("X", "1/0", 3)) + self.widget.apply() + self.wait_until_finished(self.widget) + self.assertTrue(self.widget.Error.transform_error.is_shown()) + self.widget.removeFeature(0) + self.widget.addFeature(ContinuousDescriptor("X", "1", 3)) + self.widget.apply() + self.wait_until_finished(self.widget) + self.assertFalse(self.widget.Error.transform_error.is_shown()) + + def test_replace_existing_vars(self): data = Table("iris") self.widget.setData(data) self.widget.addFeature( @@ -351,8 +420,11 @@ def test_renaming_duplicate_vars(self): ) self.widget.apply() output = self.get_output(self.widget.Outputs.data) - self.assertEqual(len(set(var.name for var in output.domain.variables)), - len(output.domain.variables)) + domain = output.domain + self.assertEqual(len(domain.attributes), 4) + self.assertEqual(len(domain.class_vars), 1) + self.assertIsInstance(domain.class_vars[0], ContinuousVariable) + self.assertEqual(domain.class_vars[0].name, "iris") def test_discrete_no_values(self): """ @@ -365,14 +437,187 @@ def test_discrete_no_values(self): discreteFeatureEditor.valuesedit.setText("A") discreteFeatureEditor.nameedit.setText("D1") - discreteFeatureEditor.expressionedit.setText("iris") + discreteFeatureEditor.expressionedit.setText("1") self.widget.addFeature( discreteFeatureEditor.editorData() ) self.assertFalse(self.widget.Error.more_values_needed.is_shown()) self.widget.apply() + self.wait_until_finished(self.widget) self.assertTrue(self.widget.Error.more_values_needed.is_shown()) + def test_missing_strings(self): + domain = Domain([], metas=[StringVariable("S1")]) + data = Table.from_list(domain, [["A"], ["B"], [None]]) + self.widget.setData(data) + + editor = StringFeatureEditor() + editor.nameedit.setText("S2") + editor.expressionedit.setText("S1 + S1") + self.widget.addFeature(editor.editorData()) + self.widget.apply() + output = self.get_output(self.widget.Outputs.data) + np.testing.assert_equal(output.metas, + [["A", "AA"], ["B", "BB"], ["", ""]]) + + @patch("Orange.widgets.data.owfeatureconstructor.QMessageBox") + def test_fix_values(self, msgbox): + w = self.widget + + msgbox.ApplyRole, msgbox.RejectRole = object(), object() + msgbox.return_value = Mock() + dlgexec = msgbox.return_value.exec = Mock() + + v = [DiscreteVariable(name, values=tuple("abc")) + for name in ("ana", "berta", "cilka")] + domain = Domain(v, []) + self.send_signal(w.Inputs.data, Table.from_numpy(domain, [[0, 1, 2]])) + + w.descriptors = [StringDescriptor( + "y", "ana.value + berta.value + cilka.value")] + + # Reject fixing - no changes + dlgexec.return_value=msgbox.RejectRole + w.fix_expressions() + self.assertEqual(w.descriptors[0].expression, + "ana.value + berta.value + cilka.value") + + dlgexec.return_value = Mock(return_value=msgbox.AcceptRole) + + w.fix_expressions() + self.assertEqual(w.descriptors[0].expression, "ana + berta + cilka") + + w.descriptors = [StringDescriptor( + "y", "ana.value + dani.value + cilka.value")] + with patch.object(w, "apply"): # dani doesn't exist and will fail + w.fix_expressions() + self.assertEqual(w.descriptors[0].expression, + "ana + dani.value + cilka") + + w.descriptors = [ContinuousDescriptor("y", "sqrt(berta)", 1)] + w.fix_expressions() + self.assertEqual(w.descriptors[0].expression, + "sqrt({'a': 0, 'b': 1, 'c': 2}[berta])") + + def test_migration_discrete_strings(self): + v = [DiscreteVariable("Ana", values=tuple("012")), + ContinuousVariable("Cilka")] + domain = Domain(v) + data = Table.from_numpy(domain, [[1, 3.14]]) + + settings_w_discrete = { + "context_settings": + [Context( + attributes=dict(Ana=1, Cilka=2), metas={}, + values=dict( + descriptors=[ + ContinuousDescriptor("y", "Ana + int(Cilka)", 1), + StringDescriptor("u", "Ana.value + 'X'") + ], + currentIndex=0) + )] + } + widget = self.create_widget(OWFeatureConstructor, settings_w_discrete) + self.send_signal(widget.Inputs.data, data) + self.assertTrue(widget.expressions_with_values) + self.assertFalse(widget.fix_button.isHidden()) + out = self.get_output(widget.Outputs.data) + np.testing.assert_almost_equal(out.X, [[1, 3.14, 4]]) + np.testing.assert_equal(out.metas, [["1X"]]) + + settings_no_discrete = { + "context_settings": + [Context( + attributes=dict(Ana=1, Cilka=2), metas={}, + values=dict( + descriptors=[ + ContinuousDescriptor("y", "int(Cilka)", 1), + ], + currentIndex=0) + )] + } + widget = self.create_widget(OWFeatureConstructor, settings_no_discrete) + self.send_signal(widget.Inputs.data, data) + self.assertFalse(widget.expressions_with_values) + self.assertTrue(widget.fix_button.isHidden()) + out = self.get_output(widget.Outputs.data) + np.testing.assert_almost_equal(out.X, [[1, 3.14, 3]]) + + widget = self.create_widget(OWFeatureConstructor, settings_w_discrete) + self.send_signal(widget.Inputs.data, data) + self.assertTrue(widget.expressions_with_values) + self.assertFalse(widget.fix_button.isHidden()) + self.send_signal(widget.Inputs.data, None) + self.assertTrue(widget.fix_button.isHidden()) + self.send_signal(widget.Inputs.data, data) + self.assertFalse(widget.fix_button.isHidden()) + + def test_migration_no_context(self): + descriptors = [ + ContinuousDescriptor("y", "A + B", 1), + StringDescriptor("u", "str(A) + 'X'") + ] + settings = { + "context_settings": + [Context( + attributes=dict(A=1, B=2), metas={}, + values=dict( + descriptors=descriptors, + currentIndex=1) + )] + } + w = self.create_widget(OWFeatureConstructor, settings) + self.assertEqual(w.descriptors, descriptors) + self.assertEqual(w.currentIndex, 1) + self.assertEqual(w.expressions_with_values, True) + + def test_report(self): + settings = { + "descriptors": [ + ContinuousDescriptor("a", "x + 2", 1), + DiscreteDescriptor("b", "x < 3", (), False), + DiscreteDescriptor("c", "x > 15", (), True), + DiscreteDescriptor("d", "y > x", ("foo", "bar"), False), + DiscreteDescriptor("e", "x ** 2 + y == 5", ("foo", "bar"), True), + StringDescriptor("f", "str(x)"), + DateTimeDescriptor("g", "z") + ], + "currentIndex": 0 + } + + w = self.create_widget(OWFeatureConstructor, settings) + v = [ContinuousVariable(name) for name in "xyz"] + domain = Domain(v, []) + self.send_signal(w.Inputs.data, Table.from_numpy(domain, [[0, 1, 2]])) + w.report_items = Mock() + w.send_report() + args = w.report_items.call_args[0][1] + self.assertEqual(list(args), list("abcdefg")) + + def test_output_domain_picklable(self): + w = self.widget + self.send_signal(w.Inputs.data, Table("iris")[::5]) + features = [ + ContinuousDescriptor("X1", "max(0, sepal_width - 5)", 2), + DiscreteDescriptor("D1", "HIGH if sepal_width > 5 else LOW", + ("HIGH", "LOW"), False), + DiscreteDescriptor("D2", "'HIGH' if sepal_length > 5 else 'LOW'", + (), False), + DateTimeDescriptor("T1", "0"), + DateTimeDescriptor("T2", "'1900-01-01'"), + ] + for f in features: + w.addFeature(f) + w.apply() + out = self.get_output(w.Outputs.data) + domain_a = out.domain + domain_b= pickle.loads(pickle.dumps(domain_a)) + for name in ["X1", "D1", "D2", "T1", "T2"]: + a = domain_a[name] + b = domain_b[name] + self.assertEqual(a, b) + self.assertEqual(hash(a), hash(b)) + class TestFeatureEditor(unittest.TestCase): def test_has_functions(self): @@ -380,47 +625,6 @@ def test_has_functions(self): self.assertIs(FeatureEditor.FUNCTIONS["sqrt"], math.sqrt) -class FeatureConstructorHandlerTests(unittest.TestCase): - def test_handles_builtins_in_expression(self): - self.assertTrue( - FeatureConstructorHandler().is_valid_item( - OWFeatureConstructor.descriptors, - StringDescriptor("X", "str(A) + str(B)"), - {"A": vartype(DiscreteVariable)}, - {"B": vartype(DiscreteVariable)} - ) - ) - - # no variables is also ok - self.assertTrue( - FeatureConstructorHandler().is_valid_item( - OWFeatureConstructor.descriptors, - StringDescriptor("X", "str('foo')"), - {}, - {} - ) - ) - - # should fail on unknown variables - self.assertFalse( - FeatureConstructorHandler().is_valid_item( - OWFeatureConstructor.descriptors, - StringDescriptor("X", "str(X)"), - {}, - {} - ) - ) - - def test_handles_special_characters_in_var_names(self): - self.assertTrue( - FeatureConstructorHandler().is_valid_item( - OWFeatureConstructor.descriptors, - StringDescriptor("X", "A_2_f"), - {"A.2 f": vartype(DiscreteVariable)}, - {} - ) - ) - if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owfeaturestatistics.py b/Orange/widgets/data/tests/test_owfeaturestatistics.py index 629cf98404d..51f0f9231d7 100644 --- a/Orange/widgets/data/tests/test_owfeaturestatistics.py +++ b/Orange/widgets/data/tests/test_owfeaturestatistics.py @@ -316,7 +316,7 @@ def setUp(self): self.send_signal(self.widget.Inputs.data, self.data) self.select_rows = partial(select_rows, widget=self.widget) - def test_changing_data_updates_ouput(self): + def test_changing_data_updates_output(self): # Test behaviour of widget when auto commit is OFF self.widget.auto_commit = False @@ -325,33 +325,18 @@ def test_changing_data_updates_ouput(self): self.select_rows([0]) # By default, nothing should be sent since auto commit is off self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNone(self.get_output(self.widget.Outputs.statistics)) # When we commit, the data should be on the output - self.widget.unconditional_commit() + self.widget.commit.now() self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics)) # Send some new data iris = Table('iris') self.send_signal(self.widget.Inputs.data, iris) # By default, there should be nothing on the output self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNone(self.get_output(self.widget.Outputs.statistics)) # Nothing should change after commit, since we haven't selected any rows - self.widget.unconditional_commit() + self.widget.commit.now() self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNone(self.get_output(self.widget.Outputs.statistics)) - - # Now let's switch back to the original data, where we selected row 0 - self.send_signal(self.widget.Inputs.data, self.data) - # Again, since auto commit is off, nothing should be on the output - self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNone(self.get_output(self.widget.Outputs.statistics)) - # Since the row selection is saved into context settings, the appropriate - # thing should be sent to output - self.widget.unconditional_commit() - self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics)) def test_changing_data_updates_output_with_autocommit(self): # Test behaviour of widget when auto commit is ON @@ -362,25 +347,22 @@ def test_changing_data_updates_output_with_autocommit(self): self.select_rows([0]) # Selecting rows should send data to output self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics)) # Send some new data iris = Table('iris') self.send_signal(self.widget.Inputs.data, iris) # Don't select anything, so the outputs should be empty self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNone(self.get_output(self.widget.Outputs.statistics)) # Now let's switch back to the original data, where we had selected row 0, # we expect that to be sent to output self.send_signal(self.widget.Inputs.data, self.data) self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics)) def test_sends_single_attribute_table_to_output(self): # Check if selecting a single attribute row self.select_rows([0]) - self.widget.unconditional_commit() + self.widget.commit.now() desired_domain = Domain(attributes=[continuous_full.variable]) output = self.get_output(self.widget.Outputs.reduced_data) @@ -389,7 +371,7 @@ def test_sends_single_attribute_table_to_output(self): def test_sends_multiple_attribute_table_to_output(self): # Check if selecting a single attribute row self.select_rows([0, 1]) - self.widget.unconditional_commit() + self.widget.commit.now() desired_domain = Domain(attributes=[ continuous_full.variable, continuous_missing.variable, @@ -399,7 +381,7 @@ def test_sends_multiple_attribute_table_to_output(self): def test_sends_single_class_var_table_to_output(self): self.select_rows([2]) - self.widget.unconditional_commit() + self.widget.commit.now() desired_domain = Domain(attributes=[], class_vars=[rgb_full.variable]) output = self.get_output(self.widget.Outputs.reduced_data) @@ -407,7 +389,7 @@ def test_sends_single_class_var_table_to_output(self): def test_sends_single_meta_table_to_output(self): self.select_rows([4]) - self.widget.unconditional_commit() + self.widget.commit.now() desired_domain = Domain(attributes=[], metas=[ints_full.variable]) output = self.get_output(self.widget.Outputs.reduced_data) @@ -415,7 +397,7 @@ def test_sends_single_meta_table_to_output(self): def test_sends_multiple_var_types_table_to_output(self): self.select_rows([0, 2, 4]) - self.widget.unconditional_commit() + self.widget.commit.now() desired_domain = Domain( attributes=[continuous_full.variable], @@ -428,7 +410,7 @@ def test_sends_multiple_var_types_table_to_output(self): def test_sends_all_samples_to_output(self): """All rows should be sent to output for selected column.""" self.select_rows([0, 2]) - self.widget.unconditional_commit() + self.widget.commit.now() selected_vars = Domain( attributes=[continuous_full.variable], @@ -442,14 +424,84 @@ def test_sends_all_samples_to_output(self): def test_clearing_selection_sends_none_to_output(self): """Clearing all the selected rows should send `None` to output.""" self.select_rows([0]) - self.widget.unconditional_commit() + self.widget.commit.now() self.assertIsNotNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNotNone(self.get_output(self.widget.Outputs.statistics)) self.widget.table_view.clearSelection() - self.widget.unconditional_commit() + self.widget.commit.now() + self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) + + def test_output_statistics(self): + self.widget.auto_commit = True + + data = make_table([continuous_full, continuous_missing, + rgb_full, rgb_missing]) + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.statistics) + np.testing.assert_almost_equal( + output.X, + [[2, 2, 0.7071068, 0, 4, 0], + [1.75, 1.5, 0.8451543, 0, 4, 1], + [np.nan, np.nan, 0.9502705, np.nan, np.nan, 0], + [np.nan, np.nan, 1.0397208, np.nan, np.nan, 1]], + ) + np.testing.assert_equal( + output.metas, + [["continuous_full", "0"], + ["continuous_missing", "0"], + ["rgb_full", "g"], + ["rgb_missing", "g"]]) + + data = make_table([continuous_full, continuous_missing]) + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.statistics) + np.testing.assert_almost_equal( + output.X, + [[2, 2, 0.7071068, 0, 4, 0], + [1.75, 1.5, 0.8451543, 0, 4, 1]] + ) + np.testing.assert_equal( + output.metas, + [["continuous_full", "0"], + ["continuous_missing", "0"]]) + + data = make_table([rgb_full, rgb_missing]) + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.statistics) + np.testing.assert_almost_equal( + output.X, + [[0.9502705, 0], + [1.0397208, 1]], + ) + np.testing.assert_equal( + output.metas, + [["rgb_full", "g"], + ["rgb_missing", "g"]]) + + self.send_signal(self.widget.Inputs.data, None) + output = self.get_output(self.widget.Outputs.statistics) + self.assertIsNone(output) + + def test_output_combinations(self): + # No selection -> reduced_data is not output, statistics is present + self.widget.commit.now() + self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) + self.assertEqual(len(self.get_output(self.widget.Outputs.statistics)), + self.widget.model.rowCount()) + + # Has selection -> all outputs present + self.select_rows([0, 1]) + self.widget.commit.now() + outp = self.get_output(self.widget.Outputs.reduced_data) + self.assertEqual(len(outp), len(self.data)) + self.assertEqual(len(outp.domain.variables), 2) + self.assertEqual(len(self.get_output(self.widget.Outputs.statistics)), + self.widget.model.rowCount()) + + # No data -> no output + self.send_signal(self.widget.Inputs.data, None) + self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) self.assertIsNone(self.get_output(self.widget.Outputs.reduced_data)) - self.assertIsNone(self.get_output(self.widget.Outputs.statistics)) class TestFeatureStatisticsUI(WidgetTest): @@ -511,6 +563,39 @@ def test_report(self): self.assertIn("", report_text) self.assertEqual(6, report_text.count("")) # header + 5 rows + def test_color_legend(self): + w = self.widget + data = Table("heart_disease") + self.send_signal(self.widget.Inputs.data, data) + + self.assertIs(w.color_var, data.domain.class_var) + self.assertEqual(len(w.legend_items), 2) + self.assertFalse(w.legend_view.isHidden()) + + w.cb_color_var.setCurrentIndex(4) # age (numeric, no legend) + w.cb_color_var.activated.emit(4) + self.assertEqual(len(w.legend_items), 0) + self.assertTrue(w.legend_view.isHidden()) + + w.cb_color_var.setCurrentIndex(6) # chest pain + w.cb_color_var.activated.emit(6) + self.assertEqual(len(w.legend_items), 4) + self.assertFalse(w.legend_view.isHidden()) + + w.cb_color_var.setCurrentIndex(0) # None + w.cb_color_var.activated.emit(0) + self.assertEqual(len(w.legend_items), 0) + self.assertTrue(w.legend_view.isHidden()) + + # Show + w.cb_color_var.setCurrentIndex(6) # chest pain + w.cb_color_var.activated.emit(6) + + # to check that the legend is hidden when the data is removed + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(len(w.legend_items), 0) + self.assertTrue(w.legend_view.isHidden()) + class TestSummary(WidgetTest): def setUp(self): diff --git a/Orange/widgets/data/tests/test_owfile.py b/Orange/widgets/data/tests/test_owfile.py index 13793f3737c..b0c86417c01 100644 --- a/Orange/widgets/data/tests/test_owfile.py +++ b/Orange/widgets/data/tests/test_owfile.py @@ -1,19 +1,20 @@ # Test methods with long descriptive names can omit docstrings -# pylint: disable=missing-docstring,protected-access +# pylint: disable=missing-docstring,protected-access,too-many-public-methods from os import path, remove, getcwd from os.path import dirname import unittest +from threading import Thread from unittest.mock import Mock, patch import pickle import tempfile import warnings -import time import numpy as np import scipy.sparse as sp -from AnyQt.QtCore import QMimeData, QPoint, Qt, QUrl, QThread, QObject +from AnyQt.QtCore import QMimeData, QPoint, Qt, QUrl, QPointF from AnyQt.QtGui import QDragEnterEvent, QDropEvent +from AnyQt.QtTest import QTest from AnyQt.QtWidgets import QComboBox import Orange @@ -24,7 +25,7 @@ from Orange.data.io import TabReader from Orange.tests import named_file -from Orange.widgets.data.owfile import OWFile +from Orange.widgets.data.owfile import OWFile, OWFileDropHandler, DEFAULT_READER_TEXT from Orange.widgets.utils.filedialogs import dialog_formats, format_filter, RecentPath from Orange.widgets.tests.base import WidgetTest from Orange.widgets.utils.domaineditor import ComboDelegate, VarTypeDelegate, VarTableModel @@ -40,7 +41,9 @@ class FailedSheetsFormat(FileFormat): def read(self): pass + @property def sheets(self): + # pylint: disable=broad-exception-raised raise Exception("Not working") @@ -70,22 +73,30 @@ class TestOWFile(WidgetTest): event_data = None def setUp(self): + super().setUp() self.widget = self.create_widget(OWFile) # type: OWFile dataset_dirs.append(dirname(__file__)) def tearDown(self): dataset_dirs.pop() + super().tearDown() + + def test_describe_call_get_nans(self): + table = Table("iris") + with patch.object(Table, "get_nan_frequency_attribute", return_value=0.) as mock: + self.widget._describe(table) + mock.assert_called() + + table = Table.from_numpy(domain=None, X=np.random.random((10000, 1000))) + with patch.object(Table, "get_nan_frequency_attribute", return_value=0.) as mock: + self.widget._describe(table) + mock.assert_not_called() def test_dragEnterEvent_accepts_urls(self): event = self._drag_enter_event(QUrl.fromLocalFile(TITANIC_PATH)) self.widget.dragEnterEvent(event) self.assertTrue(event.isAccepted()) - def test_dragEnterEvent_skips_osx_file_references(self): - event = self._drag_enter_event(QUrl.fromLocalFile('/.file/id=12345')) - self.widget.dragEnterEvent(event) - self.assertFalse(event.isAccepted()) - def test_dragEnterEvent_skips_usupported_files(self): event = self._drag_enter_event(QUrl.fromLocalFile('file.unsupported')) self.widget.dragEnterEvent(event) @@ -110,18 +121,25 @@ def test_dropEvent_selects_file(self): self.assertTrue(path.samefile(self.widget.last_path(), TITANIC_PATH)) self.widget.load_data.assert_called_with() + event = self._drop_event(QUrl("https://example.com/aa.csv")) + self.widget.load_data.reset_mock() + self.widget.dropEvent(event) + self.assertEqual(self.widget.source, OWFile.URL) + self.widget.load_data.assert_called_with() + def _drop_event(self, url): # make sure data does not get garbage collected before it used self.event_data = data = QMimeData() data.setUrls([QUrl(url)]) return QDropEvent( - QPoint(0, 0), Qt.MoveAction, data, + QPointF(0, 0), Qt.MoveAction, data, Qt.NoButton, Qt.NoModifier, QDropEvent.Drop) def test_check_file_size(self): self.assertFalse(self.widget.Warning.file_too_big.is_shown()) self.widget.SIZE_LIMIT = 4000 + # We're avoiding __new__, pylint: disable=unnecessary-dunder-call self.widget.__init__() self.assertTrue(self.widget.Warning.file_too_big.is_shown()) @@ -253,12 +271,14 @@ def test_nothing_selected(self): self.create_widget(OWFile, stored_settings={"recent_paths": []}) widget.Outputs.data.send = Mock() - widget._try_load() + widget.load_data() + self.assertTrue(widget.Information.no_file_selected.is_shown()) widget.Outputs.data.send.assert_called_with(None) widget.Outputs.data.send.reset_mock() widget.source = widget.URL - widget._try_load() + widget.load_data() + self.assertTrue(widget.Information.no_file_selected.is_shown()) widget.Outputs.data.send.assert_called_with(None) def test_check_column_noname(self): @@ -320,7 +340,9 @@ def test_check_datetime_disabled(self): with named_file(dat, suffix=".tab") as filename: self.open_dataset(filename) domain_editor = self.widget.domain_editor - idx = lambda x: self.widget.domain_editor.model().createIndex(x, 1) + + def idx(x): + return self.widget.domain_editor.model().createIndex(x, 1) qcombobox = QComboBox() combo = ComboDelegate(domain_editor, @@ -344,13 +366,13 @@ def test_reader_custom_tab(self): outdata = self.get_output(self.widget.Outputs.data) self.assertEqual(len(outdata), 150) # loaded iris - def test_no_reader_extension(self): + def test_unknown_extension(self): with named_file("", suffix=".xyz_unknown") as fn: no_reader = RecentPath(fn, None, None) self.widget = self.create_widget(OWFile, stored_settings={"recent_paths": [no_reader]}) self.widget.load_data() - self.assertTrue(self.widget.Error.missing_reader.is_shown()) + self.assertTrue(self.widget.Error.select_file_type.is_shown()) def test_fail_sheets(self): with named_file("", suffix=".failed_sheet") as fn: @@ -380,6 +402,7 @@ def open_iris_with_no_spec_format(_a, _b, _c, filters, _e): self.widget.browse_file() self.assertIsNone(self.widget.recent_paths[0].file_format) + self.assertEqual(self.widget.reader_combo.currentText(), DEFAULT_READER_TEXT) def open_iris_with_tab(*_): return iris.__file__, format_filter(TabReader) @@ -389,6 +412,7 @@ def open_iris_with_tab(*_): self.widget.browse_file() self.assertEqual(self.widget.recent_paths[0].file_format, "Orange.data.io.TabReader") + self.assertTrue(self.widget.reader_combo.currentText().startswith("Tab-separated")) def test_no_specified_reader(self): with named_file("", suffix=".tab") as fn: @@ -397,6 +421,163 @@ def test_no_specified_reader(self): stored_settings={"recent_paths": [no_class]}) self.widget.load_data() self.assertTrue(self.widget.Error.missing_reader.is_shown()) + self.assertEqual(self.widget.reader_combo.currentText(), "not.a.file.reader.class") + + + def _select_reader(self, name): + reader_combo = self.widget.reader_combo + len_with_qname = len(reader_combo) + for i in range(len_with_qname): + text = reader_combo.itemText(i) + if text.startswith(name): + break + else: + assert f"No reader starts with {name!r}" + reader_combo.setCurrentIndex(i) + reader_combo.activated.emit(i) + + def _select_tab_reader(self): + self._select_reader("Tab-separated") + + def test_select_reader(self): + filename = FileFormat.locate("iris.tab", dataset_dirs) + + # a setting which adds a new qualified name to the reader combo + no_class = RecentPath(filename, None, None, file_format="not.a.file.reader.class") + self.widget = self.create_widget(OWFile, + stored_settings={"recent_paths": [no_class]}) + self.widget.load_data() + len_with_qname = len(self.widget.reader_combo) + self.assertEqual(self.widget.reader_combo.currentText(), "not.a.file.reader.class") + self.assertEqual(self.widget.reader, None) + + # select the last option, the same reader + self.widget.reader_combo.activated.emit(len_with_qname - 1) + self.assertEqual(len(self.widget.reader_combo), len_with_qname) + self.assertEqual(self.widget.reader_combo.currentText(), "not.a.file.reader.class") + self.assertEqual(self.widget.reader, None) + + self._select_tab_reader() + self.assertEqual(len(self.widget.reader_combo), len_with_qname - 1) + self.assertTrue(self.widget.reader_combo.currentText().startswith("Tab-separated")) + self.assertIsInstance(self.widget.reader, TabReader) + + # select the default reader + self.widget.reader_combo.activated.emit(0) + self.assertEqual(len(self.widget.reader_combo), len_with_qname - 1) + self.assertEqual(self.widget.reader_combo.currentText(), DEFAULT_READER_TEXT) + self.assertIsInstance(self.widget.reader, TabReader) + + def test_auto_detect_and_override(self): + tab_as_xlsx = FileFormat.locate("actually-a-tab-file.xlsx", + [dirname(__file__)]) + iris = FileFormat.locate("iris", dataset_dirs) + + reader_combo = self.widget.reader_combo + + reader_combo.setCurrentIndex(0) + reader_combo.activated.emit(0) + assert (self.widget.reader_combo.currentText() + == "Determine type from the file extension") + + def open_file(_a, _b, _c, filters, _e): + return filename, filters.split(";;")[0] + + with patch("AnyQt.QtWidgets.QFileDialog.getOpenFileName", + open_file): + + # Loading a tab file with extension xlsx fails with auto-detect + filename = tab_as_xlsx + self.widget.browse_file() + + self.assertEqual(self.widget.reader_combo.currentText(), + "Determine type from the file extension") + self.assertTrue(self.widget.Error.unknown_select.is_shown()) + self.assertIsNone(self.get_output(self.widget.Outputs.data)) + + # Select the tab reader: it should work + self._select_tab_reader() + assert "Tab-separated" in self.widget.reader_combo.currentText() + + self.assertFalse(self.widget.Error.unknown_select.is_shown()) + self.assertIsInstance(self.widget.reader, TabReader) + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + + # Switching to iris resets the combo to auto-detect + filename = iris + self.widget.browse_file() + self.assertEqual(self.widget.reader_combo.currentText(), + "Determine type from the file extension") + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + + # Taking the tab-as-xlsx file from recent paths should restore + # the file type for that file + self.widget.file_combo.setCurrentIndex(1) + self.widget.file_combo.activated.emit(1) + self.assertIn("Tab-separated", self.widget.reader_combo.currentText()) + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + + # Reloading should work + self.widget.load_data() + self.assertIn("Tab-separated", self.widget.reader_combo.currentText()) + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + + # Loading this file - not from history - should fail + filename = tab_as_xlsx + self.widget.browse_file() + self.assertTrue(self.widget.Error.unknown_select.is_shown()) + self.assertIsNone(self.get_output(self.widget.Outputs.data)) + + # Set the correct type again (preparation for the next text block) + self._select_tab_reader() + assert not self.widget.Error.unknown_select.is_shown() + assert isinstance(self.widget.reader, TabReader) + assert self.get_output(self.widget.Outputs.data) is not None + + # Now load a real Excel file: this is a known excention so the combo + # should return to auto-detect + filename = FileFormat.locate("an_excel_file.xlsx", [dirname(__file__)]) + self.widget.browse_file() + self.assertEqual(self.widget.reader_combo.currentText(), + "Determine type from the file extension") + self.assertFalse(self.widget.Error.unknown_select.is_shown()) + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + + # Load iris to prepare for the next test block + filename = iris + self.widget.browse_file() + assert (self.widget.reader_combo.currentText() + == "Determine type from the file extension") + assert self.get_output(self.widget.Outputs.data) is not None + + # Files with unknown extensions require manual selection + filename = FileFormat.locate("an_excel_file.foo", [dirname(__file__)]) + self.widget.browse_file() + self.assertTrue(self.widget.Error.select_file_type.is_shown()) + self.assertIsNone(self.get_output(self.widget.Outputs.data)) + + self._select_reader("Excel") + self.assertFalse(self.widget.Error.unknown_select.is_shown()) + self.assertFalse(self.widget.Error.select_file_type.is_shown()) + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + + # Consecutive loading of files with the same extension keeps selection + filename = FileFormat.locate("an_excel_file-too.foo", [dirname(__file__)]) + self.widget.browse_file() + self.assertFalse(self.widget.Error.unknown_select.is_shown()) + self.assertFalse(self.widget.Error.select_file_type.is_shown()) + self.assertIsNotNone(self.get_output(self.widget.Outputs.data)) + + def test_select_reader_errors(self): + filename = FileFormat.locate("iris.tab", dataset_dirs) + + no_class = RecentPath(filename, None, None, file_format="Orange.data.io.ExcelReader") + self.widget = self.create_widget(OWFile, + stored_settings={"recent_paths": [no_class]}) + self.widget.load_data() + self.assertIn("Excel", self.widget.reader_combo.currentText()) + self.assertTrue(self.widget.Error.unknown.is_shown()) + self.assertFalse(self.widget.Error.missing_reader.is_shown()) def test_domain_edit_no_changes(self): self.open_dataset("iris") @@ -408,12 +589,12 @@ def test_domain_edit_no_changes(self): def test_domain_edit_on_sparse_data(self): iris = Table("iris").to_sparse() - f = tempfile.NamedTemporaryFile(suffix='.pickle', delete=False) - pickle.dump(iris, f) - f.close() + with named_file("", suffix='.pickle') as fn: + with open(fn, "wb") as f: + pickle.dump(iris, f) - self.widget.add_path(f.name) - self.widget.load_data() + self.widget.add_path(fn) + self.widget.load_data() output = self.get_output(self.widget.Outputs.data) self.assertIsInstance(output, Table) @@ -552,9 +733,8 @@ def test_open_moved_workflow(self): (i.e. sent by email), considering data file is stored in the same directory as the workflow. """ - temp_file = tempfile.NamedTemporaryFile(dir=getcwd(), delete=False) - file_name = temp_file.name - temp_file.close() + with tempfile.NamedTemporaryFile(dir=getcwd(), delete=False) as temp_file: + file_name = temp_file.name base_name = path.basename(file_name) try: recent_path = RecentPath( @@ -575,9 +755,8 @@ def test_files_relocated(self): """ This test testes if paths are relocated correctly """ - temp_file = tempfile.NamedTemporaryFile(dir=getcwd(), delete=False) - file_name = temp_file.name - temp_file.close() + with tempfile.NamedTemporaryFile(dir=getcwd(), delete=False) as temp_file: + file_name = temp_file.name base_name = path.basename(file_name) try: recent_path = RecentPath( @@ -618,23 +797,13 @@ def test_sheets(self): @patch("os.path.exists", new=lambda _: True) def test_warning_from_another_thread(self): - class AnotherWidget(QObject): - # This must be a method, not a staticmethod to run in the thread - def issue_warning(self): # pylint: disable=no-self-use - time.sleep(0.1) - warnings.warn("warning from another thread") - warning_thread.quit() - def read(): - warning_thread.start() - time.sleep(0.2) + thread = Thread( + target=lambda: warnings.warn("warning from another thread") + ) + thread.start() + thread.join() return Table(TITANIC_PATH) - - warning_thread = QThread() - another_widget = AnotherWidget() - another_widget.moveToThread(warning_thread) - warning_thread.started.connect(another_widget.issue_warning) - reader = Mock() reader.read = read self.widget._get_reader = lambda: reader @@ -646,7 +815,6 @@ def read(): self.widget._try_load() self.assertFalse(self.widget.Warning.load_warning.is_shown()) - @patch("os.path.exists", new=lambda _: True) def test_warning_from_this_thread(self): WARNING_MSG = "warning from this thread" @@ -665,6 +833,53 @@ def read(): self.assertTrue(self.widget.Warning.load_warning.is_shown()) self.assertIn(WARNING_MSG, str(self.widget.Warning.load_warning)) + def test_recent_url_serialization(self): + with patch.object(self.widget, "load_data", lambda: None): + self.widget.url_combo.insertItem(0, "https://example.com/test.tab") + self.widget.url_combo.insertItem(1, "https://example.com/test1.tab") + self.widget.source = OWFile.URL + s = self.widget.settingsHandler.pack_data(self.widget) + self.assertEqual(s["recent_urls"], + ["https://example.com/test.tab", + "https://example.com/test1.tab"]) + self.widget.url_combo.lineEdit().clear() + QTest.keyClicks(self.widget.url_combo, "https://example.com/test1.tab") + QTest.keyClick(self.widget.url_combo, Qt.Key_Enter) + # must move the entered url to first position + s = self.widget.settingsHandler.pack_data(self.widget) + self.assertEqual(s["recent_urls"], + ["https://example.com/test1.tab", + "https://example.com/test.tab"]) + + +class TestOWFileDropHandler(unittest.TestCase): + def test_canDropUrl(self): + handler = OWFileDropHandler() + self.assertTrue(handler.canDropUrl(QUrl("https://example.com/test.tab"))) + self.assertTrue(handler.canDropUrl(QUrl.fromLocalFile("test.tab"))) + + def test_parametersFromUrl(self): + handler = OWFileDropHandler() + r = handler.parametersFromUrl(QUrl("https://example.com/test.tab")) + self.assertEqual(r["source"], OWFile.URL) + self.assertEqual(r["recent_urls"], ["https://example.com/test.tab"]) + r = handler.parametersFromUrl(QUrl.fromLocalFile("test.tab")) + self.assertEqual(r["source"], OWFile.LOCAL_FILE) + self.assertEqual(r["recent_paths"][0].basename, "test.tab") + + defs = { + "source": OWFile.LOCAL_FILE, + "recent_paths": [ + RecentPath("/foo.tab", None, None, "foo.tab"), + RecentPath(path.abspath("test.tab"), None, None, "test.tab"), + ] + } + with patch.object(OWFile.settingsHandler, "defaults", defs): + r = handler.parametersFromUrl(QUrl.fromLocalFile("test.tab")) + + self.assertEqual(len(r["recent_paths"]), 2) + self.assertEqual(r["recent_paths"][0].basename, "test.tab") + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owgroupby.py b/Orange/widgets/data/tests/test_owgroupby.py new file mode 100644 index 00000000000..d8032eafe76 --- /dev/null +++ b/Orange/widgets/data/tests/test_owgroupby.py @@ -0,0 +1,965 @@ +import os +import unittest +from collections import Counter +from typing import List +from unittest.mock import Mock, patch + +import numpy as np +import pandas as pd +from AnyQt import QtCore +from AnyQt.QtCore import QItemSelectionModel, Qt +from AnyQt.QtWidgets import QListView + +from Orange.data import ( + Table, + table_to_frame, + Domain, + ContinuousVariable, + DiscreteVariable, + TimeVariable, + StringVariable, +) +from Orange.data.tests.test_aggregate import create_sample_data +from Orange.widgets.data.owgroupby import OWGroupBy +from Orange.widgets.tests.base import WidgetTest + + +class TestOWGroupBy(WidgetTest): + def setUp(self) -> None: + self.widget = self.create_widget(OWGroupBy) + self.iris = Table("iris") + + self.data = create_sample_data() + + def test_none_data(self): + self.send_signal(self.widget.Inputs.data, None) + + self.assertEqual(self.widget.agg_table_model.rowCount(), 0) + self.assertEqual(self.widget.gb_attrs_model.rowCount(), 0) + self.assertIsNone(self.get_output(self.widget.Outputs.data)) + + def test_data(self): + self.send_signal(self.widget.Inputs.data, self.iris) + + self.assertEqual(self.widget.agg_table_model.rowCount(), 5) + self.assertEqual(self.widget.gb_attrs_model.rowCount(), 5) + + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(3, len(output)) + + self.send_signal(self.widget.Inputs.data, None) + self.assertIsNone(self.get_output(self.widget.Outputs.data)) + + def test_data_domain_changed(self): + self.send_signal(self.widget.Inputs.data, self.iris[:, -2:]) + self.assert_aggregations_equal(["Mean", "Mode"]) + + self.send_signal(self.widget.Inputs.data, self.iris[:, -3:]) + self.assert_aggregations_equal(["Mean", "Mean", "Mode"]) + self.select_table_rows(self.widget.agg_table_view, [0]) + + @staticmethod + def _set_selection(view: QListView, indices: List[int]): + view.clearSelection() + sm = view.selectionModel() + model = view.model() + for ind in indices: + sm.select(model.index(ind, 0), QItemSelectionModel.Select) + + def test_groupby_attr_selection(self): + gb_view = self.widget.controls.gb_attrs + self.send_signal(self.widget.Inputs.data, self.iris) + + self._set_selection(gb_view, [1]) # sepal length + self.wait_until_finished() + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(35, len(output)) + + # select iris attribute with index 0 + self._set_selection(gb_view, [0]) + self.wait_until_finished() + + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(3, len(output)) + + # select iris and sepal length attribute + self._set_selection(gb_view, [0, 1]) + self.wait_until_finished() + + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(57, len(output)) + + def assert_enabled_cbs(self, enabled_true): + enabled_actual = set( + name for name, cb in self.widget.agg_checkboxes.items() if cb.isEnabled() + ) + self.assertSetEqual(enabled_true, enabled_actual) + + @staticmethod + def select_table_rows(table, rows): + table.clearSelection() + indexes = [table.model().index(r, 0) for r in rows] + mode = QtCore.QItemSelectionModel.Select | QtCore.QItemSelectionModel.Rows + for i in indexes: + table.selectionModel().select(i, mode) + + def test_attr_table_row_selection(self): + # fmt: off + continuous_aggs = { + "Mean", "Median", "Q1", "Q3", "Min. value", "Max. value", "Mode", "Sum", + "Standard deviation", "Variance", "Count defined", "Count", "Concatenate", + "Span", "First value", "Last value", "Random value", "Proportion defined", + } + discrete_aggs = { + "Mode", "Count defined", "Count", "Concatenate", "First value", + "Last value", "Random value", "Proportion defined" + } + string_aggs = { + "Count defined", "Count", "Concatenate", "First value", + "Last value", "Random value", "Proportion defined" + } + # fmt: on + self.send_signal(self.widget.Inputs.data, self.data) + + model = self.widget.agg_table_model + table = self.widget.agg_table_view + + self.assertListEqual( + ["a", "b", "cvar", "dvar", "svar"], + [model.data(model.index(i, 0)) for i in range(model.rowCount())], + ) + + self.select_table_rows(table, [0]) + self.assert_enabled_cbs(continuous_aggs) + self.select_table_rows(table, [0, 1]) + self.assert_enabled_cbs(continuous_aggs) + self.select_table_rows(table, [2]) + self.assert_enabled_cbs(continuous_aggs) + self.select_table_rows(table, [3]) # discrete variable + self.assert_enabled_cbs(discrete_aggs) + self.select_table_rows(table, [4]) # string variable + self.assert_enabled_cbs(string_aggs) + self.select_table_rows(table, [3, 4]) # discrete + string variable + self.assert_enabled_cbs(string_aggs | discrete_aggs) + self.select_table_rows(table, [2, 3, 4]) # cont + disc + str variable + self.assert_enabled_cbs(string_aggs | discrete_aggs | continuous_aggs) + + def assert_aggregations_equal(self, expected_text): + model = self.widget.agg_table_model + agg_text = [model.data(model.index(i, 1)) for i in range(model.rowCount())] + self.assertListEqual(expected_text, agg_text) + + def test_aggregations_change(self): + table = self.widget.agg_table_view + d = self.data.domain + + self.send_signal(self.widget.Inputs.data, self.data) + + self.assert_aggregations_equal( + ["Mean", "Mean", "Mean", "Mode", "Concatenate"] + ) + self.assertDictEqual( + { + d["a"]: {"Mean"}, + d["b"]: {"Mean"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.select_table_rows(table, [0]) + self.widget.agg_checkboxes["Median"].click() + self.assert_aggregations_equal( + ["Mean, Median", "Mean", "Mean", "Mode", "Concatenate"] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Median"}, + d["b"]: {"Mean"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.select_table_rows(table, [0, 1]) + self.widget.agg_checkboxes["Mode"].click() + self.assert_aggregations_equal( + ["Mean, Median, Mode", "Mean, Mode", "Mean", "Mode", "Concatenate"] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Median", "Mode"}, + d["b"]: {"Mean", "Mode"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.select_table_rows(table, [0, 1]) + # median is partially checked and will become checked + self.assertEqual( + Qt.PartiallyChecked, self.widget.agg_checkboxes["Median"].checkState() + ) + self.widget.agg_checkboxes["Median"].click() + self.assertEqual(Qt.Checked, self.widget.agg_checkboxes["Median"].checkState()) + self.assert_aggregations_equal( + [ + "Mean, Median, Mode", + "Mean, Median, Mode", + "Mean", + "Mode", + "Concatenate", + ] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Median", "Mode"}, + d["b"]: {"Mean", "Median", "Mode"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.widget.agg_checkboxes["Median"].click() + self.assertEqual( + Qt.Unchecked, self.widget.agg_checkboxes["Median"].checkState() + ) + self.assert_aggregations_equal( + ["Mean, Mode", "Mean, Mode", "Mean", "Mode", "Concatenate"] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Mode"}, + d["b"]: {"Mean", "Mode"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.select_table_rows(table, [0, 3]) + # median is unchecked and will change to partially checked + self.assertEqual( + Qt.Unchecked, self.widget.agg_checkboxes["Median"].checkState() + ) + self.widget.agg_checkboxes["Median"].click() + self.assertEqual( + Qt.PartiallyChecked, self.widget.agg_checkboxes["Median"].checkState() + ) + self.assert_aggregations_equal( + ["Mean, Median, Mode", "Mean, Mode", "Mean", "Mode", "Concatenate"] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Median", "Mode"}, + d["b"]: {"Mean", "Mode"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.widget.agg_checkboxes["Median"].click() + self.assertEqual( + Qt.Unchecked, self.widget.agg_checkboxes["Median"].checkState() + ) + self.assert_aggregations_equal( + ["Mean, Mode", "Mean, Mode", "Mean", "Mode", "Concatenate"] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Mode"}, + d["b"]: {"Mean", "Mode"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.widget.agg_checkboxes["Count"].click() + self.assertEqual(Qt.Checked, self.widget.agg_checkboxes["Count"].checkState()) + self.assert_aggregations_equal( + [ + "Mean, Mode, Count", + "Mean, Mode", + "Mean", + "Mode, Count", + "Concatenate", + ] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Mode", "Count"}, + d["b"]: {"Mean", "Mode"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Count", "Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + # test the most complicated scenario: numeric with mode, numeric without + # mode and discrete + self.select_table_rows(table, [0]) + self.widget.agg_checkboxes["Mode"].click() + self.assert_aggregations_equal( + ["Mean, Count", "Mean, Mode", "Mean", "Mode, Count", "Concatenate"] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Count"}, + d["b"]: {"Mean", "Mode"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Count", "Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.select_table_rows(table, [0, 1, 4]) + self.assertEqual( + Qt.PartiallyChecked, self.widget.agg_checkboxes["Mode"].checkState() + ) + self.widget.agg_checkboxes["Mode"].click() + # must stay partially checked since one Continuous can still have mode + # as a aggregation and string cannot have it + self.assertEqual( + Qt.PartiallyChecked, self.widget.agg_checkboxes["Mode"].checkState() + ) + self.assert_aggregations_equal( + [ + "Mean, Mode, Count", + "Mean, Mode", + "Mean", + "Mode, Count", + "Concatenate", + ] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Mode", "Count"}, + d["b"]: {"Mean", "Mode"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Count", "Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + # since now all that can have Mode have it as an aggregation it can be + # unchecked on the next click + self.widget.agg_checkboxes["Mode"].click() + self.assertEqual(Qt.Unchecked, self.widget.agg_checkboxes["Mode"].checkState()) + self.assert_aggregations_equal( + ["Mean, Count", "Mean", "Mean", "Mode, Count", "Concatenate"] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Count"}, + d["b"]: {"Mean"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Count", "Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + self.widget.agg_checkboxes["Mode"].click() + self.widget.agg_checkboxes["Count defined"].click() + self.assertEqual( + Qt.PartiallyChecked, self.widget.agg_checkboxes["Mode"].checkState() + ) + self.assert_aggregations_equal( + [ + "Mean, Mode, Count defined and 1 more", + "Mean, Mode, Count defined", + "Mean", + "Mode, Count", + "Concatenate, Count defined", + ] + ) + self.assertDictEqual( + { + d["a"]: {"Mean", "Mode", "Count", "Count defined"}, + d["b"]: {"Mean", "Mode", "Count defined"}, + d["cvar"]: {"Mean"}, + d["dvar"]: {"Count", "Mode"}, + d["svar"]: {"Concatenate", "Count defined",}, + }, + self.widget.aggregations, + ) + + def test_aggregation(self): + """Test aggregation results""" + self.send_signal(self.widget.Inputs.data, self.data) + self._set_selection(self.widget.controls.gb_attrs, [1]) # a var + output = self.get_output(self.widget.Outputs.data) + + np.testing.assert_array_almost_equal( + output.X, [[1, 2.143, 0.317, 0], [2, 2, 2, 0]], decimal=3 + ) + np.testing.assert_array_equal( + output.metas, + np.array( + [ + [ + "sval1 sval2 sval2 sval1 sval2 sval1", + 1.0, + ], + [ + "sval2 sval1 sval2 sval1 sval2 sval1", + 2.0, + ], + ], + dtype=object, + ), + ) + + # select all aggregations for all features except a and b + self._set_selection(self.widget.controls.gb_attrs, [1, 2]) + self.select_table_rows(self.widget.agg_table_view, [2, 3, 4]) + # select all aggregations + for cb in self.widget.agg_checkboxes.values(): + cb.click() + while not cb.isChecked(): + cb.click() + + self.select_table_rows(self.widget.agg_table_view, [0, 1]) + # unselect all aggregations for attr a and b + for cb in self.widget.agg_checkboxes.values(): + while cb.isChecked(): + cb.click() + + expected_columns = [ + "cvar - Mean", + "cvar - Median", + "cvar - Q1", + "cvar - Q3", + "cvar - Min. value", + "cvar - Max. value", + "cvar - Mode", + "cvar - Standard deviation", + "cvar - Variance", + "cvar - Sum", + "cvar - Span", + "cvar - First value", + "cvar - Last value", + "cvar - Count defined", + "cvar - Count", + "cvar - Proportion defined", + "dvar - Mode", + "dvar - First value", + "dvar - Last value", + "dvar - Count defined", + "dvar - Count", + "dvar - Proportion defined", + "svar - Count defined", + "svar - Count", + "svar - Proportion defined", + "cvar - Concatenate", + "dvar - Concatenate", + "svar - Concatenate", + "svar - First value", + "svar - Last value", + "a", # groupby variables are last two in metas + "b", + ] + + # fmt: off + expected_df = pd.DataFrame([ + [.15, .15, .125, .175, .1, .2, .1, .07, .005, .3, .1, 0.1, 0.2, 2, 2, 1, + "val1", "val1", "val2", 2, 2, 1, + 2, 2, 1, + "0.1 0.2", "val1 val2", "sval1 sval2", "sval1", "sval2", + 1, 1], + [.3, .3, .3, .3, .3, .3, .3, np.nan, np.nan, .3, 0, .3, .3, 1, 2, 0.5, + "val2", "val2", "val2", 1, 2, 0.5, + 2, 2, 1, + "0.3", "val2", "sval2", "", "sval2", + 1, 2], + [.433, .4, .35, .5, .3, .6, .3, 0.153, 0.023, 1.3, .3, .3, .6, 3, 3, 1, + "val1", "val1", "val1", 3, 3, 1, + 3, 3, 1, + "0.3 0.4 0.6", "val1 val2 val1", "sval1 sval2 sval1", "sval1", "sval1", + 1, 3], + [1.5, 1.5, 1.25, 1.75, 1, 2, 1, 0.707, 0.5, 3, 1, 1, 2, 2, 2, 1, + "val1", "val2", "val1", 2, 2, 1, + 2, 2, 1, + "1.0 2.0", "val2 val1", "sval2 sval1", "sval2", "sval1", + 2, 1], + [-0.5, -0.5, -2.25, 1.25, -4, 3, -4, 4.95, 24.5, -1, 7, 3, -4, 2, 2, 1, + "val1", "val2", "val1", 2, 2, 1, + 2, 2, 1, + "3.0 -4.0", "val2 val1", "sval2 sval1", "sval2", "sval1", + 2, 2], + [5, 5, 5, 5, 5, 5, 5, 0, 0, 10, 0, 5, 5, 2, 2, 1, + "val1", "val2", "val1", 2, 2, 1, + 2, 2, 1, + "5.0 5.0", "val2 val1", "sval2 sval1", "sval2", "sval1", + 2, 3] + ], columns=expected_columns + ) + # fmt: on + + output_df = table_to_frame( + self.get_output(self.widget.Outputs.data), include_metas=True + ) + # remove random since it is not possible to test + output_df = output_df.loc[:, ~output_df.columns.str.endswith("Random value")] + + pd.testing.assert_frame_equal( + output_df, + expected_df, + check_dtype=False, + check_column_type=False, + check_categorical=False, + atol=1e-3, + ) + + def test_metas_results(self): + """Test if variable that is in meta in input table remains in metas""" + self.send_signal(self.widget.Inputs.data, self.data) + self._set_selection(self.widget.controls.gb_attrs, [0, 1]) + + output = self.get_output(self.widget.Outputs.data) + self.assertIn(self.data.domain["svar"], output.domain.metas) + + def test_context(self): + d = self.data.domain + self.send_signal(self.widget.Inputs.data, self.data) + + self.assert_aggregations_equal( + ["Mean", "Mean", "Mean", "Mode", "Concatenate"] + ) + + self.select_table_rows(self.widget.agg_table_view, [0, 2]) + self.widget.agg_checkboxes["Median"].click() + self.assert_aggregations_equal( + ["Mean, Median", "Mean", "Mean, Median", "Mode", "Concatenate"] + ) + + self._set_selection(self.widget.controls.gb_attrs, [1, 2]) + self.assertListEqual([d["a"], d["b"]], self.widget.gb_attrs) + self.assertDictEqual( + { + d["a"]: {"Mean", "Median"}, + d["b"]: {"Mean"}, + d["cvar"]: {"Mean", "Median"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + # send new data and previous data to check if context restored correctly + self.send_signal(self.widget.Inputs.data, self.iris) + self.send_signal(self.widget.Inputs.data, self.data) + + self.assert_aggregations_equal( + ["Mean, Median", "Mean", "Mean, Median", "Mode", "Concatenate"] + ) + self._set_selection(self.widget.controls.gb_attrs, [1, 2]) + self.assertListEqual([d["a"], d["b"]], self.widget.gb_attrs) + self.assertDictEqual( + { + d["a"]: {"Mean", "Median"}, + d["b"]: {"Mean"}, + d["cvar"]: {"Mean", "Median"}, + d["dvar"]: {"Mode"}, + d["svar"]: {"Concatenate"}, + }, + self.widget.aggregations, + ) + + def test_context_time_variable(self): + """ + Test migrate_context which removes sum for TimeVariable since + GroupBy does not support it anymore for TimeVariable + """ + tv = TimeVariable("T", have_time=True, have_date=True) + data = Table.from_numpy( + Domain([DiscreteVariable("G", values=["G1", "G2"]), tv]), + np.array([[0.0, 0.0], [0, 10], [0, 20], [1, 500], [1, 1000]]), + ) + self.send_signal(self.widget.Inputs.data, data) + self.widget.aggregations[tv].add("Sum") + self.widget.aggregations[tv].add("Median") + self.send_signal(self.widget.Inputs.data, self.iris) + + widget = self.create_widget( + OWGroupBy, + stored_settings=self.widget.settingsHandler.pack_data(self.widget), + ) + self.send_signal(widget.Inputs.data, data, widget=widget) + self.assertSetEqual(widget.aggregations[tv], {"Mean", "Median"}) + + @patch( + "Orange.data.aggregate.OrangeTableGroupBy.aggregate", + Mock(side_effect=ValueError("Test unexpected err")), + ) + def test_unexpected_error(self): + """Test if exception in aggregation shown correctly""" + + self.send_signal(self.widget.Inputs.data, self.iris) + self.wait_until_finished() + + self.assertTrue(self.widget.Error.unexpected_error.is_shown()) + self.assertEqual( + str(self.widget.Error.unexpected_error), + "Test unexpected err", + ) + + def test_time_variable(self): + cur_dir = os.path.dirname(os.path.realpath(__file__)) + test10_path = os.path.join( + cur_dir, "..", "..", "..", "tests", "datasets", "test10.tab" + ) + data = Table.from_file(test10_path) + + # time variable as a group by variable + self.send_signal(self.widget.Inputs.data, data) + self._set_selection(self.widget.controls.gb_attrs, [3]) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(3, len(output)) + + # time variable as a grouped variable + attributes = [data.domain["c2"], data.domain["d2"]] + self.send_signal(self.widget.Inputs.data, data[:, attributes]) + self._set_selection(self.widget.controls.gb_attrs, [1]) # d2 + # check all aggregations + self.assert_aggregations_equal(["Mean", "Mode"]) + self.select_table_rows(self.widget.agg_table_view, [0]) # c2 + for cb in self.widget.agg_checkboxes.values(): + if cb.text() != "Mean": + cb.click() + self.assert_aggregations_equal(["Mean, Median, Q1 and 14 more", "Mode"]) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(2, len(output)) + + def test_time_variable_results(self): + data = Table.from_numpy( + Domain( + [ + DiscreteVariable("G", values=["G1", "G2", "G3"]), + TimeVariable("T", have_time=True, have_date=True), + ] + ), + np.array([[0.0, 0], [0, 10], [0, 20], [1, 500], [1, 1000], [2, 1]]), + ) + self.send_signal(self.widget.Inputs.data, data) + + # disable aggregating G + self.select_table_rows(self.widget.agg_table_view, [0]) # T + self.widget.agg_checkboxes["Mode"].click() + # select all possible aggregations for T + self.select_table_rows(self.widget.agg_table_view, [1]) # T + for cb in self.widget.agg_checkboxes.values(): + if cb.text() != "Mean": + cb.click() + self.assert_aggregations_equal(["", "Mean, Median, Q1 and 14 more"]) + + expected_df = pd.DataFrame( + { + "T - Mean": [ + "1970-01-01 00:00:10", + "1970-01-01 00:12:30", + "1970-01-01 00:00:01", + ], + "T - Median": [ + "1970-01-01 00:00:10", + "1970-01-01 00:12:30", + "1970-01-01 00:00:01", + ], + "T - Q1": [ + "1970-01-01 00:00:05", + "1970-01-01 00:10:25", + "1970-01-01 00:00:01", + ], + "T - Q3": [ + "1970-01-01 00:00:15", + "1970-01-01 00:14:35", + "1970-01-01 00:00:01", + ], + "T - Min. value": [ + "1970-01-01 00:00:00", + "1970-01-01 00:08:20", + "1970-01-01 00:00:01", + ], + "T - Max. value": [ + "1970-01-01 00:00:20", + "1970-01-01 00:16:40", + "1970-01-01 00:00:01", + ], + "T - Mode": [ + "1970-01-01 00:00:00", + "1970-01-01 00:08:20", + "1970-01-01 00:00:01", + ], + "T - Standard deviation": [10, 353.5533905932738, np.nan], + "T - Variance": [100, 125000, np.nan], + "T - Span": [20, 500, 0], + "T - First value": [ + "1970-01-01 00:00:00", + "1970-01-01 00:08:20", + "1970-01-01 00:00:01", + ], + "T - Last value": [ + "1970-01-01 00:00:20", + "1970-01-01 00:16:40", + "1970-01-01 00:00:01", + ], + "T - Count defined": [3, 2, 1], + "T - Count": [3, 2, 1], + "T - Proportion defined": [1, 1, 1], + "T - Concatenate": [ + "1970-01-01 00:00:00 1970-01-01 00:00:10 1970-01-01 00:00:20", + "1970-01-01 00:08:20 1970-01-01 00:16:40", + "1970-01-01 00:00:01", + ], + "G": ["G1", "G2", "G3"], + } + ) + df_col = [ + "T - Mean", + "T - Median", + "T - Q1", + "T - Q3", + "T - Mode", + "T - Min. value", + "T - Max. value", + "T - First value", + "T - Last value", + ] + expected_df[df_col] = expected_df[df_col].apply(pd.to_datetime) + output = self.get_output(self.widget.Outputs.data) + output_df = table_to_frame(output, include_metas=True) + # remove random since it is not possible to test + output_df = output_df.loc[:, ~output_df.columns.str.endswith("Random value")] + + pd.testing.assert_frame_equal( + output_df, + expected_df, + check_dtype=False, + check_column_type=False, + check_categorical=False, + atol=1e-3, + ) + expected_attributes = ( + TimeVariable("T - Mean", have_date=1, have_time=1), + TimeVariable("T - Median", have_date=1, have_time=1), + TimeVariable("T - Q1", have_date=1, have_time=1), + TimeVariable("T - Q3", have_date=1, have_time=1), + TimeVariable("T - Min. value", have_date=1, have_time=1), + TimeVariable("T - Max. value", have_date=1, have_time=1), + TimeVariable("T - Mode", have_date=1, have_time=1), + ContinuousVariable(name="T - Standard deviation"), + ContinuousVariable(name="T - Variance"), + ContinuousVariable(name="T - Span"), + TimeVariable("T - First value", have_date=1, have_time=1), + TimeVariable("T - Last value", have_date=1, have_time=1), + TimeVariable("T - Random value", have_date=1, have_time=1), + ContinuousVariable(name="T - Count defined"), + ContinuousVariable(name="T - Count"), + ContinuousVariable(name="T - Proportion defined"), + ) + expected_metas = ( + StringVariable(name="T - Concatenate"), + DiscreteVariable(name="G", values=("G1", "G2", "G3")), + ) + self.assertTupleEqual(output.domain.attributes, expected_attributes) + self.assertTupleEqual(output.domain.metas, expected_metas) + + def test_tz_time_variable_results(self): + """ Test results in case of timezoned time variable""" + tv = TimeVariable("T", have_time=True, have_date=True) + data = Table.from_numpy( + Domain([DiscreteVariable("G", values=["G1", "G2"]), tv]), + np.array([[0.0, tv.parse("1970-01-01 01:00:00+01:00")], + [0, tv.parse("1970-01-01 01:00:10+01:00")], + [0, tv.parse("1970-01-01 01:00:20+01:00")]]), + ) + + self.send_signal(self.widget.Inputs.data, data) + + # disable aggregating G + self.select_table_rows(self.widget.agg_table_view, [0]) # T + self.widget.agg_checkboxes["Mode"].click() + # select all possible aggregations for T + self.select_table_rows(self.widget.agg_table_view, [1]) # T + for cb in self.widget.agg_checkboxes.values(): + if cb.text() != "Mean": + cb.click() + self.assert_aggregations_equal(["", "Mean, Median, Q1 and 14 more"]) + + expected_df = pd.DataFrame( + { + "T - Mean": ["1970-01-01 00:00:10"], + "T - Median": ["1970-01-01 00:00:10"], + "T - Q1": ["1970-01-01 00:00:05"], + "T - Q3": ["1970-01-01 00:00:15"], + "T - Min. value": ["1970-01-01 00:00:00"], + "T - Max. value": ["1970-01-01 00:00:20"], + "T - Mode": ["1970-01-01 00:00:00"], + "T - Standard deviation": [10], + "T - Variance": [100], + "T - Span": [20, ], + "T - First value": ["1970-01-01 00:00:00"], + "T - Last value": ["1970-01-01 00:00:20"], + "T - Count defined": [3], + "T - Count": [3], + "T - Proportion defined": [1], + "T - Concatenate": [ + "1970-01-01 00:00:00 1970-01-01 00:00:10 1970-01-01 00:00:20", + ], + "G": ["G1"], + } + ) + df_col = [ + "T - Mean", + "T - Median", + "T - Q1", + "T - Q3", + "T - Min. value", + "T - Max. value", + "T - Mode", + "T - First value", + "T - Last value", + ] + expected_df[df_col] = expected_df[df_col].apply(pd.to_datetime) + output_df = table_to_frame( + self.get_output(self.widget.Outputs.data), include_metas=True + ) + # remove random since it is not possible to test + output_df = output_df.loc[:, ~output_df.columns.str.endswith("Random value")] + + pd.testing.assert_frame_equal( + output_df, + expected_df, + check_dtype=False, + check_column_type=False, + check_categorical=False, + atol=1e-3, + ) + + def test_only_nan_in_group(self): + data = Table( + Domain([ContinuousVariable("A"), ContinuousVariable("B")]), + np.array([[1, np.nan], [2, 1], [1, np.nan], [2, 1]]), + ) + self.send_signal(self.widget.Inputs.data, data) + + # select feature A as group-by + self._set_selection(self.widget.controls.gb_attrs, [0]) + # select all aggregations for feature B + self.select_table_rows(self.widget.agg_table_view, [1]) + for cb in self.widget.agg_checkboxes.values(): + while not cb.isChecked(): + cb.click() + + # unselect all aggregations for attr A + self.select_table_rows(self.widget.agg_table_view, [0]) + for cb in self.widget.agg_checkboxes.values(): + while cb.isChecked(): + cb.click() + + expected_columns = [ + "B - Mean", + "B - Median", + "B - Q1", + "B - Q3", + "B - Min. value", + "B - Max. value", + "B - Mode", + "B - Standard deviation", + "B - Variance", + "B - Sum", + "B - Span", + "B - First value", + "B - Last value", + "B - Random value", + "B - Count defined", + "B - Count", + "B - Proportion defined", + "B - Concatenate", + "A", + ] + n = np.nan + expected_df = pd.DataFrame( + [ + [n, n, n, n, n, n, n, n, n, 0, n, n, n, n, 0, 2, 0, "", 1], + [1, 1, 1, 1, 1, 1, 1, 0, 0, 2, 0, 1, 1, 1, 2, 2, 1, "1.0 1.0", 2], + ], + columns=expected_columns, + ) + output_df = table_to_frame( + self.get_output(self.widget.Outputs.data), include_metas=True + ) + pd.testing.assert_frame_equal( + output_df, + expected_df, + check_dtype=False, + check_column_type=False, + check_categorical=False, + ) + + def test_hidden_attributes(self): + domain = self.iris.domain + data = self.iris.transform(domain.copy()) + + data.domain.attributes[0].attributes["hidden"] = True + self.send_signal(self.widget.Inputs.data, data) + self.assertListEqual([data.domain["iris"]], self.widget.gb_attrs) + + data = self.iris.transform(domain.copy()) + data.domain.class_vars[0].attributes["hidden"] = True + self.send_signal(self.widget.Inputs.data, data) + # iris is hidden now so sepal length is selected + self.assertListEqual([data.domain["sepal length"]], self.widget.gb_attrs) + + d = domain.copy() + data = self.iris.transform(Domain(d.attributes[:3], metas=d.attributes[3:])) + data.domain.metas[0].attributes["hidden"] = True + self.send_signal(self.widget.Inputs.data, data) + # sepal length still selected because of context + self.assertListEqual([data.domain["sepal length"]], self.widget.gb_attrs) + + # test case when one of two selected attributes is hidden + self._set_selection(self.widget.controls.gb_attrs, [0, 1]) # sep l, sep w + data.domain.attributes[0].attributes["hidden"] = True + self.send_signal(self.widget.Inputs.data, data) + # sepal length is hidden - only sepal width remain selected + self.assertListEqual([data.domain["sepal width"]], self.widget.gb_attrs) + + def test_aggregate_discrete(self): + values = ["HCD", "DOS", "SDE"] + domain = Domain([DiscreteVariable("Cluster", ["C1", "C2"]), + DiscreteVariable("group", values)]) + l = [[1, 1], [1, 1], [1, 1], [0, 2], [1, 1], [0, 2], [0, 0], [0, 2], + [1, 1], [1, 1], [0, 2], [0, 2], [1, 2], [1, 1], [0, 2], [0, 1]] + array = np.array(l) + data = Table.from_list(domain, array) + + mask0 = array[:, 0] == 0 + mask1 = array[:, 0] == 1 + most_common0 = Counter(array[mask0, 1]).most_common(1)[0][0] + most_common1 = Counter(array[mask1, 1]).most_common(1)[0][0] + + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output[0, 1], values[most_common0]) # 2 - SDE + self.assertEqual(output[1, 1], values[most_common1]) # 1 - DOS + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/data/tests/test_owimpute.py b/Orange/widgets/data/tests/test_owimpute.py index 7cec4263864..161ee11fb8b 100644 --- a/Orange/widgets/data/tests/test_owimpute.py +++ b/Orange/widgets/data/tests/test_owimpute.py @@ -58,8 +58,8 @@ def test_empty_data(self): # only meta columns data = data.transform(Domain([], [], data.domain.attributes)) - self.send_signal("Data", data, wait=1000) - imp_data = self.get_output("Data") + self.send_signal(self.widget.Inputs.data, data, wait=1000) + imp_data = self.get_output() self.assertEqual(len(imp_data), len(data)) self.assertEqual(imp_data.domain, data.domain) np.testing.assert_equal(imp_data.metas, data.metas) diff --git a/Orange/widgets/data/tests/test_owmelt.py b/Orange/widgets/data/tests/test_owmelt.py index d319c59dacc..b1ab760025e 100644 --- a/Orange/widgets/data/tests/test_owmelt.py +++ b/Orange/widgets/data/tests/test_owmelt.py @@ -140,10 +140,10 @@ def test_context_disregards_none(self): self.send_signal(widget.Inputs.data, self.data) self.assertIs(widget.idvar, expected) - @data_without_commit def test_no_suitable_features(self): widget = self.widget heart = Table("heart_disease") + self.send_signal(self.widget.Inputs.data, self.data) self.assertFalse(widget.Information.no_suitable_features.is_shown()) self.assertIsNotNone(self.get_output(widget.Outputs.data)) @@ -176,6 +176,38 @@ def test_no_suitable_features(self): self.assertIsNone(widget.idvar) self.assertSequenceEqual(widget.idvar_model, [None]) + def test_nothing_to_melt(self): + widget = self.widget + widget.only_numeric = False + zoo = Table("zoo") + heart = Table("heart_disease") + + self.send_signal(self.widget.Inputs.data, zoo) + self.assertFalse(widget.Error.nothing_to_melt.is_shown()) + + widget.controls.only_numeric.click() + assert widget.only_numeric + self.assertTrue(widget.Error.nothing_to_melt.is_shown()) + + self.send_signal(widget.Inputs.data, heart) + assert widget.only_numeric + self.assertFalse(widget.Error.nothing_to_melt.is_shown()) + + self.send_signal(self.widget.Inputs.data, zoo) + assert widget.only_numeric + self.assertTrue(widget.Error.nothing_to_melt.is_shown()) + + widget.controls.only_numeric.click() + assert not widget.only_numeric + self.assertFalse(widget.Error.nothing_to_melt.is_shown()) + + widget.controls.only_numeric.click() + assert widget.only_numeric + self.assertTrue(widget.Error.nothing_to_melt.is_shown()) + + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(widget.Error.nothing_to_melt.is_shown()) + def test_invalidates(self): widget = self.widget mock_return = Table("heart_disease") diff --git a/Orange/widgets/data/tests/test_owmergedata.py b/Orange/widgets/data/tests/test_owmergedata.py index 02b4ff37c77..561eb3eafa2 100644 --- a/Orange/widgets/data/tests/test_owmergedata.py +++ b/Orange/widgets/data/tests/test_owmergedata.py @@ -214,9 +214,11 @@ def test_match_attr_name(self): def test_add_row_button(self): boxes = self.widget.attr_boxes boxes.set_state([(INSTANCEID, INSTANCEID), (INSTANCEID, INSTANCEID)]) - boxes.rows[-1].add_button.clicked.emit() + layout = boxes.layout() + add_button = layout.itemAt(layout.count() - 1).itemAt(1).widget() + add_button.clicked.emit() self.assertEqual(len(boxes.rows), 3) - self.assertEqual(boxes.layout().count(), 3) + self.assertEqual(boxes.layout().count(), 4) def test_remove_row(self): widget = self.widget @@ -228,38 +230,26 @@ def test_remove_row(self): boxes.set_state( [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)]) - for i, row in enumerate(boxes.rows): + for row in boxes.rows: self.assertTrue(row.remove_button.isEnabled()) - self.assertEqual(row.remove_button.text(), "×") - self.assertEqual(row.add_button.isEnabled(), i == 2) - self.assertEqual(row.add_button.text(), ["", "+"][i == 2]) boxes.rows[1].remove_button.clicked.emit() self.assertEqual(boxes.current_state(), [(INDEX, INDEX), (var0, var1)]) - for i, row in enumerate(boxes.rows): + for row in boxes.rows: self.assertTrue(row.remove_button.isEnabled()) - self.assertEqual(row.remove_button.text(), "×") - self.assertEqual(row.add_button.isEnabled(), i == 1) - self.assertEqual(row.add_button.text(), ["", "+"][i]) boxes.rows[1].remove_button.clicked.emit() self.assertEqual(boxes.current_state(), [(INDEX, INDEX)]) row = boxes.rows[0] self.assertFalse(row.remove_button.isEnabled()) - self.assertEqual(row.remove_button.text(), "") - self.assertTrue(row.add_button.isEnabled()) - self.assertEqual(row.add_button.text(), "+") boxes.set_state( [(INDEX, INDEX), (INSTANCEID, INSTANCEID), (var0, var1)]) boxes.rows[2].remove_button.clicked.emit() self.assertEqual( boxes.current_state(), [(INDEX, INDEX), (INSTANCEID, INSTANCEID)]) - for i, row in enumerate(boxes.rows): + for row in boxes.rows: self.assertTrue(row.remove_button.isEnabled()) - self.assertEqual(row.remove_button.text(), "×") - self.assertEqual(row.add_button.isEnabled(), i == 1) - self.assertEqual(row.add_button.text(), ["", "+"][i == 1]) def test_dont_remove_single_row(self): widget = self.widget @@ -559,7 +549,7 @@ def test_output_merge_by_attribute_left(self): self.send_signal(self.widget.Inputs.data, self.dataA) self.send_signal(self.widget.Inputs.extra_data, self.dataB) self.widget.attr_boxes.set_state([(domainA[0], domainB[0])]) - self.widget.commit() + self.widget.commit.now() output = self.get_output(self.widget.Outputs.data) self.assertTablesEqual(output, result) self.assertEqual(output.name, self.dataA.name) @@ -645,7 +635,7 @@ class variable""" self.send_signal(self.widget.Inputs.data, self.dataA) self.send_signal(self.widget.Inputs.extra_data, self.dataB) self.widget.attr_boxes.set_state([(domainA[2], domainB[2])]) - self.widget.commit() + self.widget.commit.now() self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result) def test_output_merge_by_class_inner(self): @@ -709,7 +699,7 @@ def test_output_merge_by_meta_left(self): self.send_signal(self.widget.Inputs.data, self.dataA) self.send_signal(self.widget.Inputs.extra_data, self.dataB) self.widget.attr_boxes.set_state([(domainA[-2], domainB[-1])]) - self.widget.commit() + self.widget.commit.now() self.assertTablesEqual(self.get_output(self.widget.Outputs.data), result) def test_output_merge_by_meta_inner(self): @@ -780,19 +770,20 @@ def test_sparse(self): data = Table("iris")[::25] data_ed_dense = Table("titanic")[::300] data_ed_sparse = Table("titanic")[::300].to_sparse() - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) - self.send_signal("Extra Data", data_ed_dense) - output_dense = self.get_output("Data") + self.send_signal(self.widget.Inputs.extra_data, data_ed_dense) + output_dense = self.get_output() self.assertFalse(sp.issparse(output_dense.X)) self.assertFalse(output_dense.is_sparse()) - self.send_signal("Extra Data", data_ed_sparse) - output_sparse = self.get_output("Data") + self.send_signal(self.widget.Inputs.extra_data, data_ed_sparse) + output_sparse = self.get_output() self.assertTrue(sp.issparse(output_sparse.X)) self.assertTrue(output_sparse.is_sparse()) - output_sparse.X = output_sparse.X.toarray() + with output_sparse.unlocked(): + output_sparse.X = output_sparse.X.toarray() self.assertTablesEqual(output_dense, output_sparse) def test_commit_on_new_data(self): @@ -823,7 +814,7 @@ def test_multiple_attributes_left(self): self.send_signal(self.widget.Inputs.extra_data, dataB) self.widget.attr_boxes.set_state( [(domainA[0], domainB[0]), (domainA[1], domainB[1])]) - self.widget.commit() + self.widget.commit.now() output = self.get_output(self.widget.Outputs.data) self.assertEqual(output.name, dataA.name) @@ -847,62 +838,182 @@ def test_nonunique(self): self.assertFalse(widget.Error.nonunique_left.is_shown()) self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) widget.attr_boxes.set_state([(INSTANCEID, INSTANCEID)]) - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.nonunique_left.is_shown()) self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNotNone(self.get_output(widget.Outputs.data)) widget.attr_boxes.set_state([(INDEX, INDEX)]) - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.nonunique_left.is_shown()) self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNotNone(self.get_output(widget.Outputs.data)) widget.attr_boxes.set_state([(x, x)]) - widget.unconditional_commit() - self.assertTrue(widget.Error.nonunique_left.is_shown()) + widget.commit.now() + self.assertTrue(widget.Error.nonunique_left_matched.is_shown()) self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNone(self.get_output(widget.Outputs.data)) widget.merging = widget.LeftJoin - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.nonunique_left.is_shown()) self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNotNone(self.get_output(widget.Outputs.data)) widget.merging = widget.InnerJoin widget.attr_boxes.set_state([(x, x), (d, d)]) - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.nonunique_left.is_shown()) self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNotNone(self.get_output(widget.Outputs.data)) widget.attr_boxes.set_state([(d, d)]) - widget.unconditional_commit() - self.assertTrue(widget.Error.nonunique_left.is_shown()) - self.assertTrue(widget.Error.nonunique_right.is_shown()) + widget.commit.now() + self.assertTrue(widget.Error.nonunique_left_matched.is_shown()) + self.assertTrue(widget.Error.nonunique_right_matched.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNone(self.get_output(widget.Outputs.data)) widget.merging = widget.LeftJoin - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.nonunique_left.is_shown()) - self.assertTrue(widget.Error.nonunique_right.is_shown()) + self.assertTrue(widget.Error.nonunique_right_matched.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNone(self.get_output(widget.Outputs.data)) widget.merging = widget.InnerJoin - widget.unconditional_commit() - self.assertTrue(widget.Error.nonunique_left.is_shown()) + widget.commit.now() + self.assertTrue(widget.Error.nonunique_left_matched.is_shown()) + self.assertTrue(widget.Error.nonunique_right_matched.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) + self.assertIsNone(self.get_output(widget.Outputs.data)) + + self.send_signal(widget.Inputs.data, None) + self.send_signal(widget.Inputs.extra_data, None) + self.assertFalse(widget.Error.nonunique_left.is_shown()) + self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) + self.assertIsNone(self.get_output(widget.Outputs.data)) + + def test_nonunique_warning(self): + widget = self.widget + x = ContinuousVariable("x") + d = DiscreteVariable("d", values=tuple("abc")) + domain = Domain([x, d], []) + dataA = Table.from_numpy( + domain, np.array([[1.0, 0], [2, 1]])) + dataB = Table.from_numpy( + domain, np.array([[1.0, 0], [2, 1], [3, 1], [3, 1]])) + dataB.ids = dataA.ids + + + self.send_signal(widget.Inputs.data, dataA) + self.send_signal(widget.Inputs.extra_data, dataB) + widget.attr_boxes.set_state([(x, x)]) + + widget.merging = widget.LeftJoin + widget.commit.now() + self.assertFalse(widget.Error.nonunique_left.is_shown()) + self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Error.nonunique_left_matched.is_shown()) + self.assertFalse(widget.Error.nonunique_right_matched.is_shown()) + self.assertFalse(widget.Warning.nonunique_left.is_shown()) + self.assertTrue(widget.Warning.nonunique_right.is_shown()) + self.assertIsNotNone(self.get_output(widget.Outputs.data)) + + widget.merging = widget.InnerJoin + widget.commit.now() + self.assertFalse(widget.Error.nonunique_left.is_shown()) + self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Error.nonunique_left_matched.is_shown()) + self.assertFalse(widget.Error.nonunique_right_matched.is_shown()) + self.assertFalse(widget.Warning.nonunique_left.is_shown()) + self.assertTrue(widget.Warning.nonunique_right.is_shown()) + self.assertIsNotNone(self.get_output(widget.Outputs.data)) + + widget.merging = widget.OuterJoin + widget.attr_boxes.set_state([(x, x), (d, d)]) + widget.commit.now() + self.assertFalse(widget.Error.nonunique_left.is_shown()) self.assertTrue(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Error.nonunique_left_matched.is_shown()) + self.assertFalse(widget.Error.nonunique_right_matched.is_shown()) + self.assertFalse(widget.Warning.nonunique_left.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNone(self.get_output(widget.Outputs.data)) self.send_signal(widget.Inputs.data, None) self.send_signal(widget.Inputs.extra_data, None) self.assertFalse(widget.Error.nonunique_left.is_shown()) self.assertFalse(widget.Error.nonunique_right.is_shown()) + self.assertFalse(widget.Error.nonunique_left_matched.is_shown()) + self.assertFalse(widget.Error.nonunique_right_matched.is_shown()) + self.assertFalse(widget.Warning.nonunique_left.is_shown()) + self.assertFalse(widget.Warning.nonunique_right.is_shown()) self.assertIsNone(self.get_output(widget.Outputs.data)) + def test_check_uniqueness(self): + # Above test_nonunique and test_nonunique_warning tests a larger + # flow within the widget; this one tests the particular function, + # check_uniqueneess, which performs the check + + def test(left, right, indicators): + aleft = np.vstack((left, np.zeros(len(left)))).T + aright = np.vstack((right, np.zeros(len(right)))).T + for w.merging, indi, msg in zip( + (w.LeftJoin, w.InnerJoin, w.OuterJoin), + indicators, + ("left", "inner", "outer")): + if isinstance(indi, int): + indi = (indi, ) + w.Error.clear() + w.Warning.clear() + w._check_uniqueness(np.array(aleft), mask[:len(left)], + np.array(aright), mask[:len(right)]) + self.assertIs(w.Error.nonunique_left_matched.is_shown(), elm in indi, msg) + self.assertIs(w.Error.nonunique_right_matched.is_shown(), erm in indi, msg) + self.assertIs(w.Error.nonunique_left.is_shown(), el in indi, msg) + self.assertIs(w.Error.nonunique_right.is_shown(), er in indi, msg) + self.assertIs(w.Warning.nonunique_left.is_shown(), wl in indi, msg) + self.assertIs(w.Warning.nonunique_right.is_shown(), wr in indi, msg) + + mask = np.array([False, False, True, True, True, True]) + seq1234 = (0, 0, 1, 2, 3, 4) + seq567 = (0, 0, 5, 6, 7) + seq1233 = (0, 0, 1, 2, 3, 3) + seq1255 = (0, 0, 1, 2, 5, 5) + wl, wr, elm, erm, el, er = range(6) + w = self.widget + + # no duplicates + test(seq1234, seq567, [()] * 3) + test(seq1234, seq1234, [()] * 3) + + # used duplicates on right: always error + test(seq1234, seq1233, [erm, erm, er]) + + # unused duplicates on right: error on outer, warning elsewhere + test(seq1234, seq1255, [wr, wr, er]) + + # (unused) duplicates on left: left is ok, inner warns, outer errors + test(seq1255, seq1234, [(), wl, el]) + + # duplicates on both sides: always error + test(seq1255, seq1255, [erm, (elm, erm), (el, er)]) + + # unused duplicates on both sides: + # left warns about right, inner warns both, outer errors both + test(seq1233, seq1255, [wr, (wl, wr), (el, er)]) + def test_invalide_pairs(self): widget = self.widget x = ContinuousVariable("x") @@ -917,43 +1028,43 @@ def test_invalide_pairs(self): self.send_signal(widget.Inputs.extra_data, dataB) widget.attr_boxes.set_state([(x, x), (d, d)]) - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.matching_id_with_sth.is_shown()) self.assertFalse(widget.Error.matching_index_with_sth.is_shown()) self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown()) widget.attr_boxes.set_state([(x, x), (INDEX, d)]) - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.matching_id_with_sth.is_shown()) self.assertTrue(widget.Error.matching_index_with_sth.is_shown()) self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown()) widget.attr_boxes.set_state([(x, x), (d, INDEX)]) - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.matching_id_with_sth.is_shown()) self.assertTrue(widget.Error.matching_index_with_sth.is_shown()) self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown()) widget.attr_boxes.set_state([(x, x), (INSTANCEID, d)]) - widget.unconditional_commit() + widget.commit.now() self.assertTrue(widget.Error.matching_id_with_sth.is_shown()) self.assertFalse(widget.Error.matching_index_with_sth.is_shown()) self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown()) widget.attr_boxes.set_state([(x, x), (d, INSTANCEID)]) - widget.unconditional_commit() + widget.commit.now() self.assertTrue(widget.Error.matching_id_with_sth.is_shown()) self.assertFalse(widget.Error.matching_index_with_sth.is_shown()) self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown()) widget.attr_boxes.set_state([(x, x), (INDEX, INSTANCEID)]) - widget.unconditional_commit() + widget.commit.now() self.assertTrue(widget.Error.matching_id_with_sth.is_shown() or widget.Error.matching_index_with_sth.is_shown()) self.assertFalse(widget.Error.matching_numeric_with_nonnum.is_shown()) widget.attr_boxes.set_state([(x, x), (x, d)]) - widget.unconditional_commit() + widget.commit.now() self.assertFalse(widget.Error.matching_id_with_sth.is_shown()) self.assertFalse(widget.Error.matching_index_with_sth.is_shown()) self.assertTrue(widget.Error.matching_numeric_with_nonnum.is_shown()) @@ -997,14 +1108,14 @@ def test_keep_non_duplicate_variables_missing_rows(self): # Only one row is matched; A has different values and it's duplicated, # and B has the same values, so we get only one copy self.widget.merging = self.widget.InnerJoin - self.widget.unconditional_commit() + self.widget.commit.now() merged_data = self.get_output(self.widget.Outputs.data) self.assertListEqual([m.name for m in merged_data.domain.variables], ["A (1)", "B", "C", "A (2)"]) # Table has additional rows; keep all columns self.widget.merging = self.widget.OuterJoin - self.widget.unconditional_commit() + self.widget.commit.now() merged_data = self.get_output(self.widget.Outputs.data) self.assertListEqual( [m.name for m in merged_data.domain.variables], @@ -1015,7 +1126,7 @@ def test_keep_non_duplicate_variables_missing_rows(self): extra_data = Table(domain, np.array([[1., 1, 1], [0, 1, 2]])) self.send_signal(self.widget.Inputs.extra_data, extra_data) self.widget.merging = self.widget.LeftJoin - self.widget.unconditional_commit() + self.widget.commit.now() merged_data = self.get_output(self.widget.Outputs.data) self.assertListEqual([m.name for m in merged_data.domain.variables], ["A", "B", "C"]) diff --git a/Orange/widgets/data/tests/test_owneighbors.py b/Orange/widgets/data/tests/test_owneighbors.py index 3249c658a82..66517a2b80d 100644 --- a/Orange/widgets/data/tests/test_owneighbors.py +++ b/Orange/widgets/data/tests/test_owneighbors.py @@ -46,17 +46,17 @@ def test_input_reference_disconnect(self): self.send_signal(widget.Inputs.reference, None) self.assertEqual(widget.reference, None) widget.apply_button.button.click() - self.assertIsNone(self.get_output("Neighbors")) + self.assertIsNone(self.get_output()) def test_output_neighbors(self): """Check if neighbors are on the output after apply""" widget = self.widget - self.assertIsNone(self.get_output("Neighbors")) + self.assertIsNone(self.get_output()) self.send_signals(((widget.Inputs.data, self.iris), (widget.Inputs.reference, self.iris[:10]))) widget.apply_button.button.click() - self.assertIsNotNone(self.get_output("Neighbors")) - self.assertIsInstance(self.get_output("Neighbors"), Table) + self.assertIsNotNone(self.get_output()) + self.assertIsInstance(self.get_output(), Table) self.assertTrue(all([i in self.iris.ids for i in self.get_output(widget.Outputs.data).ids]) ) @@ -75,27 +75,36 @@ def test_settings(self): widget.apply_button.button.click() if METRICS[widget.distance_index][0] != "Jaccard" \ and widget.n_neighbors != 0: - self.assertIsNotNone(self.get_output("Neighbors")) + self.assertIsNotNone(self.get_output()) - def test_exclude_reference(self): - """Check neighbors when reference is excluded""" + def test_include_reference(self): widget = self.widget + widget.n_neighbors = 10 + self.widget.include_reference = False reference = self.iris[:5] self.send_signal(widget.Inputs.data, self.iris) self.send_signal(widget.Inputs.reference, reference) - self.widget.exclude_reference = True - widget.apply_button.button.click() neighbors = self.get_output(widget.Outputs.data) + self.assertEqual(len(neighbors), 10) for inst in reference: self.assertNotIn(inst, neighbors) + self.widget.include_reference = True + widget.commit.now() + neighbors = self.get_output(widget.Outputs.data) + self.assertEqual(len(neighbors), 15) + for inst in reference: + self.assertNotIn(inst, neighbors[:10]) + for inst in reference: + self.assertIn(inst, neighbors[10:]) + def test_similarity(self): widget = self.widget reference = self.iris[:10] self.send_signal(widget.Inputs.data, self.iris) self.send_signal(widget.Inputs.reference, reference) widget.apply_button.button.click() - neighbors = self.get_output("Neighbors") + neighbors = self.get_output() self.assertEqual(self.iris.domain.attributes, neighbors.domain.attributes) self.assertEqual(self.iris.domain.class_vars, @@ -107,35 +116,36 @@ def test_missing_values(self): widget = self.widget data = Table("iris") reference = data[:3] - data.X[0:10, 0] = np.nan + with data.unlocked(): + data.X[0:10, 0] = np.nan self.send_signal(widget.Inputs.data, self.iris) self.send_signal(widget.Inputs.reference, reference) widget.apply_button.button.click() - self.assertIsNotNone(self.get_output("Neighbors")) + self.assertIsNotNone(self.get_output()) def test_compute_distances_apply_called(self): """Check compute distances and apply are called when receiving signal""" widget = self.widget cdist = widget.compute_distances = Mock() - apply = widget.unconditional_apply = Mock() + def_commit = widget.commit.now = Mock() self.widget.auto_apply = False data = Table("iris") self.send_signal(widget.Inputs.data, data) cdist.assert_called() - apply.assert_called() + def_commit.assert_called() cdist.reset_mock() - apply.reset_mock() + def_commit.reset_mock() self.send_signal(widget.Inputs.reference, data[:10]) cdist.assert_called() - apply.assert_called() + def_commit.assert_called() cdist.reset_mock() - apply.reset_mock() + def_commit.reset_mock() self.send_signals([(widget.Inputs.data, data), (widget.Inputs.reference, data[:10])]) self.assertEqual(cdist.call_count, 1) - self.assertEqual(apply.call_count, 1) + self.assertEqual(def_commit.call_count, 1) def test_compute_distances_calls_distance(self): widget = self.widget @@ -253,7 +263,7 @@ def test_data_with_similarity(self): self.assertEqual(len(neighbours.domain.metas), 3) self.assertEqual(neighbours.metas.shape, (4, 3)) np.testing.assert_almost_equal( - neighbours.get_column_view("distance")[0], indices + 1000) + neighbours.get_column("distance"), indices + 1000) np.testing.assert_almost_equal(neighbours.X, data2.X[indices]) def test_apply(self): @@ -283,7 +293,6 @@ def test_all_equal_ref(self): self.assertIsNone(self.get_output(widget.Outputs.data)) self.send_signal(widget.Inputs.data, data[:15]) - widget.apply() self.assertFalse(widget.Warning.all_data_as_reference.is_shown()) self.assertTrue(widget.Info.removed_references.is_shown()) self.assertIsNotNone(self.get_output(widget.Outputs.data)) @@ -442,6 +451,35 @@ def test_n_neighbours_spin_max(self): self.send_signal(w.Inputs.data, None) self.assertEqual(sb.maximum(), default) + def test_inherited_table(self): + # pylint: disable=abstract-method + class Table2(Table): + pass + + data = Table2(self.iris) + self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.reference, data[0:1]) + self.assertIsInstance(self.get_output(self.widget.Outputs.data), Table2) + + def test_order_by_distance(self): + domain = Domain([ContinuousVariable(x) for x in "ab"]) + reference = Table.from_numpy(domain, [[1, 0]]) + data = Table.from_numpy(domain, [[1, 0.1], [2, 0], [1, 0], [0, 0.1], [0.1, 0]]) + self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.reference, reference) + + output = self.get_output(self.widget.Outputs.data) + expected = [[1, 0], [1, 0.1], [0.1, 0], [2, 0], [0, 0.1]] + np.testing.assert_array_equal(output.X, expected) + dst = output.get_column("distance").tolist() + self.assertTrue(dst == sorted(dst)) # check distance in ascending order + + # test on bigger set + self.send_signal(self.widget.Inputs.data, self.iris) + self.send_signal(self.widget.Inputs.reference, self.iris[:1]) + dst = self.get_output(self.widget.Outputs.data).get_column("distance").tolist() + self.assertTrue(dst == sorted(dst)) # check distance in ascending order + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owoutliers.py b/Orange/widgets/data/tests/test_owoutliers.py index 2e4e3e14e20..f3c481ae432 100644 --- a/Orange/widgets/data/tests/test_owoutliers.py +++ b/Orange/widgets/data/tests/test_owoutliers.py @@ -159,11 +159,12 @@ def test_report(self, mocked_report: Mock): self.wait_until_finished() self.widget.send_report() mocked_report.assert_called() + self.assertEqual(mocked_report.call_count, 2) mocked_report.reset_mock() self.send_signal(self.widget.Inputs.data, None) self.widget.send_report() - mocked_report.assert_not_called() + mocked_report.assert_called_once() def test_migrate_settings(self): settings = {"cont": 20, "empirical_covariance": True, diff --git a/Orange/widgets/data/tests/test_owpaintdata.py b/Orange/widgets/data/tests/test_owpaintdata.py index a8fda41d151..d799f5842d9 100644 --- a/Orange/widgets/data/tests/test_owpaintdata.py +++ b/Orange/widgets/data/tests/test_owpaintdata.py @@ -1,13 +1,19 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring, protected-access +import unittest import numpy as np +from numpy.testing import assert_array_equal, assert_almost_equal import scipy.sparse as sp -from AnyQt.QtCore import QRectF, QPointF, QEvent, Qt +from AnyQt.QtCore import QRectF, QPointF, QPoint ,QEvent, Qt from AnyQt.QtGui import QMouseEvent +from AnyQt.QtTest import QTest + +from orangecanvas.gui.test import mouseMove from Orange.data import Table, DiscreteVariable, ContinuousVariable, Domain +from Orange.widgets.utils import itemmodels from Orange.widgets.data import owpaintdata from Orange.widgets.data.owpaintdata import OWPaintData from Orange.widgets.tests.base import WidgetTest, datasets @@ -78,8 +84,9 @@ def test_sparse_data(self): GH-2298 GH-2163 """ - data = Table("iris")[::25] - data.X = sp.csr_matrix(data.X) + data = Table("iris")[::25].copy() + with data.unlocked(): + data.X = sp.csr_matrix(data.X) self.send_signal(self.widget.Inputs.data, data) self.assertTrue(self.widget.Warning.sparse_not_supported.is_shown()) self.send_signal(self.widget.Inputs.data, None) @@ -119,3 +126,115 @@ def test_reset_to_input(self): self.widget.reset_to_input() output = self.get_output(self.widget.Outputs.data) self.assertEqual(len(output), len(data)) + + def test_tools_interaction(self): + def mouse_path(stroke, button=Qt.LeftButton, delay=50): + assert len(stroke) > 2 + QTest.mousePress(viewport, button, pos=stroke[0], delay=delay) + for p in stroke[1:-1]: + mouseMove(viewport, button, pos=p, delay=delay) + QTest.mouseRelease(viewport, button, pos=stroke[-1], delay=delay) + + def assert_close(p1, p2): + assert_almost_equal(np.array(p1), np.array(p2)) + + w = self.widget + w.adjustSize() + viewport = w.plotview.viewport() + center = viewport.rect().center() + # Put single point + w.set_current_tool(owpaintdata.PutInstanceTool) + QTest.mouseClick(viewport, Qt.LeftButton) + p0 = w.data[0] + # Air brush stroke + w.set_current_tool(owpaintdata.AirBrushTool) + mouse_path([center, center + QPoint(5, 5), center + QPoint(5, 10), center + QPoint(0, 10)]) + + w.set_current_tool(owpaintdata.SelectTool) + + # Draw selection rect + mouse_path([center - QPoint(100, 100), center, center + QPoint(100, 100)]) + # Move selection + mouse_path([center, center + QPoint(30, 30), center + QPoint(50, 50)]) + self.assertNotEqual(w.data[0], p0) + count = len(w.data) + + w.current_tool.delete() # + self.assertNotEqual(len(w.data), count) + + w.set_current_tool(owpaintdata.ClearTool) + self.assertEqual(len(w.data), 0) + w.undo_stack.undo() # clear + w.undo_stack.undo() # delete selection + w.undo_stack.undo() # move + assert_close(w.data[0], p0) + + stroke = [center - QPoint(10, 10), center, center + QPoint(10, 10)] + + w.set_current_tool(owpaintdata.MagnetTool) + mouse_path(stroke) + w.undo_stack.undo() + assert_close(w.data[0], p0) + + w.set_current_tool(owpaintdata.JitterTool) + mouse_path(stroke) + w.undo_stack.undo() + assert_close(w.data[0], p0) + + def test_add_remove_class(self): + def put_instance(): + w.set_current_tool(owpaintdata.PutInstanceTool) + QTest.mouseClick(viewport, Qt.LeftButton) + + def assert_class_column_equal(data): + assert_array_equal(np.array(w.data)[:, 2].ravel(), data) + + w = self.widget + viewport = w.plotview.viewport() + put_instance() + itemmodels.select_row(w.classValuesView, 1) + put_instance() + w.add_new_class_label() + itemmodels.select_row(w.classValuesView, 2) + put_instance() + self.assertSequenceEqual(w.class_model, ["C1", "C2", "C3"]) + assert_class_column_equal([0, 1, 2]) + itemmodels.select_row(w.classValuesView, 0) + w.remove_selected_class_label() + self.assertSequenceEqual(w.class_model, ["C2", "C3"]) + assert_class_column_equal([0, 1]) + w.undo_stack.undo() + self.assertSequenceEqual(w.class_model, ["C1", "C2", "C3"]) + assert_class_column_equal([0, 1, 2]) + + +class TestCommands(unittest.TestCase): + def test_merge_cmd(self): # pylint: disable=import-outside-toplevel + from Orange.widgets.data.owpaintdata import ( + Append, Move, DeleteIndices, Composite, merge_cmd + ) + + def merge(a, b): + return merge_cmd(Composite(a, b)) + + a1 = Append(np.array([[0., 0., 1.], [1., 1., 0.]])) + a2 = Append(np.array([[2., 2., 1,]])) + c = merge(a1, a2) + self.assertIsInstance(c, Append) + assert_array_equal(c.points, np.array([[0., 0., 1.], [1, 1, 0], [2, 2, 1]])) + m1 = Move(range(2), np.array([1., 1., 0.])) + m2 = Move(range(2), np.array([.5, .5, -1])) + c = merge(m1, m2) + self.assertIsInstance(c, Move) + assert_array_equal(c.delta, np.array([1.5, 1.5, -1])) + c = merge(m1, Move(range(100, 102), np.array([1., 1., 1.]))) + self.assertIsInstance(c, Composite) + + c = merge(m1, Move([100, 105], np.array([0., 0, 0]))) + self.assertIsInstance(c, Composite) + + d1 = DeleteIndices(range(0, 3)) + d2 = DeleteIndices(range(3, 5)) + c = merge(d1, d2) + self.assertIsInstance(c, DeleteIndices) + self.assertEqual(c.indices, range(0, 5)) diff --git a/Orange/widgets/data/tests/test_owpivot.py b/Orange/widgets/data/tests/test_owpivot.py index f817f21351f..24034c3879c 100644 --- a/Orange/widgets/data/tests/test_owpivot.py +++ b/Orange/widgets/data/tests/test_owpivot.py @@ -1,19 +1,18 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring,unsubscriptable-object import unittest -from unittest.mock import patch -from pickle import loads, dumps +from unittest.mock import patch, Mock import numpy as np from AnyQt.QtCore import Qt, QPoint from AnyQt.QtTest import QTest +import Orange.widgets.data.owpivot from Orange.data import (Table, Domain, ContinuousVariable as Cv, StringVariable as sv, DiscreteVariable as Dv, TimeVariable as Tv) -from Orange.widgets.data.owpivot import (OWPivot, Pivot, - AggregationFunctionsEnum) +from Orange.widgets.data.owpivot import OWPivot, Pivot, Function from Orange.widgets.tests.base import WidgetTest from Orange.widgets.tests.utils import simulate @@ -72,13 +71,13 @@ def test_output_grouped_data_time_var(self): X = np.array([[0, 1e9], [0, 1e8], [1, 2e8], [1, np.nan]]) data = Table(domain, X) self.send_signal(self.widget.Inputs.data, data) - self.agg_checkboxes[Pivot.Functions.Mean.value].click() + self.agg_checkboxes[Pivot.Mean.value].click() grouped = self.get_output(self.widget.Outputs.grouped_data) str_grouped = "[[a, 2, 1987-06-06],\n [b, 2, 1976-05-03]]" self.assertEqual(str(grouped), str_grouped) def test_output_filtered_data(self): - self.agg_checkboxes[Pivot.Functions.Sum.value].click() + self.agg_checkboxes[Pivot.Sum.value].click() self.send_signal(self.widget.Inputs.data, self.iris) simulate.combobox_activate_item(self.widget.controls.row_feature, self.iris.domain.attributes[0].name) @@ -161,8 +160,7 @@ def test_aggregations(self): self.iris.domain.class_var.name) self.assertFalse(self.widget.Warning.cannot_aggregate.is_shown()) # agg: Count, Majority, feature: None - simulate.combobox_activate_item(self.widget.controls.val_feature, - "(None)") + simulate.combobox_activate_index(self.widget.controls.val_feature, 0) self.assertTrue(self.widget.Warning.cannot_aggregate.is_shown()) # agg: Count, Majority, feature: None, row: Continuous simulate.combobox_activate_item(self.widget.controls.row_feature, @@ -187,7 +185,7 @@ def test_group_table_created_once(self, initialize): initialize.assert_not_called() def test_saved_workflow(self): - self.agg_checkboxes[Pivot.Functions.Sum.value].click() + self.agg_checkboxes[Pivot.Sum.value].click() self.send_signal(self.widget.Inputs.data, self.iris) simulate.combobox_activate_item(self.widget.controls.row_feature, self.iris.domain.attributes[0].name) @@ -212,26 +210,33 @@ def test_saved_workflow(self): def test_select_by_click(self): view = self.widget.table_view self.send_signal(self.widget.Inputs.data, self.heart_disease) - self.agg_checkboxes[Pivot.Functions.Sum.value].click() + self.agg_checkboxes[Pivot.Sum.value].click() simulate.combobox_activate_item(self.widget.controls.val_feature, self.heart_disease.domain[0].name) + def pos(row, col) -> QPoint: + model = view.model() + rect = view.visualRect( # pylint:disable=protected-access + model.index(row + view._n_leading_rows, + col + view._n_leading_cols)) + return rect.center() + # column in a group - QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=QPoint(208, 154)) + QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=pos(2, 0)) self.assertSetEqual({(3, 0), (2, 0)}, view.get_selection()) # column - QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=QPoint(340, 40)) + QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=pos(-1, 1)) self.assertSetEqual({(0, 1), (3, 1), (1, 1), (2, 1)}, view.get_selection()) # group - QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=QPoint(155, 75)) + QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=pos(0, -1)) self.assertSetEqual({(0, 1), (1, 0), (0, 0), (1, 1)}, view.get_selection()) # all - QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=QPoint(400, 198)) + QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=pos(4, 2)) self.assertSetEqual({(0, 1), (0, 0), (3, 0), (3, 1), (2, 1), (2, 0), (1, 0), (1, 1)}, view.get_selection()) @@ -262,7 +267,7 @@ def test_max_values(self): def test_table_values(self): self.send_signal(self.widget.Inputs.data, self.heart_disease) domain = self.heart_disease.domain - self.agg_checkboxes[Pivot.Functions.Majority.value].click() + self.agg_checkboxes[Pivot.Majority.value].click() simulate.combobox_activate_item(self.widget.controls.col_feature, domain["gender"].name) simulate.combobox_activate_item(self.widget.controls.val_feature, @@ -278,15 +283,42 @@ def test_table_values(self): self.assertEqual(model.data(model.index(4, 4)), "114.0") self.assertEqual(model.data(model.index(5, 4)), "reversable defect") + def test_only_metas_table(self): + data = self.zoo.transform(Domain([], metas=self.zoo.domain.attributes)) + self.send_signal(self.widget.Inputs.data, data) + self.assertFalse(self.widget.Warning.no_variables.is_shown()) + + def test_empty_table(self): + zoo_domain = self.zoo.domain + data = self.zoo.transform(Domain([], metas=zoo_domain.metas)) + self.send_signal(self.widget.Inputs.data, data) + self.assertTrue(self.widget.Warning.no_variables.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Warning.no_variables.is_shown()) + + heart = self.heart_disease + self.send_signal(self.widget.Inputs.data, heart) -class TestAggregationFunctionsEnum(unittest.TestCase): - def test_pickle(self): - self.assertIs(AggregationFunctionsEnum.Sum, - loads(dumps(AggregationFunctionsEnum.Sum))) + self.send_signal(self.widget.Inputs.data, data) + + domain = Domain([], zoo_domain.class_vars, metas=zoo_domain.metas) + data = self.zoo.transform(domain) + self.send_signal(self.widget.Inputs.data, data) - def test_sort(self): - af = AggregationFunctionsEnum - self.assertEqual(sorted([af.Sum, af.Min]), sorted([af.Min, af.Sum])) + def test_migrate_settings_1_to_2(self): + afe = Orange.widgets.data.owpivot.AggregationFunctionsEnum + settings = {'sel_agg_functions': + {afe.Count, afe.Sum, afe.Min, afe.Majority}} + OWPivot.migrate_settings(settings, 1) + self.assertEqual(settings["sel_agg_functions"], + {Pivot.Count.value, Pivot.Sum.value, + Pivot.Min.value, Pivot.Majority.value}) + + +_MockCount = Function(Pivot.Count.value, "Count", + Mock(side_effect=Pivot.Count.func)) +_MockSum = Function(Pivot.Sum.value, "Sum", + Mock(side_effect=Pivot.Sum.func)) class TestPivot(unittest.TestCase): @@ -345,8 +377,9 @@ def test_group_table_metas(self): Dv("d2", ("a", "b")), Cv("c2")]) X = np.array([[0, 1, 0, 2], [1, 2, np.nan, 3], [0, 3, 1, np.nan]]) table = Table(domain, X).transform( - Domain(domain.attributes[:2], metas=domain.attributes[2:])) - table.metas = table.metas.astype(object) + Domain(domain.attributes[:2], metas=domain.attributes[2:])).copy() + with table.unlocked(): + table.metas = table.metas.astype(object) pivot = Pivot(table, Pivot.Functions, table.domain[-1]) group_tab = pivot.group_table @@ -364,14 +397,24 @@ def test_group_table_metas(self): np.nan, np.nan, np.nan, np.nan, np.nan]], dtype=float) self.assert_table_equal(group_tab, Table(Domain(atts), X)) - @patch("Orange.widgets.data.owpivot.Pivot.Count.func", - side_effect=Pivot.Count.func) - @patch("Orange.widgets.data.owpivot.Pivot.Sum.func", - side_effect=Pivot.Sum.func) - def test_group_table_use_cached(self, count_func, sum_func): + @patch("Orange.widgets.data.owpivot.Pivot.Functions", new=[ + _MockCount if f.name == "Count" else _MockSum if f.name == "Sum" else f + for f in Orange.widgets.data.owpivot.Pivot.Functions]) + @patch("Orange.widgets.data.owpivot.Pivot.Sum", new=_MockSum) + @patch("Orange.widgets.data.owpivot.Pivot.Count", new=_MockCount) + @patch("Orange.widgets.data.owpivot.Pivot.AutonomousFunctions", + new=(_MockCount,)) + @patch("Orange.widgets.data.owpivot.Pivot.ContVarFunctions", + new=(_MockSum, ) + Pivot.ContVarFunctions[1:]) + @patch("Orange.widgets.data.owpivot.Pivot.FloatFunctions", + new=(_MockCount, ) + Pivot.FloatFunctions[1:]) + def test_group_table_use_cached(self): + domain = self.table.domain pivot = Pivot(self.table, [Pivot.Count, Pivot.Sum], domain[0], domain[1]) group_tab = pivot.group_table + count_func = _MockCount.func + sum_func = _MockSum.func count_func.reset_mock() sum_func.reset_mock() @@ -466,7 +509,7 @@ def test_group_table_update(self): [1, 2, 1, 1, 1, 1, 2, 1, 7, 7, 7, 7, 7, 7, 0]]) table = Table(Domain(domain[:2] + atts), X) - agg = [Pivot.Functions.Count, Pivot.Functions.Sum] + agg = [Pivot.Count, Pivot.Sum] pivot = Pivot(self.table, agg, domain[0], domain[1]) group_tab = pivot.group_table pivot.update_group_table(Pivot.Functions) @@ -536,7 +579,7 @@ def test_pivot(self): def test_pivot_total(self): domain = self.table.domain - pivot = Pivot(self.table, [Pivot.Functions.Count, Pivot.Functions.Sum], + pivot = Pivot(self.table, [Pivot.Count, Pivot.Sum], domain[0], domain[1], domain[2]) atts = (Dv(domain[0].name, ["Total"]), @@ -683,7 +726,7 @@ def test_pivot_attr_combinations(self): def test_pivot_update(self): domain = self.table.domain - pivot = Pivot(self.table, [Pivot.Functions.Count], domain[0], + pivot = Pivot(self.table, [Pivot.Count], domain[0], domain[1], domain[2]) pivot_tab1 = pivot.pivot_table pivot.update_pivot_table(domain[1]) @@ -704,7 +747,7 @@ def test_pivot_renaming_domain(self): data = Table("iris") cls_var = data.domain.class_var.copy(name='Aggregate') data.domain = Domain(data.domain.attributes, (cls_var,)) - pivot = Pivot(data, [Pivot.Functions.Sum], cls_var, None, None) + pivot = Pivot(data, [Pivot.Sum], cls_var, None, None) renamed_var = data.domain.class_var.copy(name='Aggregate (1)') self.assertTrue(renamed_var in pivot.pivot_table.domain) diff --git a/Orange/widgets/data/tests/test_owpreprocess.py b/Orange/widgets/data/tests/test_owpreprocess.py index cfdf1456c40..9d2f13f4486 100644 --- a/Orange/widgets/data/tests/test_owpreprocess.py +++ b/Orange/widgets/data/tests/test_owpreprocess.py @@ -1,5 +1,7 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring,unsubscriptable-object +import unittest + import numpy as np from Orange.data import Table @@ -41,7 +43,8 @@ def test_randomize(self): def test_remove_sparse(self): data = Table("iris") idx = int(data.X.shape[0]/10) - data.X[:idx+1, 0] = np.zeros((idx+1,)) + with data.unlocked(): + data.X[:idx+1, 0] = np.zeros((idx+1,)) saved = {"preprocessors": [("orange.preprocess.remove_sparse", {'filter0': True, 'useFixedThreshold': False, 'percThresh':10, 'fixedThresh': 50})]} @@ -230,6 +233,13 @@ def test_editor(self): self.assertIsInstance(p, fss.SelectRandomFeatures) self.assertEqual(p.k, 0.25) + def test_repr(self): + widget = owpreprocess.RandomFeatureSelectEditor() + for strategy in (owpreprocess.RandomFeatureSelectEditor.Fixed, + owpreprocess.RandomFeatureSelectEditor.Percentage): + widget.setStrategy(strategy) + repr(widget) + class TestRandomizeEditor(WidgetTest): def test_editor(self): @@ -280,8 +290,8 @@ def test_editor(self): self.assertEqual(p.rank, 5) self.assertEqual(p.max_error, 0.5) -class TestRemoveSparseEditor(WidgetTest): +class TestRemoveSparseEditor(WidgetTest): def test_editor(self): widget = owpreprocess.RemoveSparseEditor() self.assertEqual( @@ -303,3 +313,12 @@ def test_editor(self): self.assertIsInstance(p, RemoveSparse) self.assertEqual(p.threshold, 30) self.assertFalse(p.filter0) + + def test_repr(self): + widget = owpreprocess.RemoveSparseEditor() + for widget.useFixedThreshold in (False, True): + repr(widget) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py index 6c505737e76..f0aeee38de0 100644 --- a/Orange/widgets/data/tests/test_owpythonscript.py +++ b/Orange/widgets/data/tests/test_owpythonscript.py @@ -1,16 +1,25 @@ # Test methods with long descriptive names can omit docstrings -# pylint: disable=missing-docstring +# pylint: disable=missing-docstring, unused-wildcard-import +# pylint: disable=wildcard-import, protected-access +import os import sys +import unittest +from unittest.mock import patch -from AnyQt.QtCore import QMimeData, QUrl, QPoint, Qt -from AnyQt.QtGui import QDragEnterEvent, QDropEvent +from AnyQt.QtCore import QMimeData, QPoint, Qt, QUrl +from AnyQt.QtGui import QDragEnterEvent -from Orange.data import Table from Orange.classification import LogisticRegressionLearner +from Orange.data import Table from Orange.tests import named_file -from Orange.widgets.data.owpythonscript import OWPythonScript, read_file_content, Script -from Orange.widgets.tests.base import WidgetTest, DummySignalManager -from Orange.widgets.widget import OWWidget +from Orange.widgets.data.owpythonscript import ( + OWPythonScript, + OWPythonScriptDropHandler, + Script, + read_file_content, +) +from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.widget import Input, OWWidget class TestOWPythonScript(WidgetTest): @@ -25,18 +34,22 @@ def tearDown(self): sys.last_type = sys.last_value = sys.last_traceback = None super().tearDown() + @WidgetTest.skipNonEnglish def test_inputs(self): """Check widget's inputs""" for input_, data in (("Data", self.iris), ("Learner", self.learner), ("Classifier", self.model), ("Object", "object")): - self.assertEqual(getattr(self.widget, input_.lower()), {}) + self.assertEqual(getattr(self.widget, input_.lower()), []) self.send_signal(input_, data, 1) - self.assertEqual(getattr(self.widget, input_.lower()), {1: data}) + self.assertEqual(getattr(self.widget, input_.lower()), [data]) self.send_signal(input_, None, 1) - self.assertEqual(getattr(self.widget, input_.lower()), {}) + self.assertEqual(getattr(self.widget, input_.lower()), [None]) + self.send_signal(input_, Input.Closed, 1) + self.assertEqual(getattr(self.widget, input_.lower()), []) + @WidgetTest.skipNonEnglish def test_outputs(self): """Check widget's outputs""" for signal, data in ( @@ -88,6 +101,7 @@ def test_wrong_outputs(self): def test_owns_errors(self): self.assertIsNot(self.widget.Error, OWWidget.Error) + @WidgetTest.skipNonEnglish def test_multiple_signals(self): click = self.widget.execute_button.click console_locals = self.widget.console.locals @@ -113,12 +127,19 @@ def test_multiple_signals(self): self.send_signal("Data", None, 2) click() + datas = console_locals["in_datas"] + self.assertEqual(len(datas), 2) + self.assertIs(datas[0], self.iris) + self.assertIs(datas[1], None) + + self.send_signal("Data", Input.Closed, 2) + click() self.assertIs(console_locals["in_data"], self.iris) datas = console_locals["in_datas"] self.assertEqual(len(datas), 1) self.assertIs(datas[0], self.iris) - self.send_signal("Data", None, 1) + self.send_signal("Data", Input.Closed, 1) click() self.assertIsNone(console_locals["in_data"]) self.assertEqual(console_locals["in_datas"], []) @@ -174,7 +195,11 @@ def test_script_insert_mime_file(self): url = QUrl.fromLocalFile(fn) mime.setUrls([url]) self.widget.text.insertFromMimeData(mime) - self.assertEqual("test", self.widget.text.toPlainText()) + text = self.widget.text.toPlainText().split("print('Hello world')")[0] + self.assertTrue( + "'" + fn + "'", + text + ) self.widget.text.undo() self.assertEqual(previous, self.widget.text.toPlainText()) @@ -201,52 +226,6 @@ def _drag_enter_event(self, url): QPoint(0, 0), Qt.MoveAction, data, Qt.NoButton, Qt.NoModifier) - def test_dropEvent_replaces_file(self): - with named_file("test", suffix=".42") as fn: - previous = self.widget.text.toPlainText() - event = self._drop_event(QUrl.fromLocalFile(fn)) - self.widget.dropEvent(event) - self.assertEqual("test", self.widget.text.toPlainText()) - self.widget.text.undo() - self.assertEqual(previous, self.widget.text.toPlainText()) - - def _drop_event(self, url): - # make sure data does not get garbage collected before it used - # pylint: disable=attribute-defined-outside-init - self.event_data = data = QMimeData() - data.setUrls([QUrl(url)]) - - return QDropEvent( - QPoint(0, 0), Qt.MoveAction, data, - Qt.NoButton, Qt.NoModifier, QDropEvent.Drop) - - def test_shared_namespaces(self): - widget1 = self.create_widget(OWPythonScript) - widget2 = self.create_widget(OWPythonScript) - self.signal_manager = DummySignalManager() - widget3 = self.create_widget(OWPythonScript) - - self.send_signal(widget1.Inputs.data, self.iris, 1, widget=widget1) - widget1.text.setPlainText("x = 42\n" - "out_data = in_data\n") - widget1.execute_button.click() - self.assertIs( - self.get_output(widget1.Outputs.data, widget=widget1), - self.iris) - - widget2.text.setPlainText("out_object = 2 * x\n" - "out_data = in_data") - widget2.execute_button.click() - self.assertEqual( - self.get_output(widget1.Outputs.object, widget=widget2), - 84) - self.assertIsNone(self.get_output(widget1.Outputs.data, widget=widget2)) - - sys.last_traceback = None - widget3.text.setPlainText("out_object = 2 * x") - widget3.execute_button.click() - self.assertIsNotNone(sys.last_traceback) - def test_migrate(self): w = self.create_widget(OWPythonScript, { "libraryListSource": [Script("A", "1")], @@ -260,3 +239,62 @@ def test_restore(self): "__version__": 2 }) self.assertEqual(w.libraryListSource[0].name, "A") + + def test_no_shared_namespaces(self): + """ + Previously, Python Script widgets in the same schema shared a namespace. + I (irgolic) think this is just a way to encourage users in writing + messy workflows with race conditions, so I encourage them to share + between Python Script widgets with Object signals. + """ + widget1 = self.create_widget(OWPythonScript) + widget2 = self.create_widget(OWPythonScript) + + click1 = widget1.execute_button.click + click2 = widget2.execute_button.click + + widget1.text.text = "x = 42" + click1() + + widget2.text.text = "y = 2 * x" + click2() + self.assertIn("NameError: name 'x' is not defined", + widget2.console.toPlainText()) + + +class TestOWPythonScriptDropHandler(unittest.TestCase): + def test_canDropFile(self): + handler = OWPythonScriptDropHandler() + self.assertTrue(handler.canDropFile(__file__)) + self.assertFalse(handler.canDropFile("test.tab")) + + def test_parametersFromFile(self): + handler = OWPythonScriptDropHandler() + r = handler.parametersFromFile(__file__) + item = r["scriptLibrary"][0] + self.assertEqual(item["filename"], __file__) + scripts = [ + { + "name": "Add", + "script": "1 + 1", + "filename": None, + }, + { + "name": os.path.basename(__file__), + "script": "42", + "filename": __file__, + }, + ] + defs = { + "scriptLibrary": scripts, + "__version__": 2 + } + with patch.object(OWPythonScript.settingsHandler, "defaults", defs): + r = handler.parametersFromFile(__file__) + self.assertEqual(len(r["scriptLibrary"]), 2) + item = r["scriptLibrary"][0] + self.assertEqual(item["filename"], __file__) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/tests/test_owrandomize.py b/Orange/widgets/data/tests/test_owrandomize.py index ab594846cba..59af8e9ab0a 100644 --- a/Orange/widgets/data/tests/test_owrandomize.py +++ b/Orange/widgets/data/tests/test_owrandomize.py @@ -68,14 +68,14 @@ def test_replicable_shuffling(self): self.assertTrue((output.Y != self.zoo.Y).any()) self.assertTrue((np.sort(output.Y, axis=0) == np.sort(self.zoo.Y, axis=0)).all()) - self.widget.apply() + self.widget.commit.now() output2 = self.get_output(self.widget.Outputs.data) np.testing.assert_array_equal(output.X, output2.X) np.testing.assert_array_equal(output.Y, output2.Y) np.testing.assert_array_equal(output.metas, output2.metas) def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_apply') as apply: + with patch.object(self.widget.commit, 'now') as apply: self.widget.auto_apply = False apply.reset_mock() self.send_signal(self.widget.Inputs.data, self.zoo) diff --git a/Orange/widgets/data/tests/test_owrank.py b/Orange/widgets/data/tests/test_owrank.py index c015f313dd3..d4a5f02d707 100644 --- a/Orange/widgets/data/tests/test_owrank.py +++ b/Orange/widgets/data/tests/test_owrank.py @@ -2,15 +2,20 @@ import time import warnings import unittest +from enum import Enum +from itertools import count from unittest.mock import patch import numpy as np from sklearn.exceptions import ConvergenceWarning -from AnyQt.QtCore import Qt, QItemSelection -from AnyQt.QtWidgets import QCheckBox +from AnyQt.QtCore import Qt, QItemSelection, QItemSelectionModel, \ + QT_VERSION_INFO +from AnyQt.QtGui import QIcon +from AnyQt.QtWidgets import QCheckBox, QApplication from orangewidget.settings import Context, IncompatibleContext +from orangewidget.tests.base import GuiTest from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable from Orange.modelling import RandomForestLearner, SGDLearner @@ -18,7 +23,9 @@ from Orange.classification import LogisticRegressionLearner from Orange.regression import LinearRegressionLearner from Orange.projection import PCA -from Orange.widgets.data.owrank import OWRank, ProblemType, CLS_SCORES, REG_SCORES +from Orange.widgets import gui +from Orange.widgets.data.owrank import OWRank, ProblemType, CLS_SCORES, \ + REG_SCORES, RankTableModel from Orange.widgets.tests.base import WidgetTest, datasets from Orange.widgets.widget import AttributeList @@ -31,6 +38,62 @@ def score_data(self, data, feature=None): return np.ones((1, len(data.domain.attributes))) +class TestRankModel(GuiTest): + def setUp(self): + attributes = [DiscreteVariable("ann", values=tuple("ab")), + DiscreteVariable("great", values=tuple("defg"))] \ + + [ContinuousVariable(x) for x in "def"] + self.attributes = attributes + attributes[0].attributes["foo"] = "bar" + data = [[var, nvals] + [10 * i + j for j in range(3)] + for i, var, nvals in zip(count(), attributes, + (2, 4, np.nan, np.nan, np.nan))] + self.model = RankTableModel() + self.model.wrap(data) + + def _get(self, row, column, role=Qt.DisplayRole): + return self.model.index(row, column).data(role) + + def test_data(self): + # scores + self.assertEqual(self._get(0, 4), 2) + self.assertEqual(self._get(1, 2), 10) + + # n values + self.assertEqual(self._get(0, 1), 2) + self.assertEqual(self._get(1, 1), 4) + self.assertEqual(self._get(2, 1), "") + + # variables + self.assertEqual(self._get(0, 0), "ann") + self.assertEqual(self._get(1, 0), "great") + self.assertEqual(self._get(3, 0), "e") + + self.assertIsInstance(self._get(0, 0, Qt.DecorationRole), QIcon) + self.assertIsInstance(self._get(2, 0, Qt.DecorationRole), QIcon) + + self.assertIn("foo", self._get(0, 0, Qt.ToolTipRole)) + self.assertIn("bar", self._get(0, 0, Qt.ToolTipRole)) + self.assertNotIn("bar", self._get(1, 0, Qt.ToolTipRole)) + + self.assertIs(self._get(0, 0, gui.TableVariable), self.attributes[0]) + self.assertIs(self._get(3, 0, gui.TableVariable), self.attributes[3]) + + def test_sorting(self): + self.model.sort(0, Qt.AscendingOrder) + self.assertIs(self._get(0, 0, gui.TableVariable), self.attributes[0]) + self.assertIs(self._get(1, 0, gui.TableVariable), self.attributes[2]) + self.assertIs(self._get(0, 2 + 1), 1) + self.assertIs(self._get(1, 2 + 0), 20) + + self.model.sort(2, Qt.DescendingOrder) + self.assertIs(self._get(0, 0, gui.TableVariable), self.attributes[4]) + self.assertIs(self._get(4, 0, gui.TableVariable), self.attributes[0]) + self.assertIs(self._get(4, 2 + 1), 1) + self.assertIs(self._get(3, 2 + 0), 10) + self.assertIs(self._get(0, 2 + 0), 40) + + class TestOWRank(WidgetTest): def setUp(self): self.widget = self.create_widget(OWRank) # type: OWRank @@ -59,16 +122,16 @@ def test_input_data_disconnect(self): def test_input_scorer(self): """Check widget's scorer with scorer on the input""" - self.assertEqual(self.widget.scorers, {}) + self.assertEqual(self.widget.scorers, []) self.send_signal(self.widget.Inputs.scorer, self.log_reg, 1) self.wait_until_finished() - value = self.widget.scorers[1] + value = self.widget.scorers[0] self.assertEqual(self.log_reg, value.scorer) self.assertIsInstance(value.scorer, Scorer) def test_input_scorer_fitter(self): heart_disease = Table('heart_disease') - self.assertEqual(self.widget.scorers, {}) + self.assertEqual(self.widget.scorers, []) model = self.widget.ranksModel @@ -77,12 +140,12 @@ def test_input_scorer_fitter(self): with self.subTest(fitter=fitter),\ warnings.catch_warnings(): warnings.filterwarnings("ignore", ".*", ConvergenceWarning) - self.send_signal("Scorer", fitter, 1) + self.send_signal(self.widget.Inputs.scorer, fitter, 1) for data in (self.housing, heart_disease): with self.subTest(data=data.name): - self.send_signal('Data', data) + self.send_signal(self.widget.Inputs.data, data) self.wait_until_finished() scores = [model.data(model.index(row, model.columnCount() - 1)) for row in range(model.rowCount())] @@ -93,15 +156,15 @@ def test_input_scorer_fitter(self): model.columnCount() - 1, Qt.Horizontal).lower() self.assertIn(name, last_column) - self.send_signal("Scorer", None, 1) - self.assertEqual(self.widget.scorers, {}) + self.send_signal(self.widget.Inputs.scorer, None, 1) + self.assertEqual(self.widget.scorers, []) def test_input_scorer_disconnect(self): """Check widget's scorer after disconnecting scorer on the input""" self.send_signal(self.widget.Inputs.scorer, self.log_reg, 1) self.assertEqual(len(self.widget.scorers), 1) self.send_signal(self.widget.Inputs.scorer, None, 1) - self.assertEqual(self.widget.scorers, {}) + self.assertEqual(self.widget.scorers, []) def test_output_data(self): """Check data on the output after apply""" @@ -308,23 +371,58 @@ def test_scores_sorting(self): order1 = self.widget.ranksModel.mapToSourceRows(...).tolist() self._get_checkbox('FCBF').setChecked(True) self.wait_until_finished() - self.widget.ranksView.horizontalHeader().setSortIndicator(3, Qt.DescendingOrder) + self.widget.ranksView.horizontalHeader().setSortIndicator(4, Qt.DescendingOrder) order2 = self.widget.ranksModel.mapToSourceRows(...).tolist() self.assertNotEqual(order1, order2) + def test_score_sorting_int(self): + """ + Order setting was previously set to Qt.SortOrder which is in PyQt5 + int-like PyQt object. Since in PyQt6 it is Enum (non int) object, and it + is not nice to have objects in settings we changed it to int. This test + cover current case and also case with int-like object before. + """ + self.widget.sorting = (1, 1) # Gini col, descending order + self.send_signal(self.widget.Inputs.data, self.iris) + self.wait_until_finished() + order = self.widget.ranksModel.mapToSourceRows(...).tolist() + self.assertListEqual([2, 3, 0, 1], order) + + self.widget.sorting = (1, 0) # Gini col, descending order + self.send_signal(self.widget.Inputs.data, self.iris) + self.wait_until_finished() + order = self.widget.ranksModel.mapToSourceRows(...).tolist() + self.assertListEqual([1, 0, 3, 2], order) + + # change old setting to int + # since test can run in both pyqt5 or 6 we create SortOrder like object + class SortOrderE(Enum): # pyqt6 like + ASCENDING = 0 + + settings = {"sorting": (1, SortOrderE.ASCENDING), "__version__": 2} + w = self.create_widget(OWRank, stored_settings=settings) + self.assertEqual(0, w.sorting[1]) + + class SortOrderI: # pyqt5 like + ASCENDING = 0 + + settings = {"sorting": (1, SortOrderI.ASCENDING), "__version__": 2} + w = self.create_widget(OWRank, stored_settings=settings) + self.assertEqual(0, w.sorting[1]) + def test_scores_nan_sorting(self): """Check NaNs are sorted last""" data = self.iris.copy() - data.get_column_view('petal length')[0][:] = np.nan + with data.unlocked(): + data.set_column('petal length', np.nan) self.send_signal(self.widget.Inputs.data, data) self.wait_until_finished() # Assert last row is all nan - for order in (Qt.AscendingOrder, - Qt.DescendingOrder): - self.widget.ranksView.horizontalHeader().setSortIndicator(1, order) + for order in (Qt.AscendingOrder, Qt.DescendingOrder): + self.widget.ranksView.horizontalHeader().setSortIndicator(2, order) last_row = self.widget.ranksModel[self.widget.ranksModel.mapToSourceRows(...)[-1]] - np.testing.assert_array_equal(last_row, np.repeat(np.nan, 3)) + np.testing.assert_array_equal(last_row[1:], np.repeat(np.nan, 3)) def test_default_sort_indicator(self): self.send_signal(self.widget.Inputs.data, self.iris) @@ -350,6 +448,8 @@ def test_data_which_make_scorer_nan(self): self.widget.selected_methods.add('ANOVA') self.send_signal(self.widget.Inputs.data, table) + @unittest.skipIf(lambda: QT_VERSION_INFO < (6,), + "headerState is not restored in Qt6") def test_setting_migration_fixes_header_state(self): # Settings as of version 3.3.5 settings = { @@ -395,7 +495,7 @@ def test_auto_selection_manual(self): # Sort by number of values and set selection to attributes with most # values. This must select the top 4 rows. - self.widget.ranksView.horizontalHeader().setSortIndicator(0, Qt.DescendingOrder) + self.widget.ranksView.horizontalHeader().setSortIndicator(1, Qt.DescendingOrder) w.selectionMethod = w.SelectManual w.selected_attrs = [dom["chest pain"], dom["rest ECG"], dom["slope peak exc ST"], dom["thal"]] @@ -404,6 +504,69 @@ def test_auto_selection_manual(self): sorted({idx.row() for idx in w.ranksView.selectedIndexes()}), [0, 1, 2, 3]) + def test_resorting_and_selection(self): + def sortby(col): + model.sort(col) + view.horizontalHeader().sectionClicked.emit(col) + QApplication.processEvents() + + Names, Values, Gain = range(3) + + w = self.widget + + data = Table("heart_disease") + self.send_signal(w.Inputs.data, data) + self.wait_until_finished() + + first4 = set(sorted((var.name for var in data.domain.attributes), key=str.lower)[:4]) + + view = w.ranksView + model = w.ranksModel + selModel = view.selectionModel() + columnCount = model.columnCount() + + w.selectionMethod = w.SelectNBest + w.nSelected = 4 + + # Sort by gain ratio, store selection + sortby(Gain) + gain_sel_4 = w.selected_attrs[:] + self.assertEqual(len(gain_sel_4), 4) + + # Sort by names or number of values: selection unchanged + sortby(Values) + self.assertEqual(w.selected_attrs[:], gain_sel_4) + + sortby(Names) + self.assertEqual(w.selected_attrs[:], gain_sel_4) + + # Select first four (alphabetically) + w.selectionMethod = w.SelectManual + selection = QItemSelection( + model.index(0, 0), + model.index(3, columnCount - 1) + ) + selModel.select(selection, QItemSelectionModel.ClearAndSelect) + # Sanity check + self.assertEqual({var.name for var in w.selected_attrs}, first4) + + # Manual sorting: sorting by score does not change selection + sortby(Gain) + self.assertEqual({var.name for var in w.selected_attrs}, first4) + + # Sort by first four, again + sortby(Names) + # Sanity check + self.assertEqual({var.name for var in w.selected_attrs}, first4) + + w.selectionMethod = w.SelectNBest + # Sanity check + self.assertEqual({var.name for var in w.selected_attrs}, first4) + + # Sorting by gain must change selection + sortby(Gain) + self.assertEqual(set(w.selected_attrs), set(gain_sel_4)) + def test_auto_send(self): widget = self.widget model = widget.ranksModel diff --git a/Orange/widgets/data/tests/test_owsave.py b/Orange/widgets/data/tests/test_owsave.py index 2f17a3d186a..9cedbda224e 100644 --- a/Orange/widgets/data/tests/test_owsave.py +++ b/Orange/widgets/data/tests/test_owsave.py @@ -36,6 +36,7 @@ class OWSaveTestBase(WidgetTest, SaveWidgetsTestBaseMixin): def setUp(self): with open_widget_classes(): class OWSaveMockWriter(OWSave): + keywords = "_keywords" writer = Mock() writer.EXTENSIONS = [".csv"] writer.SUPPORT_COMPRESSED = True @@ -73,7 +74,7 @@ def test_initial_start_dir(self): with patch("os.path.exists", return_value=True): widget.filename = _w("/usr/foo/bar.csv") - self.assertEqual(widget.initial_start_dir(), widget.filename) + self.assertEqual(widget.initial_start_dir(), widget.filename[:-4]) widget.filename = "" widget.last_dir = _w("/usr/bar") @@ -81,12 +82,11 @@ def test_initial_start_dir(self): widget.last_dir = _w("/usr/bar") self.send_signal(widget.Inputs.data, self.iris) - self.assertEqual(widget.initial_start_dir(), - _w("/usr/bar/iris.csv")) + self.assertEqual(widget.initial_start_dir(), _w("/usr/bar/iris")) widget.last_dir = "" self.assertEqual(widget.initial_start_dir(), - os.path.expanduser(_w("~/iris.csv"))) + os.path.expanduser(_w("~/iris"))) @patch("Orange.widgets.utils.save.owsavebase.QFileDialog.getSaveFileName") def test_save_file_sets_name(self, _filedialog): @@ -167,7 +167,8 @@ def test_save_file_checks_can_save(self): widget.writer.write.assert_called() widget.writer.reset_mock() - self.iris.X = sp.csr_matrix(self.iris.X) + with self.iris.unlocked(): + self.iris.X = sp.csr_matrix(self.iris.X) widget.save_file() widget.writer.write.assert_not_called() @@ -239,7 +240,8 @@ def test_sparse_error(self): widget.update_messages() self.assertFalse(err.is_shown()) - widget.data.X = sp.csr_matrix(widget.data.X) + with self.iris.unlocked(): + widget.data.X = sp.csr_matrix(widget.data.X) widget.update_messages() self.assertTrue(err.is_shown()) @@ -264,7 +266,8 @@ def test_valid_filters_for_sparse(self): widget.data = self.iris self.assertEqual(widget.get_filters(), widget.valid_filters()) - widget.data.X = sp.csr_matrix(widget.data.X) + with self.iris.unlocked(): + widget.data.X = sp.csr_matrix(widget.data.X) valid = widget.valid_filters() self.assertNotEqual(widget.get_filters(), {}) # false positive, pylint: disable=no-member @@ -282,7 +285,8 @@ def test_valid_default_filter(self): widget.data = self.iris self.assertIs(widget.filter, widget.default_valid_filter()) - widget.data.X = sp.csr_matrix(widget.data.X) + with self.iris.unlocked(): + widget.data.X = sp.csr_matrix(widget.data.X) self.assertTrue( widget.get_filters()[widget.default_valid_filter()] .SUPPORT_SPARSE_DATA) @@ -311,6 +315,7 @@ def test_send_report(self): else: self.assertFalse(items["Type annotations"], msg=msg) + @WidgetTest.skipNonEnglish def test_migration_to_version_2(self): const_settings = { 'add_type_annotations': True, 'auto_save': False, @@ -363,13 +368,38 @@ def test_migration_to_version_2(self): OWSave.migrate_settings(settings) self.assertTrue(settings["filter"] in OWSave.get_filters()) - # Unsupported file format (is this possible?) + # Unsupported file format settings = {**const_settings, 'compress': True, 'compression': 'lzma (.xz)', 'filetype': 'Bar file (.bar)'} OWSave.migrate_settings(settings) self.assertTrue(settings["filter"] in OWSave.get_filters()) + def test_migration_to_version_3(self): + settings = {"add_type_annotations": True, + "stored_name": "zoo.xlsx", + "__version__": 2} + widget = self.create_widget(OWSave, stored_settings=settings) + self.assertFalse(widget.add_type_annotations) + + settings = {"add_type_annotations": True, + "stored_name": "zoo.tab", + "__version__": 2} + widget = self.create_widget(OWSave, stored_settings=settings) + self.assertTrue(widget.add_type_annotations) + + settings = {"add_type_annotations": False, + "stored_name": "zoo.xlsx", + "__version__": 2} + widget = self.create_widget(OWSave, stored_settings=settings) + self.assertFalse(widget.add_type_annotations) + + settings = {"add_type_annotations": False, + "stored_name": "zoo.tab", + "__version__": 2} + widget = self.create_widget(OWSave, stored_settings=settings) + self.assertFalse(widget.add_type_annotations) + class TestFunctionalOWSave(WidgetTest): def setUp(self): @@ -381,7 +411,8 @@ def test_save_uncompressed(self): widget.auto_save = False spiris = Table("iris") - spiris.X = sp.csr_matrix(spiris.X) + with spiris.unlocked(): + spiris.X = sp.csr_matrix(spiris.X) for selected_filter, writer in widget.get_filters().items(): widget.write = writer @@ -401,6 +432,33 @@ def test_save_uncompressed(self): if hasattr(writer, "read"): self.assertEqual(len(writer(filename).read()), 150) + def test_unsupported_file_format(self): + widget = self.create_widget( + OWSave, + stored_settings=dict( + filter="Unsupported filter (*.foo)", stored_name="test.foo", + __version__=2) + ) + filters = widget.get_filters() + def_filter = filters[widget.default_filter()] + + iris = Table("iris") + self.send_signal(widget.Inputs.data, iris) + + # With unsupported format from settings, the widget should indicate + # an error + with patch.object(def_filter, "write"): + widget.save_file() + self.assertTrue(widget.Error.unsupported_format.is_shown()) + def_filter.write.assert_not_called() + + # Without file name, if `filter` is set to unsupported format, + # initial_start_dir should choose the default filter + widget.stored_name = "" + widget.initial_start_dir() + self.assertIs(filters[widget.filter], def_filter) + self.assertIs(widget.writer, def_filter) + @unittest.skipUnless(sys.platform == "linux", "Tests for dialog on Linux") class TestOWSaveLinuxDialog(OWSaveTestBase): @@ -463,15 +521,6 @@ def test_save_file_dialog_uses_valid_filters_linux(self): @unittest.skipUnless(sys.platform in ("darwin", "win32"), "Test for native dialog on Windows and macOS") class TestOWSaveDarwinDialog(OWSaveTestBase): # pragma: no cover - if sys.platform == "darwin": - @staticmethod - def remove_star(filt): - return filt.replace(" (*.", " (.") - else: - @staticmethod - def remove_star(filt): - return filt - @patch("Orange.widgets.utils.save.owsavebase.QFileDialog") def test_get_save_filename_darwin(self, dlg): widget = self.widget @@ -482,14 +531,11 @@ def test_get_save_filename_darwin(self, dlg): instance = dlg.return_value instance.exec.return_value = dlg.Accepted = QFileDialog.Accepted instance.selectedFiles.return_value = ["foo"] - instance.selectedNameFilter.return_value = self.remove_star("aa (*.a)") + instance.selectedNameFilter.return_value = "aa (*.a)" self.assertEqual(widget.get_save_filename(), ("foo.a", "aa (*.a)")) self.assertEqual(dlg.call_args[0][2], "baz") - self.assertEqual( - dlg.call_args[0][3], - self.remove_star("aa (*.a);;bb (*.b);;cc (*.c)")) - instance.selectNameFilter.assert_called_with( - self.remove_star("bb (*.b)")) + self.assertEqual(dlg.call_args[0][3], "aa (*.a);;bb (*.b);;cc (*.c)") + instance.selectNameFilter.assert_called_with("bb (*.b)") instance.exec.return_value = dlg.Rejected = QFileDialog.Rejected self.assertEqual(widget.get_save_filename(), ("", "")) @@ -509,7 +555,7 @@ def test_save_file_dialog_enforces_extension_darwin(self, dlg): instance = dlg.return_value instance.exec.return_value = QFileDialog.Accepted - instance.selectedNameFilter.return_value = self.remove_star(filter1) + instance.selectedNameFilter.return_value = filter1 instance.selectedFiles.return_value = ["foo"] self.assertEqual(widget.get_save_filename()[0], "foo.tab") instance.selectedFiles.return_value = ["foo.pkl"] @@ -521,7 +567,7 @@ def test_save_file_dialog_enforces_extension_darwin(self, dlg): instance.selectedFiles.return_value = ["foo.bar"] self.assertEqual(widget.get_save_filename()[0], "foo.bar.tab") - instance.selectedNameFilter.return_value = self.remove_star(filter2) + instance.selectedNameFilter.return_value = filter2 instance.selectedFiles.return_value = ["foo"] self.assertEqual(widget.get_save_filename()[0], "foo.csv.gz") instance.selectedFiles.return_value = ["foo.pkl"] @@ -533,36 +579,6 @@ def test_save_file_dialog_enforces_extension_darwin(self, dlg): instance.selectedFiles.return_value = ["foo.bar"] self.assertEqual(widget.get_save_filename()[0], "foo.bar.csv.gz") - @patch("Orange.widgets.utils.save.owsavebase.QFileDialog") - @patch("os.path.exists", new=lambda x: x == "old.tab") - @patch("Orange.widgets.utils.save.owsavebase.QMessageBox") - def test_save_file_dialog_asks_for_overwrite_darwin(self, msgbox, dlg): - def selected_files(): - nonlocal attempts - attempts += 1 - return [["old.tab", "new.tab"][attempts]] - - widget = self.widget - widget.initial_start_dir = lambda: "baz" - filter1 = "" # prevent pylint warning 'undefined-loop-variable' - for filter1 in widget.get_filters(): - if OWSaveBase._extension_from_filter(filter1) == ".tab": - break - - widget.filter = filter1 - instance = dlg.return_value - instance.exec.return_value = QFileDialog.Accepted - instance.selectedFiles = selected_files - instance.selectedNameFilter.return_value = self.remove_star(filter1) - - attempts = -1 - msgbox.question.return_value = msgbox.Yes = 1 - self.assertEqual(widget.get_save_filename()[0], "old.tab") - - attempts = -1 - msgbox.question.return_value = msgbox.No = 0 - self.assertEqual(widget.get_save_filename()[0], "new.tab") - @patch("Orange.widgets.utils.save.owsavebase.QFileDialog") def test_save_file_dialog_uses_valid_filters_darwin(self, dlg): widget = self.widget @@ -571,10 +587,8 @@ def test_save_file_dialog_uses_valid_filters_darwin(self, dlg): instance = dlg.return_value instance.exec.return_value = dlg.Rejected = QFileDialog.Rejected widget.get_save_filename() - self.assertEqual( - dlg.call_args[0][3], self.remove_star("aa (*.a);;bb (*.b)")) - instance.selectNameFilter.assert_called_with( - self.remove_star("aa (*.a)")) + self.assertEqual(dlg.call_args[0][3], "aa (*.a);;bb (*.b)") + instance.selectNameFilter.assert_called_with("aa (*.a)") if __name__ == "__main__": diff --git a/Orange/widgets/data/tests/test_owselectcolumns.py b/Orange/widgets/data/tests/test_owselectcolumns.py index 174ca9938ab..20b684bb777 100644 --- a/Orange/widgets/data/tests/test_owselectcolumns.py +++ b/Orange/widgets/data/tests/test_owselectcolumns.py @@ -1,27 +1,32 @@ # pylint: disable=unsubscriptable-object import unittest from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch import numpy as np -from AnyQt.QtCore import QMimeData, QPoint, Qt -from AnyQt.QtGui import QDragEnterEvent +from AnyQt.QtCore import QMimeData, QPoint, QPointF, Qt +from AnyQt.QtGui import QDragEnterEvent, QDropEvent, QDrag +from AnyQt.QtWidgets import QApplication +from orangewidget.tests.base import GuiTest -from Orange.data import Table, ContinuousVariable, DiscreteVariable, Domain +from Orange.data import Table, Domain, \ + ContinuousVariable, DiscreteVariable, StringVariable from Orange.widgets.settings import ContextSetting from Orange.widgets.utils import vartype from Orange.widgets.tests.base import WidgetTest from Orange.widgets.data.owselectcolumns \ import OWSelectAttributes, VariablesListItemModel, \ - SelectAttributesDomainContextHandler + SelectAttributesDomainContextHandler, SelectedVarsView, PrimitivesView from Orange.widgets.data.owrank import OWRank +from Orange.widgets.utils.itemmodels import select_rows from Orange.widgets.widget import AttributeList Continuous = vartype(ContinuousVariable("c")) Discrete = vartype(DiscreteVariable("d")) +# It is, what it is (and should be), pylint: disable=invalid-name class TestSelectAttributesDomainContextHandler(TestCase): def setUp(self): self.domain = Domain( @@ -41,6 +46,7 @@ def setUp(self): self.handler.read_defaults = lambda: None def test_open_context(self): + # Why not? pylint: disable=use-dict-literal self.handler.bind(SimpleWidget) context = Mock( attributes=self.args[1], metas=self.args[2], values=dict( @@ -68,6 +74,7 @@ def test_open_context(self): domain['c2']: ('class', 0)}) def test_open_context_with_imperfect_match(self): + # Why not? pylint: disable=use-dict-literal self.handler.bind(SimpleWidget) context1 = Mock(values=dict( domain_role_hints=({('d1', Discrete): ('attribute', 0), @@ -97,15 +104,72 @@ def test_open_context_with_imperfect_match(self): class TestModel(TestCase): + def setUp(self): + self.variables = \ + [ContinuousVariable(c) for c in "xyz"] + \ + [StringVariable(s) for s in "spqr"] + \ + [DiscreteVariable(d, values=tuple("def")) for d in "abc"] + + @staticmethod + def _vars(s): + return "".join(var.name for var in s) + def test_drop_mime(self): - iris = Table("iris") - m = VariablesListItemModel(iris.domain.variables) + m = VariablesListItemModel(self.variables) mime = m.mimeData([m.index(1, 0)]) self.assertTrue(mime.hasFormat(VariablesListItemModel.MIME_TYPE)) assert m.dropMimeData(mime, Qt.MoveAction, 5, 0, m.index(-1, -1)) self.assertIs(m[5], m[1]) assert m.dropMimeData(mime, Qt.MoveAction, -1, -1, m.index(-1, -1)) - self.assertIs(m[6], m[1]) + self.assertIs(m[11], m[1]) + + def test_drop_mime_primitive(self): + mime = QMimeData() + # the encoded 'data' is empty, variables are passed by properties + mime.setData(VariablesListItemModel.MIME_TYPE, b'') + mime.setProperty("_items", self.variables[2:]) + + m = VariablesListItemModel(self.variables[:2], primitive=False) + assert m.dropMimeData(mime, Qt.MoveAction, 1, 0, m.index(-1, -1)) + self.assertEqual(self._vars(m), "xzspqrabcy") + self.assertTrue(mime.property("_moved")) + + m = VariablesListItemModel(self.variables[:2], primitive=True) + assert m.dropMimeData(mime, Qt.MoveAction, 1, 0, m.index(-1, -1)) + self.assertEqual(self._vars(m), "xzabcy") + self.assertEqual(self._vars(mime.property("_moved")), "zabc") + + def test_drop_mime_noop(self): + m = VariablesListItemModel(self.variables[:2], primitive=False) + + mime = QMimeData() + # the encoded 'data' is empty, variables are passed by properties + mime.setData(VariablesListItemModel.MIME_TYPE, b'') + + mime.setProperty("_items", self.variables[:2]) + self.assertTrue(m.dropMimeData(mime, Qt.IgnoreAction, 1, 0, m.index(-1, -1))) + self.assertEqual(self._vars(m), "xy") + self.assertIsNone(mime.property("_moved")) + + mime.setProperty("_items", None) + self.assertFalse(m.dropMimeData(mime, Qt.MoveAction, 1, 0, m.index(-1, -1))) + self.assertEqual(self._vars(m), "xy") + self.assertIsNone(mime.property("_moved")) + + mime = QMimeData() + mime.setData("application/x-that-other-format", b'') + mime.setProperty("_items", self.variables[:2]) + + self.assertFalse(m.dropMimeData(mime, Qt.MoveAction, 1, 0, m.index(-1, -1))) + self.assertEqual(self._vars(m), "xy") + self.assertIsNone(mime.property("_moved")) + + def test_mimedata(self): + m = VariablesListItemModel(self.variables) + mime = m.mimeData([m.index(i, 0) for i in (1, 2, 5, 7, 9)]) + # 0123456789 + # xyzspqrabc + self.assertEqual(self._vars(mime.property("_items")), "yzqac") def test_flags(self): m = VariablesListItemModel([ContinuousVariable("X")]) @@ -117,6 +181,96 @@ def test_flags(self): self.assertTrue(flags & Qt.ItemIsDropEnabled) +class TestViews(GuiTest): + def setUp(self): + self.variables = \ + [ContinuousVariable(c) for c in "xyz"] + \ + [StringVariable(s) for s in "spqr"] + \ + [DiscreteVariable(d, values=tuple("def")) for d in "abc"] + self.model = VariablesListItemModel(self.variables) + self.view = SelectedVarsView() + self.view.setModel(self.model) + + @staticmethod + def _vars(s): + return "".join(var.name for var in s) + + @patch("AnyQt.QtGui.QDrag.exec") + def test_noop(self, drag_exec): + with patch.object(self.view, "selectedIndexes", return_value=[]): + assert self.view.startDrag(Qt.MoveAction) is None + drag_exec.assert_not_called() + + with patch.object(self.view, "selectedIndexes", + return_value=[self.model.index(1, 0)]), \ + patch.object(self.model, "mimeData", return_value=None): + assert self.view.startDrag(Qt.MoveAction) is None + drag_exec.assert_not_called() + + def test_move(self): + + def drag_exec(self, *_): + self.mimeData().setProperty("_moved", moved) + return Qt.MoveAction + + # 0123456789 + # xyzspqrabc + # yz p rab + indexes = [self.model.index(i, 0) for i in (1, 2, 4, 6, 7, 8)] + selmodel = self.view.selectionModel() + for index in indexes: + selmodel.select(index, selmodel.Select) + with patch("AnyQt.QtGui.QDrag.exec", drag_exec): + + moved = None + self.view.startDrag(Qt.MoveAction) + self.assertEqual(self.model.rowCount(), 10) + + moved = True + self.view.startDrag(Qt.MoveAction) + self.assertEqual(self._vars(self.model), "xsqc") + + self.model[:] = self.variables + indexes = [self.model.index(i, 0) for i in (1, 2, 4, 6, 7, 8)] + for index in indexes: + selmodel.select(index, selmodel.Select) + moved = [self.model[i] for i in (4, 6)] + self.view.startDrag(Qt.MoveAction) + self.assertEqual(self._vars(self.model), "xyzsqabc") + + @patch("AnyQt.QtGui.QDropEvent.source") + def test_primitives_accepts_drop(self, src): + view = PrimitivesView() + mime = QMimeData() + mime.setData(VariablesListItemModel.MIME_TYPE, b'') + event = QDropEvent(QPointF(20, 20), Qt.MoveAction, mime, + Qt.NoButton, Qt.NoModifier) + + with patch.object(event, "mimeData"): + self.assertFalse(view.acceptsDropEvent(event)) + event.mimeData.assert_not_called() + self.assertFalse(event.isAccepted()) + + src.return_value.window.return_value = view.window() + + mime.setProperty("_items", self.variables) + self.assertTrue(view.acceptsDropEvent(event)) + self.assertTrue(event.isAccepted()) + event.setAccepted(False) + + mime.setProperty("_items", None) + self.assertFalse(view.acceptsDropEvent(event)) + self.assertFalse(event.isAccepted()) + + mime.setProperty("_items", []) + self.assertFalse(view.acceptsDropEvent(event)) + self.assertFalse(event.isAccepted()) + + mime.setProperty("_items", self.variables[3:7]) # string variables + self.assertFalse(view.acceptsDropEvent(event)) + self.assertFalse(event.isAccepted()) + + class SimpleWidget: domain_role_hints = ContextSetting({}) required = ContextSetting("", required=ContextSetting.REQUIRED) @@ -133,10 +287,14 @@ def setUp(self): self.widget = self.create_widget(OWSelectAttributes) def assertVariableCountsEqual(self, available, used, classattrs, metas=0): - self.assertEqual(len(self.widget.available_attrs), available) - self.assertEqual(len(self.widget.used_attrs), used) - self.assertEqual(len(self.widget.class_attrs), classattrs) - self.assertEqual(len(self.widget.meta_attrs), metas) + self.widget.update_interface_state() + for (name, box, view), nattrs in zip(self.widget.view_boxes, + (available, used, classattrs, metas)): + self.assertEqual(view.model().rowCount(), nattrs) + if nattrs: + self.assertEqual(box.title(), f"{name} ({nattrs})") + else: + self.assertEqual(box.title(), name) def assertControlsEnabled(self, _list, button, box, widget=None): if widget is None: @@ -172,6 +330,87 @@ def test_multiple_target_variable(self): self.widget.move_selected(self.widget.class_attrs_view) self.assertVariableCountsEqual(0, 0, 5) + def test_move_to_primitive(self): + app = QApplication.instance() + widget = self.widget + + data = Table("zoo") + self.send_signal(widget.Inputs.data, data) + + # Selecting meta attribute must enable the corresponding button + widget.meta_attrs_view.selectAll() + app.processEvents() + self.assertFalse(widget.move_attr_button.isEnabled()) + self.assertFalse(widget.move_class_button.isEnabled()) + self.assertTrue(widget.move_meta_button.isEnabled()) + + # Moving to available + widget.move_meta_button.click() + self.assertVariableCountsEqual(available=1, used=16, classattrs=1, metas=0) + + # Selecting available attributes must enable only meta button + # because all selected attrs are non-primitive and can't be used for + # features or classes + widget.available_attrs_view.selectAll() + app.processEvents() + self.assertFalse(widget.move_attr_button.isEnabled()) + self.assertFalse(widget.move_class_button.isEnabled()) + self.assertTrue(widget.move_meta_button.isEnabled()) + + # Selecting class attributes must enable the corresponding button + widget.class_attrs_view.selectAll() + app.processEvents() + self.assertFalse(widget.move_attr_button.isEnabled()) + self.assertTrue(widget.move_class_button.isEnabled()) + self.assertFalse(widget.move_meta_button.isEnabled()) + + # Move it to available + widget.move_class_button.click() + self.assertVariableCountsEqual(available=2, used=16, classattrs=0, metas=0) + + # Selecting meta attributes: nothing there, so disable all buttons + widget.meta_attrs_view.selectAll() + app.processEvents() + self.assertFalse(widget.move_attr_button.isEnabled()) + self.assertFalse(widget.move_class_button.isEnabled()) + self.assertFalse(widget.move_meta_button.isEnabled()) + + # Selecting available attributes must now enable all buttons because + # there some of selected attributes are not primitive + widget.available_attrs_view.selectAll() + app.processEvents() + self.assertTrue(widget.move_attr_button.isEnabled()) + self.assertTrue(widget.move_class_button.isEnabled()) + self.assertTrue(widget.move_meta_button.isEnabled()) + + # Move to metas should move both attributes + widget.move_meta_button.click() + self.assertVariableCountsEqual(available=0, used=16, classattrs=0, metas=2) + + # Move them back to available + widget.meta_attrs_view.selectAll() + app.processEvents() + widget.move_meta_button.click() + self.assertVariableCountsEqual(available=2, used=16, classattrs=0, metas=0) + + # Now move them to class: only one should be moved + widget.available_attrs_view.selectAll() + app.processEvents() + widget.move_class_button.click() + self.assertVariableCountsEqual(available=1, used=16, classattrs=1, metas=0) + + # Move them back to available + widget.class_attrs_view.selectAll() + app.processEvents() + widget.move_class_button.click() + self.assertVariableCountsEqual(available=2, used=16, classattrs=0, metas=0) + + # Now move them to attributes: only one should be moved + widget.available_attrs_view.selectAll() + app.processEvents() + widget.move_attr_button.click() + self.assertVariableCountsEqual(available=1, used=17, classattrs=0, metas=0) + def test_input_features(self): data = Table("zoo") in_features = AttributeList(data.domain.attributes) @@ -377,6 +616,33 @@ def test_move_rows(self): data.domain.attributes ) + def test_drag_drop_move_rows(self): + data = Table("iris")[:5] + w = self.widget + self.send_signal(w.Inputs.data, data) + used = w.used_attrs_view + unused = w.available_attrs_view + model = used.model() + select_rows(used, [0, 1]) + + def drag_exec(self, supported, default): + mime = self.mimeData() + drop = QDropEvent(QPointF(20, 20), supported, mime, + Qt.NoButton, Qt.NoModifier) + drop.setDropAction(default) + drop.setAccepted(False) + unused.dropEvent(drop) + assert drop.isAccepted() + return drop.dropAction() + + with patch.object(QDrag, "exec", drag_exec): + used.startDrag(Qt.MoveAction) + + self.assertEqual(model.rowCount(), 2) + self.assertEqual(unused.model().rowCount(), 2) + out = self.get_output(w.Outputs.data, w) + self.assertEqual(out.domain.attributes, data.domain.attributes[2:]) + def test_domain_new_feature(self): """ Test scenario when new attribute is added at position 0 """ data = Table("iris") diff --git a/Orange/widgets/data/tests/test_owselectrows.py b/Orange/widgets/data/tests/test_owselectrows.py index 39331071ca0..ac1b96ba169 100644 --- a/Orange/widgets/data/tests/test_owselectrows.py +++ b/Orange/widgets/data/tests/test_owselectrows.py @@ -1,10 +1,10 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring,unsubscriptable-object -import time +import unittest from unittest.mock import patch import numpy as np -from AnyQt.QtCore import QLocale, Qt, QDate +from AnyQt.QtCore import QLocale, Qt, QDate, QDateTime, QTime from AnyQt.QtTest import QTest from AnyQt.QtWidgets import QLineEdit, QComboBox @@ -47,9 +47,13 @@ FilterString.Between: ["aardwark", "cat"], FilterString.Outside: ["aardwark"], FilterString.Contains: ["aa"], + FilterString.NotContain: ["bb"], FilterString.StartsWith: ["aa"], + FilterString.NotStartsWith: ["bb"], FilterString.EndsWith: ["ark"], - FilterString.IsDefined: [] + FilterString.NotEndsWith: ["zz"], + FilterString.IsDefined: [], + FilterString.NotIsDefined: [], # TODO: needs test data } DFValues = { @@ -81,32 +85,32 @@ def test_filter_cont(self): self.widget.auto_commit = True self.widget.set_data(iris) - for i, (op, _) in enumerate(OWSelectRows.Operators[ContinuousVariable]): + for i, (op, *_) in enumerate(OWSelectRows.Operators[ContinuousVariable]): self.widget.remove_all() self.widget.add_row(iris.domain[0], i, CFValues[op]) self.widget.conditions_changed() - self.widget.unconditional_commit() + self.widget.commit.now() # continuous var in metas iris = Table.from_table( Domain([], metas=[iris.domain.attributes[0]]), iris ) self.widget.set_data(iris) - for i, (op, _) in enumerate(OWSelectRows.Operators[ContinuousVariable]): + for i, (op, *_) in enumerate(OWSelectRows.Operators[ContinuousVariable]): self.widget.remove_all() self.widget.add_row(iris.domain.metas[0], i, CFValues[op]) self.widget.conditions_changed() - self.widget.unconditional_commit() + self.widget.commit.now() def test_filter_str(self): zoo = Table("zoo")[::5] self.widget.auto_commit = False self.widget.set_data(zoo) - for i, (op, _) in enumerate(OWSelectRows.Operators[StringVariable]): + for i, (op, *_) in enumerate(OWSelectRows.Operators[StringVariable]): self.widget.remove_all() self.widget.add_row(zoo.domain.metas[0], i, SFValues[op]) self.widget.conditions_changed() - self.widget.unconditional_commit() + self.widget.commit.now() def test_filter_disc(self): lenses = Table(test_filename("datasets/lenses.tab")) @@ -117,7 +121,7 @@ def test_filter_disc(self): self.widget.remove_all() self.widget.add_row(0, i, DFValues[op]) self.widget.conditions_changed() - self.widget.unconditional_commit() + self.widget.commit.now() # discrete var in metas lenses = Table.from_table( @@ -128,29 +132,101 @@ def test_filter_disc(self): self.widget.remove_all() self.widget.add_row(lenses.domain.metas[0], i, DFValues[op]) self.widget.conditions_changed() - self.widget.unconditional_commit() + self.widget.commit.now() def test_filter_time(self): data = Table(test_filename("datasets/cyber-security-breaches.tab")) self.widget.auto_commit = False self.widget.set_data(data) - for i, (op, _) in enumerate(OWSelectRows.Operators[TimeVariable]): + for i, (op, *_) in enumerate(OWSelectRows.Operators[TimeVariable]): self.widget.remove_all() self.widget.add_row(data.domain["breach_start"], i, TFValues[op]) self.widget.conditions_changed() - self.widget.unconditional_commit() + self.widget.commit.now() # time var in metas data = Table.from_table( Domain([], metas=[data.domain["breach_start"]]), data ) self.widget.set_data(data) - for i, (op, _) in enumerate(OWSelectRows.Operators[TimeVariable]): + for i, (op, *_) in enumerate(OWSelectRows.Operators[TimeVariable]): self.widget.remove_all() self.widget.add_row(data.domain.metas[0], i, TFValues[op]) self.widget.conditions_changed() - self.widget.unconditional_commit() + self.widget.commit.now() + + self.widget.remove_all() + self.enterFilter("breach_start", "equals", QDate(2014, 6, 2)) + + container = self.widget.cond_list.cellWidget(0, 2) + date_widget = container.findChild(DateTimeWidget) + self.assertIsNotNone(date_widget) + + fmt = date_widget.format # Tuple: (have_date, have_time) + + # Test with QDate if the widget supports date + if fmt[0]: + test_date = QDate(2014, 6, 2) + date_widget.set_datetime(test_date) + self.assertEqual(date_widget.date(), test_date) + + # Test with QTime if the widget supports time + if fmt[1]: + test_time = QTime(16, 0) + date_widget.set_datetime(test_time) + self.assertEqual(date_widget.time(), test_time) + + # Test with QDateTime if the widget supports both + if fmt == (1, 1): + test_dt = QDateTime(QDate(2014, 6, 2), QTime(16, 0)) + date_widget.set_datetime(test_dt) + self.assertEqual(date_widget.dateTime(), test_dt) + + def test_set_datetime_with_qdatetime(self): + dt = QDateTime.fromString("2024-01-01T12:00:00", Qt.ISODate) + dt.setTimeSpec(Qt.UTC) + column = np.array([dt.toSecsSinceEpoch()]) + dtw = DateTimeWidget(None, column, (1, 1)) + dtw.set_datetime(dt) + self.assertEqual(dtw.dateTime().time().hour(), 12) + self.assertEqual(dtw.dateTime().date(), QDate(2024, 1, 1)) + + def test_set_datetime_with_qdate(self): + dt = QDateTime(QDate(2024, 1, 1), QTime(0, 0), Qt.UTC) + timestamp = dt.toSecsSinceEpoch() + column = np.array([timestamp]) + dtw = DateTimeWidget(None, column, (1, 0)) + dtw.set_datetime(QDate(2024, 1, 1)) + self.assertEqual(dtw.date(), QDate(2024, 1, 1)) + + def test_set_datetime_with_qtime(self): + # Use a fixed day (2000-01-01) and desired time + dt = QDateTime(QDate(2000, 1, 1), QTime(12, 0), Qt.UTC) + timestamp = dt.toSecsSinceEpoch() + column = np.array([timestamp]) + dtw = DateTimeWidget(None, column, (0, 1)) + dtw.set_datetime(QTime(12, 0)) + self.assertEqual(dtw.time(), QTime(12, 0)) + + def test_set_datetime_with_qtime_when_have_both(self): + dt = QDateTime.fromString("2024-01-01T12:00:00", Qt.ISODate) + dt.setTimeSpec(Qt.UTC) + column = np.array([dt.toSecsSinceEpoch()]) + dtw = DateTimeWidget(None, column, (1, 1)) # fecha y hora + dtw.set_datetime(QTime(12, 0)) + self.assertEqual(dtw.time(), QTime(12, 0)) + + def test_set_datetime_sets_date_with_qdate(self): + dt = QDateTime.fromString("2024-01-01T12:00:00", Qt.ISODate) + dt.setTimeSpec(Qt.UTC) + column = np.array([dt.toSecsSinceEpoch()]) + + dtw = DateTimeWidget(None, column, (1, 1)) # tiene fecha y hora + test_date = QDate(2024, 1, 1) + + dtw.set_datetime(test_date) + self.assertEqual(dtw.date(), test_date) @override_locale(QLocale.C) # Locale with decimal point def test_continuous_filter_with_c_locale(self): @@ -166,6 +242,20 @@ def test_continuous_filter_with_c_locale(self): self.widget.remove_all_button.click() self.enterFilter(iris.domain[2], "is below", "5,2") self.assertEqual(self.widget.conditions[0][2], ("52",)) + + def test_set_datetime_sets_time_when_only_time_enabled(self): + # We prepare a QDateTime with only time (the date is irrelevant) + dt = QDateTime.fromString("2000-01-01T12:00:00", Qt.ISODate) + dt.setTimeSpec(Qt.UTC) + column = np.array([dt.toSecsSinceEpoch()]) + + # We create a widget that has only time, not date. + dtw = DateTimeWidget(None, column, (0, 1)) # have_date=False, have_time=True + + test_time = QTime(12, 0) + dtw.set_datetime(test_time) + + self.assertEqual(dtw.time(), test_time) @override_locale(QLocale.Slovenian) # Locale with decimal comma def test_continuous_filter_with_sl_SI_locale(self): @@ -310,37 +400,6 @@ def test_partial_match_values(self): self.assertEqual(condition[1], 2) self.assertEqual(condition[2], (2, )) # index of value + 1 - def test_backward_compat_match_values(self): - iris = Table("iris") - domain = iris.domain - class_var = domain.class_var - self.widget = self.widget_with_context( - domain, [[class_var.name, 1, 2, (1, 2)]]) - - new_class_var = DiscreteVariable(class_var.name, class_var.values[1:]) - new_domain = Domain(domain.attributes, new_class_var) - non0 = iris.Y != 0 - iris2 = Table.from_numpy(new_domain, iris.X[non0], iris.Y[non0] - 1) - self.send_signal(self.widget.Inputs.data, iris2) - condition = self.widget.conditions[0] - self.assertIs(condition[0], new_class_var) - self.assertEqual(condition[1], 2) - self.assertEqual(condition[2], (1, 2)) # index of value + 1 - - # reset to [0] if out of range - self.widget = self.widget_with_context( - domain, [[class_var.name, 1, 2, (1, 3)]]) - - new_class_var = DiscreteVariable(class_var.name, class_var.values[1:]) - new_domain = Domain(domain.attributes, new_class_var) - non0 = iris.Y != 0 - iris2 = Table.from_numpy(new_domain, iris.X[non0], iris.Y[non0] - 1) - self.send_signal(self.widget.Inputs.data, iris2) - condition = self.widget.conditions[0] - self.assertIs(condition[0], new_class_var) - self.assertEqual(condition[1], 2) - self.assertEqual(condition[2], (0, )) # index of value + 1 - @override_locale(QLocale.C) def test_partial_matches_with_missing_vars(self): iris = Table("iris") @@ -387,9 +446,10 @@ def test_is_defined_on_continuous_variable(self): self.enterFilter(data.domain["c2"], "is defined") self.assertFalse(self.widget.Error.parsing_error.is_shown()) - self.assertEqual(len(self.get_output("Matching Data")), 3) - self.assertEqual(len(self.get_output("Unmatched Data")), 1) - self.assertEqual(len(self.get_output("Data")), len(data)) + outputs = self.widget.Outputs + self.assertEqual(len(self.get_output(outputs.matching_data)), 3) + self.assertEqual(len(self.get_output(outputs.unmatched_data)), 1) + self.assertEqual(len(self.get_output(outputs.annotated_data)), len(data)) # Test saving of settings self.widget.settingsHandler.pack_data(self.widget) @@ -404,14 +464,15 @@ def test_output_filter(self): self.send_signal(self.widget.Inputs.data, data) self.enterFilter(data.domain[0], "is below", "-1") - self.assertIsNone(self.get_output("Matching Data")) - self.assertEqual(len(self.get_output("Unmatched Data")), len_data) - self.assertEqual(len(self.get_output("Data")), len_data) + outputs = self.widget.Outputs + self.assertIsNone(self.get_output(outputs.matching_data)) + self.assertEqual(len(self.get_output(outputs.unmatched_data)), len_data) + self.assertEqual(len(self.get_output(outputs.annotated_data)), len_data) self.widget.remove_all_button.click() self.enterFilter(data.domain[0], "is below", "10") - self.assertIsNone(self.get_output("Unmatched Data")) - self.assertEqual(len(self.get_output("Matching Data")), len_data) - self.assertEqual(len(self.get_output("Data")), len_data) + self.assertIsNone(self.get_output(outputs.unmatched_data)) + self.assertEqual(len(self.get_output(outputs.matching_data)), len_data) + self.assertEqual(len(self.get_output(outputs.annotated_data)), len_data) def test_annotated_data(self): iris = Table("iris") @@ -421,7 +482,7 @@ def test_annotated_data(self): annotated = self.get_output(self.widget.Outputs.annotated_data) self.assertEqual(len(annotated), 150) - annotations = annotated.get_column_view(ANNOTATED_DATA_FEATURE_NAME)[0] + annotations = annotated.get_column(ANNOTATED_DATA_FEATURE_NAME) np.testing.assert_equal(annotations[:50], True) np.testing.assert_equal(annotations[50:], False) @@ -472,14 +533,15 @@ def test_calendar_dates(self): # first displayed date is min date self.assertEqual(value_combo.date(), QDate(2014, 1, 23)) - self.assertEqual(len(self.get_output("Matching Data")), 691) + outputs = self.widget.Outputs + self.assertEqual(len(self.get_output(outputs.matching_data)), 691) self.widget.remove_all_button.click() self.enterFilter("Date_Posted_or_Updated", "is below", QDate(2014, 4, 17)) - self.assertEqual(len(self.get_output("Matching Data")), 840) + self.assertEqual(len(self.get_output(outputs.matching_data)), 840) self.enterFilter("Date_Posted_or_Updated", "is greater than", QDate(2014, 6, 30)) - self.assertIsNone(self.get_output("Matching Data")) + self.assertIsNone(self.get_output(outputs.matching_data)) self.widget.remove_all_button.click() # date is in range min-max date self.enterFilter("Date_Posted_or_Updated", "equals", QDate(2013, 1, 1)) @@ -495,7 +557,7 @@ def test_calendar_dates(self): self.widget.remove_all_button.click() self.enterFilter("Date_Posted_or_Updated", "is between", QDate(2014, 4, 17), QDate(2014, 4, 30)) - self.assertEqual(len(self.get_output("Matching Data")), 58) + self.assertEqual(len(self.get_output(outputs.matching_data)), 58) @patch.object(owselectrows.QMessageBox, "question", return_value=owselectrows.QMessageBox.Ok) @@ -532,43 +594,15 @@ def test_report(self, _): self.enterFilter(zoo.domain[1], "is one of") self.widget.send_report() # don't crash - # Uncomment this on 2022/2/2 - # - # def test_migration_to_version_1(self): - # iris = Table("iris") - # - # ch = SelectRowsContextHandler() - # context = ch.new_context(iris.domain, *ch.encode_domain(iris.domain)) - # context.values = dict(conditions=[["petal length", 2, (5.2,)]]) - # settings = dict(context_settings=[context]) - # widget = self.create_widget(OWSelectRows, settings) - # self.assertEqual(widget.conditions, []) - - @override_locale(QLocale.C) - def test_support_old_settings(self): + def test_migration_to_version_1(self): iris = Table("iris") - self.widget = self.widget_with_context( - iris.domain, [["sepal length", 2, ("5.2",)]]) - self.send_signal(self.widget.Inputs.data, iris) - condition = self.widget.conditions[0] - self.assertEqual(condition[0], iris.domain["sepal length"]) - self.assertEqual(condition[1], 2) - self.assertTrue(condition[2][0].startswith("5.2")) - - def test_end_support_for_version_1(self): - if time.gmtime() >= (2022, 2, 2): - self.fail(""" -Happy 22/2/2! - -Now remove support for version==None settings in -SelectRowsContextHandler.decode_setting and SelectRowsContextHandler.match, -and uncomment OWSelectRows.migrate. -In tests, uncomment test_migration_to_version_1, -and remove test_support_old_settings and this test. - -Basically, revert this commit. -""") + ch = SelectRowsContextHandler() + context = ch.new_context(iris.domain, *ch.encode_domain(iris.domain)) + context.values = dict(conditions=[["petal length", 2, (5.2,)]]) + settings = dict(context_settings=[context]) + widget = self.create_widget(OWSelectRows, settings) + self.assertEqual(widget.conditions, []) def test_purge_discretized(self): housing = Table("housing") @@ -577,7 +611,9 @@ def test_purge_discretized(self): discretize_class=True, method=method) domain = discretizer(housing) data = housing.transform(domain) - widget = self.widget_with_context(domain, [["MEDV", 101, 2, (2, 3)]]) + widget = self.widget_with_context( + domain, [["MEDV", 101, 2, domain.class_var.values[1:]]] + ) widget.purge_classes = True self.send_signal(widget.Inputs.data, data) out = self.get_output(widget.Outputs.matching_data) @@ -615,6 +651,32 @@ def test_meta_setting(self): self.send_signal(self.widget.Inputs.data, data) self.assertListEqual([c[0] for c in self.widget.conditions], vars_) + def test_one_of_click(self): + """Test items checked in is one of dropdown""" + zoo = Table("zoo") + self.send_signal(self.widget.Inputs.data, zoo) + self.widget.remove_all_button.click() + self.enterFilter(zoo.domain[1], "is one of") + model = self.widget.cond_list.cellWidget(0, 2).popup.list_view.model() + + output = self.get_output(self.widget.Outputs.matching_data) + self.assertEqual(len(zoo), len(output)) + + # check second item (group 1) - only 20 elements in this group + model.item(1).setCheckState(Qt.Checked) + output = self.get_output(self.widget.Outputs.matching_data) + self.assertEqual(20, len(output)) + + # check first item (group 0) - now all elements should be at the output + model.item(0).setCheckState(Qt.Checked) + output = self.get_output(self.widget.Outputs.matching_data) + self.assertEqual(len(zoo), len(output)) + + # uncheck second element (group 1) - only elements fo group 0 at output + model.item(1).setCheckState(Qt.Unchecked) + output = self.get_output(self.widget.Outputs.matching_data) + self.assertEqual(81, len(output)) + def widget_with_context(self, domain, conditions): ch = SelectRowsContextHandler() context = ch.new_context(domain, *ch.encode_domain(domain)) @@ -669,3 +731,7 @@ def __set_value(widget, value): widget.setDate(value) else: raise ValueError("Unsupported widget {}".format(widget)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/data/tests/test_owsplit.py b/Orange/widgets/data/tests/test_owsplit.py new file mode 100644 index 00000000000..0b5a93fcabd --- /dev/null +++ b/Orange/widgets/data/tests/test_owsplit.py @@ -0,0 +1,328 @@ +# pylint: disable=missing-docstring,unsubscriptable-object +import os +import unittest + +import numpy as np + +from Orange.data import Table, StringVariable, Domain, DiscreteVariable +from Orange.widgets.tests.base import WidgetTest + +from Orange.widgets.data.owsplit import \ + OWSplit, SplitColumnOneHot, get_substrings, OneHotStrings, \ + DiscreteEncoding, SplitColumnCounts, CountStrings + + +class TestComputation(unittest.TestCase): + def setUp(self): + domain = Domain( + [ + DiscreteVariable("x", values=("a c d c bb bb bb", "bb d")) + ], + None, + [ + StringVariable("foo"), + StringVariable("bar") + ]) + self.data = Table.from_numpy( + domain, + np.array([[1], [0], [np.nan]]), None, + [["a,bbb,d,a,a", "e;f o"], ["", "f o"], ["bbb,d,bbb", "e;a;o"]] + ) + + +class TestSplitColumn(TestComputation): + def test_get_string_values(self): + np.testing.assert_equal( + set(get_substrings({"a bc", "d,e", "", "f,a t", "t"}, " ")), + {"a", "bc", "d,e", "f,a", "t"}) + np.testing.assert_equal( + set(get_substrings({"a bc", "d,e", "", "f,a t", "t"}, ",")), + {"a bc", "d", "e", "f", "a t", "t"}) + + def test_split_column_one_hot(self): + sc = SplitColumnOneHot(self.data, self.data.domain.metas[0], ",") + shared = sc(self.data) + self.assertEqual(set(sc.new_values), {"a", "bbb", "d"}) + self.assertEqual(set(shared), set(sc.new_values)) + np.testing.assert_equal(shared["a"], [0]) + np.testing.assert_equal(shared["bbb"], [0, 2]) + np.testing.assert_equal(shared["d"], [0, 2]) + + sc = SplitColumnOneHot(self.data, self.data.domain.metas[1], ";") + shared = sc(self.data) + self.assertEqual(set(sc.new_values), {"a", "e", "f o", "o"}) + self.assertEqual(set(shared), set(sc.new_values)) + np.testing.assert_equal(shared["a"], [2]) + np.testing.assert_equal(shared["e"], [0, 2]) + np.testing.assert_equal(shared["f o"], [0, 1]) + np.testing.assert_equal(shared["o"], [2]) + + def test_split_column_counts(self): + sc = SplitColumnCounts(self.data, self.data.domain.metas[0], ",") + shared = sc(self.data) + self.assertEqual(set(sc.new_values), {"a", "bbb", "d"}) + self.assertEqual(set(shared), set(sc.new_values)) + np.testing.assert_equal(shared["a"], [3, 0, 0]) + np.testing.assert_equal(shared["bbb"], [1, 0, 2]) + np.testing.assert_equal(shared["d"], [1, 0, 1]) + + def test_no_known_values(self): + sc = SplitColumnOneHot(self.data, self.data.domain.metas[0], ",") + data = Table.from_numpy( + self.data.domain, np.zeros((3, 1)), None, + np.array([["x"] * 2] * 3)) + shared = sc(data) + for attr in ("a", "bbb", "d"): + self.assertEqual(shared[attr].size, 0) + oh = OneHotStrings(sc, attr) + np.testing.assert_equal(oh(data), [0, 0, 0]) + +class TestStringEncoding(TestComputation): + def test_one_hot_strings(self): + attr = self.data.domain.metas[0] + sc = SplitColumnOneHot(self.data, attr, ",") + + oh = OneHotStrings(sc, "a") + np.testing.assert_equal(oh(self.data), [1, 0, 0]) + + oh = OneHotStrings(sc, "bbb") + np.testing.assert_equal(oh(self.data), [1, 0, 1]) + + data = Table.from_numpy( + Domain([], None, [attr]), + np.zeros((5, 0)), None, + np.array(["bbb,x,y", "", "bbb", "bbb,a", "foo"])[:, None]) + np.testing.assert_equal(oh(data), [1, 0, 1, 1, 0]) + + def test_count_strings(self): + attr = self.data.domain.metas[0] + sc = SplitColumnCounts(self.data, attr, ",") + + oh = CountStrings(sc, "a") + np.testing.assert_equal(oh(self.data), [3, 0, 0]) + + oh = CountStrings(sc, "bbb") + np.testing.assert_equal(oh(self.data), [1, 0, 2]) + + oh = CountStrings(sc, "d") + np.testing.assert_equal(oh(self.data), [1, 0, 1]) + + +class TestDiscreteEncoding(TestComputation): + def test_one_hot_discrete(self): + attr = self.data.domain.attributes[0] + + oh = DiscreteEncoding(attr, " ", True, "a") + np.testing.assert_equal(oh(self.data), [0, 1, np.nan]) + + oh = DiscreteEncoding(attr, " ", True, "d") + np.testing.assert_equal(oh(self.data), [1, 1, np.nan]) + + data = Table.from_numpy( + Domain([attr], None), + np.array([1, 0, 1, 0, np.nan])[:, None]) + + oh = DiscreteEncoding(attr, " ", True, "a") + np.testing.assert_equal(oh(data), [0, 1, 0, 1, np.nan]) + + oh = DiscreteEncoding(attr, " ", True, "d") + np.testing.assert_equal(oh(data), [1, 1, 1, 1, np.nan]) + + def test_discrete_counts(self): + attr = self.data.domain.attributes[0] + + oh = DiscreteEncoding(attr, " ", False, "a") + np.testing.assert_equal(oh(self.data), [0, 1, np.nan]) + oh = DiscreteEncoding(attr, " ", False, "bb") + np.testing.assert_equal(oh(self.data), [1, 3, np.nan]) + with self.data.unlocked(): + self.data.X[2, 0] = 0 + np.testing.assert_equal(oh(self.data), [1, 3, 3]) + + def test_discrete_metas(self): + attr = DiscreteVariable("x", values=("a c d", "bb d")) + domain = Domain([], None, [attr]) + data = Table.from_numpy(domain, np.zeros((3, 0)), None, + np.array([1, 0, np.nan])[:, None]) + oh = DiscreteEncoding(attr, " ", True, "a") + np.testing.assert_equal(oh(data), [0, 1, np.nan]) + + + +class TestOWSplit(WidgetTest): + def setUp(self): + self.widget = self.create_widget(OWSplit) + test_path = os.path.dirname(os.path.abspath(__file__)) + self.data = Table.from_file(os.path.join(test_path, "orange-in-education.tab")) + self._create_simple_corpus() + + def _set_attr(self, attr, widget=None): + if widget is None: + widget = self.widget + attr_combo = widget.controls.attribute + idx = attr_combo.model().indexOf(attr) + attr_combo.setCurrentIndex(idx) + attr_combo.activated.emit(idx) + + def _create_simple_corpus(self) -> None: + """ + Create a simple dataset with 4 documents. + """ + metas = np.array( + [ + ["foo,"], + ["bar,baz , bar, bar"], + ["foo,bar, foo"], + [""], + ] + ) + text_var = StringVariable("foo") + domain = Domain([], metas=[text_var]) + self.small_table = Table.from_numpy( + domain, + X=np.empty((len(metas), 0)), + metas=metas, + ) + + def test_data(self): + """Basic functionality""" + self.send_signal(self.widget.Inputs.data, self.data) + self._set_attr(self.data.domain.attributes[1]) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(len(output.domain.attributes), + len(self.data.domain.attributes) + 3) + self.assertTrue("in-class, in hands-on workshops" in output.domain + and "in-class, in lectures" in output.domain and + "outside the classroom" in output.domain) + np.testing.assert_array_equal(output[:10, "in-class, in hands-on " + "workshops"], + np.array([0, 0, 1, 0, 1, 1, 0, 1, 0, 0] + ).reshape(-1, 1)) + np.testing.assert_array_equal(output[:10, "in-class, in lectures"], + np.array([0, 1, 0, 0, 1, 0, 1, 1, 1, 0] + ).reshape(-1, 1)) + np.testing.assert_array_equal(output[:10, "outside the classroom"], + np.array([1, 0, 1, 1, 1, 0, 0, 1, 1, 1] + ).reshape(-1, 1)) + def test_empty_data(self): + """Do not crash on empty data""" + self.send_signal(self.widget.Inputs.data, None) + + def test_discrete(self): + """No crash on data attributes of different types""" + self.send_signal(self.widget.Inputs.data, self.data) + self.assertEqual(self.widget.attribute, self.data.domain.metas[1]) + self._set_attr(self.data.domain.attributes[1]) + self.assertEqual(self.widget.attribute, self.data.domain.attributes[1]) + + def test_numeric_only(self): + """Error raised when only numeric variables given""" + housing = Table.from_file("housing") + self.send_signal(self.widget.Inputs.data, housing) + self.assertTrue(self.widget.Warning.no_disc.is_shown()) + + def test_split_nonexisting(self): + """Test splitting when delimiter doesn't exist""" + self.widget.delimiter = "|" + self.send_signal(self.widget.Inputs.data, self.data) + new_cols = set(self.data.get_column("Country")) + self.assertFalse(any(self.widget.delimiter in v for v in new_cols)) + self.assertEqual(len(self.get_output( + self.widget.Outputs.data).domain.attributes), + len(self.data.domain.attributes) + len(new_cols)) + + def test_output_string(self): + "Test outputs; at the same time, test for duplicate variables" + self.widget.delimiter = "," + self.send_signal(self.widget.Inputs.data, self.small_table) + out = self.get_output(self.widget.Outputs.data) + self.assertEqual([attr.name for attr in out.domain.attributes], + ["bar", "baz", "foo (1)"]) + np.testing.assert_equal(out.X, + [[0, 0, 1], + [1, 1, 0], + [1, 0, 1], + [0, 0, 0]]) + + def test_output_discrete(self): + w = self.widget + w.delimiter = " " + w.output_type = w.Categorical + + attr = DiscreteVariable( + "x", + values=("bar foo bar bar foo foo foo", "bar baz", "crux crux")) + data = Table.from_numpy( + Domain([attr], None), + np.array([1, 1, 0, 1, 2, np.nan])[:, None], None) + + counts = np.array([[1, 1, 0, 0], + [1, 1, 0, 0], + [3, 0, 0, 4], + [1, 1, 0, 0], + [0, 0, 2, 0], + [np.nan, np.nan, np.nan, np.nan]]) + exp_hot = np.hstack((data.X, np.vstack((counts[:-1] > 0, [[np.nan] * 4])))) + + self.send_signal(w.Inputs.data, data) + out = self.get_output(w.Outputs.data) + self.assertEqual([attr.name for attr in out.domain.attributes], + ["x", "bar", "baz", "crux", "foo"]) + for attr in out.domain.attributes[1:]: + self.assertTrue(attr.is_discrete) + self.assertEqual(attr.values, ("No", "Yes")) + np.testing.assert_equal(out.X, exp_hot) + + w.controls.output_type.buttons[w.Numerical].click() + out = self.get_output(w.Outputs.data) + self.assertEqual([attr.name for attr in out.domain.attributes], + ["x", "bar", "baz", "crux", "foo"]) + for attr in out.domain.attributes[1:]: + self.assertTrue(attr.is_continuous) + np.testing.assert_equal(out.X, exp_hot) + + w.controls.output_type.buttons[w.Counts].click() + out = self.get_output(w.Outputs.data) + self.assertEqual([attr.name for attr in out.domain.attributes], + ["x", "bar", "baz", "crux", "foo"]) + for attr in out.domain.attributes[1:]: + self.assertTrue(attr.is_continuous) + np.testing.assert_equal( + out.X, + np.hstack((data.X, np.vstack((counts[:-1], [[np.nan] * 4]))))) + + def test_output_types_string(self): + w = self.widget + w.delimiter = "," + w.output_type = w.Categorical + + self.send_signal(w.Inputs.data, self.small_table) + counts = np.array([[0, 0, 1], [3, 1, 0], [1, 0, 2], [0, 0, 0]]) + + out = self.get_output(w.Outputs.data) + self.assertEqual([attr.name for attr in out.domain.attributes], + ["bar", "baz", "foo (1)"]) + for attr in out.domain.attributes: + self.assertTrue(attr.is_discrete) + self.assertEqual(attr.values, ("No", "Yes")) + np.testing.assert_equal(out.X, counts > 0) + + w.controls.output_type.buttons[w.Numerical].click() + out = self.get_output(w.Outputs.data) + self.assertEqual([attr.name for attr in out.domain.attributes], + ["bar", "baz", "foo (1)"]) + for attr in out.domain.attributes: + self.assertTrue(attr.is_continuous) + np.testing.assert_equal(out.X, counts > 0) + + w.controls.output_type.buttons[w.Counts].click() + out = self.get_output(w.Outputs.data) + self.assertEqual([attr.name for attr in out.domain.attributes], + ["bar", "baz", "foo (1)"]) + for attr in out.domain.attributes: + self.assertTrue(attr.is_continuous) + np.testing.assert_equal(out.X, counts) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/data/tests/test_owsql.py b/Orange/widgets/data/tests/test_owsql.py index 546a3b95958..72f65eab514 100644 --- a/Orange/widgets/data/tests/test_owsql.py +++ b/Orange/widgets/data/tests/test_owsql.py @@ -9,6 +9,17 @@ from Orange.widgets.tests.base import WidgetTest, simulate from Orange.tests.sql.base import DataBaseTest as dbt +mock_msgbox = mock.MagicMock() +mock_msgbox().addButton.return_value = "NO" +mock_msgbox().clickedButton.return_value = "NO" + + +def mock_sqltable(*args, **_): + table = Table(args[1]) + table.get_domain = lambda **_: table.domain + table.download_data = lambda *_: 1 + return table + class TestOWSqlConnected(WidgetTest, dbt): def setUpDB(self): @@ -28,7 +39,7 @@ def test_connection(self): self.assertFalse(self.widget.Error.connection.is_shown()) self.assertIsNotNone(self.widget.database_desc) - tables = ["Select a table", "Custom SQL"] + tables = ["Select a table"] self.assertTrue(set(self.widget.tables).issuperset(set(tables))) @dbt.run_on(["postgres"]) @@ -62,42 +73,6 @@ def set_connection_params(self): class TestOWSql(WidgetTest): - - @mock.patch('Orange.widgets.data.owsql.Backend') - def test_missing_extension(self, mock_backends): - """Test for correctly handled missing backend extension""" - backend = mock.Mock() - backend().display_name = "PostgreSQL" - backend().missing_extension = ["missing extension"] - backend().list_tables.return_value = [] - mock_backends.available_backends.return_value = [backend] - - settings = {"host": "host", "port": "port", - "database": "DB", "schema": "", - "username": "username", "password": "password"} - widget = self.create_widget(OWSql, stored_settings=settings) - - self.assertTrue(widget.Warning.missing_extension.is_shown()) - self.assertTrue(widget.download) - self.assertFalse(widget.downloadcb.isEnabled()) - - @mock.patch('Orange.widgets.data.owsql.Backend') - def test_non_postgres(self, mock_backends): - """Test if download is enforced for non postgres backends""" - backend = mock.Mock() - backend().display_name = "database" - del backend().missing_extension - backend().list_tables.return_value = [] - mock_backends.available_backends.return_value = [backend] - - settings = {"host": "host", "port": "port", - "database": "DB", "schema": "", - "username": "username", "password": "password"} - widget = self.create_widget(OWSql, stored_settings=settings) - - self.assertTrue(widget.download) - self.assertFalse(widget.downloadcb.isEnabled()) - @mock.patch('Orange.widgets.data.owsql.Table', mock.PropertyMock(return_value=Table('iris'))) @mock.patch('Orange.widgets.data.owsql.SqlTable') @@ -108,8 +83,8 @@ def test_restore_table(self, mock_backends, mock_sqltable): backend().display_name = "database" del backend().missing_extension backend().list_tables.return_value = ["a", "b", "c"] + backend().n_tables.return_value = 3 mock_backends.available_backends.return_value = [backend] - mock_sqltable().approx_len.return_value = 100 settings = {"host": "host", "port": "port", "database": "DB", "schema": "", "username": "username", @@ -144,6 +119,93 @@ def test_selected_backend(self, mocked_backends: mock.Mock): widget = self.create_widget(OWSql, stored_settings=settings) self.assertEqual(widget.backendcombo.currentText(), "") + @mock.patch('Orange.widgets.data.owsql.Backend') + def test_data_source(self, mocked_backends: mock.Mock): + widget: OWSql = self.create_widget(OWSql) + widget.controls.data_source.buttons[OWSql.CUSTOM_SQL].click() + + backend = mock.Mock() + backend().display_name = "Dummy Backend" + backend().list_tables.return_value = ["a", "b", "c"] + backend().n_tables.return_value = 3 + mocked_backends.available_backends.return_value = [backend] + + settings = {"selected_backend": "Dummy Backend", + "host": "host", "port": "port", "database": "DB", + "schema": "", "username": "username", + "password": "password"} + widget: OWSql = self.create_widget(OWSql, stored_settings=settings) + self.assertEqual(widget.tablecombo.currentText(), "Select a table") + self.assertFalse(widget.tablecombo.isHidden()) + self.assertTrue(widget.tabletext.isHidden()) + self.assertTrue(widget.custom_sql.isHidden()) + + widget.controls.data_source.buttons[OWSql.CUSTOM_SQL].click() + self.assertEqual(widget.tablecombo.currentText(), "Select a table") + self.assertFalse(widget.tablecombo.isHidden()) + self.assertTrue(widget.tabletext.isHidden()) + self.assertFalse(widget.custom_sql.isHidden()) + + widget.controls.data_source.buttons[OWSql.TABLE].click() + self.assertEqual(widget.tablecombo.currentText(), "Select a table") + self.assertFalse(widget.tablecombo.isHidden()) + self.assertTrue(widget.tabletext.isHidden()) + self.assertTrue(widget.custom_sql.isHidden()) + + @mock.patch('Orange.widgets.data.owsql.MAX_TABLES', 2) + @mock.patch('Orange.widgets.data.owsql.SqlTable', + mock.Mock(side_effect=mock_sqltable)) + @mock.patch('Orange.widgets.data.owsql.Backend') + def test_table_text(self, mocked_backends: mock.Mock): + backend = mock.Mock() + backend().display_name = "Dummy Backend" + backend().list_tables.return_value = ["iris", "zoo", "titanic"] + backend().n_tables.return_value = 3 + mocked_backends.available_backends.return_value = [backend] + + settings = {"selected_backend": "Dummy Backend", + "host": "host", "port": "port", "database": "DB", + "schema": "", "username": "username", + "password": "password"} + widget: OWSql = self.create_widget(OWSql, stored_settings=settings) + self.assertTrue(widget.tablecombo.isHidden()) + self.assertFalse(widget.tabletext.isHidden()) + widget.tabletext.setText("zoo") + widget.select_table() + output = self.get_output(widget.Outputs.data, widget=widget) + self.assertIsInstance(output, Table) + self.assertEqual(len(output), 101) + + @mock.patch('Orange.widgets.data.owsql.AUTO_DL_LIMIT', 120) + @mock.patch('Orange.widgets.data.owsql.is_postgres', + mock.Mock(return_value=True)) + @mock.patch('Orange.widgets.data.owsql.QMessageBox', mock_msgbox) + @mock.patch('Orange.widgets.data.owsql.SqlTable', + mock.Mock(side_effect=mock_sqltable)) + @mock.patch('Orange.widgets.data.owsql.Backend') + def test_auto_dl_limit(self, mocked_backends: mock.Mock): + backend = mock.Mock() + backend().display_name = "Dummy Backend" + backend().list_tables.return_value = ["iris", "zoo", "titanic"] + backend().n_tables.return_value = 3 + mocked_backends.available_backends.return_value = [backend] + + settings = {"selected_backend": "Dummy Backend", + "host": "host", "port": "port", "database": "DB", + "schema": "", "username": "username", + "password": "password"} + widget: OWSql = self.create_widget(OWSql, stored_settings=settings) + widget.tablecombo.setCurrentIndex(2) + widget.select_table() + output = self.get_output(widget.Outputs.data, widget=widget) + self.assertIsInstance(output, Table) + self.assertEqual(len(output), 101) + + widget.tablecombo.setCurrentIndex(1) + widget.select_table() + output = self.get_output(widget.Outputs.data, widget=widget) + self.assertIsNone(output) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owtable.py b/Orange/widgets/data/tests/test_owtable.py index 9c01ee2caa1..de6a987c406 100644 --- a/Orange/widgets/data/tests/test_owtable.py +++ b/Orange/widgets/data/tests/test_owtable.py @@ -1,113 +1,161 @@ # pylint: disable=protected-access import unittest +from unittest.mock import patch - -from unittest.mock import Mock, patch from AnyQt.QtCore import Qt from orangewidget.tests.utils import excepthook_catch -from orangewidget.widget import StateInfo -from Orange.widgets.data.owtable import OWDataTable +from Orange.widgets.data.owtable import OWTable from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin from Orange.data import Table, Domain -from Orange.widgets.utils.state_summary import format_summary_details from Orange.data.sql.table import SqlTable from Orange.tests.sql.base import DataBaseTest as dbt -class TestOWDataTable(WidgetTest, WidgetOutputsTestMixin, dbt): +class TestOWTable(WidgetTest, WidgetOutputsTestMixin): @classmethod def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls, output_all_on_no_selection=True) - cls.signal_name = "Data" + cls.signal_name = OWTable.Inputs.data cls.signal_data = cls.data # pylint: disable=no-member def setUp(self): - self.widget = self.create_widget(OWDataTable) - - def setUpDB(self): - # pylint: disable=attribute-defined-outside-init - conn, self.iris = self.create_iris_sql_table() - self.table = SqlTable(conn, self.iris) - - def tearDownDB(self): - self.drop_iris_sql_table() + super().setUp() + self.widget = self.create_widget(OWTable) def test_input_data(self): - """Check number of tabs with data on the input""" - self.send_signal(self.widget.Inputs.data, self.data, 1) - self.assertEqual(self.widget.tabs.count(), 1) - self.send_signal(self.widget.Inputs.data, self.data, 2) - self.assertEqual(self.widget.tabs.count(), 2) - self.send_signal(self.widget.Inputs.data, None, 1) - self.assertEqual(self.widget.tabs.count(), 1) + self.send_signal(self.widget.Inputs.data, self.data) + self.assertIs(self.widget.input.table, self.data) + self.assertIs(self.widget.view.model().source, self.data) + self.send_signal(self.widget.Inputs.data, None) + self.assertIsNone(self.widget.input) + self.assertIsNone(self.widget.view.model()) + + def test_input_data_empty(self): + self.send_signal(self.widget.Inputs.data, self.data[:0]) + output = self.get_output(self.widget.Outputs.annotated_data) + self.assertIsNone(output) + + def test_data_single_sparse(self): + data = self.data[:1].to_sparse() + self.send_signal(self.widget.Inputs.data, data) + self.assertIs(self.widget.input.table, data) + self.assertIs(self.widget.view.model().source, data) def test_data_model(self): - self.send_signal(self.widget.Inputs.data, self.data, 1) - self.assertEqual(self.widget.tabs.widget(0).model().rowCount(), - len(self.data)) + self.send_signal(self.widget.Inputs.data, self.data) + self.assertEqual(self.widget.view.model().rowCount(), len(self.data)) def test_reset_select(self): self.send_signal(self.widget.Inputs.data, self.data) self._select_data() self.send_signal(self.widget.Inputs.data, Table('heart_disease')) - self.assertListEqual([], self.widget.selected_cols) - self.assertListEqual([], self.widget.selected_rows) + self.assertListEqual([], self.widget.stored_selection["columns"]) + self.assertListEqual([], self.widget.stored_selection["rows"]) + + def test_clear_selection(self): + self.send_signal(self.widget.Inputs.data, self.data) + self.assertFalse(self.widget.clear_button.isEnabled()) + self._select_data() + self.assertTrue(self.widget.clear_button.isEnabled()) + self.widget.clear_button.click() + self.assertListEqual([], self.widget.stored_selection["columns"]) + self.assertListEqual([], self.widget.stored_selection["rows"]) + self.assertFalse(self.widget.clear_button.isEnabled()) def _select_data(self): - self.widget.selected_cols = list(range(len(self.data.domain.variables))) - self.widget.selected_rows = list(range(0, len(self.data.domain.variables), 10)) - self.widget.set_selection() - return self.widget.selected_rows + self.widget.set_selection( + list(range(0, len(self.data), 10)), + list(range(len(self.data.domain.variables))), + ) + return self.widget.stored_selection["rows"] def test_attrs_appear_in_corner_text(self): - iris = Table("iris") - domain = iris.domain + domain = self.data.domain new_domain = Domain( - domain.attributes[1:], iris.domain.class_var, domain.attributes[:1]) + domain.attributes[1:], domain.class_var, domain.attributes[:1]) new_domain.metas[0].attributes = {"c": "foo"} new_domain.attributes[0].attributes = {"a": "bar", "c": "baz"} new_domain.class_var.attributes = {"b": "foo"} - self.widget.set_corner_text = Mock() - self.send_signal(self.widget.Inputs.data, iris.transform(new_domain)) - # false positive, pylint: disable=unsubscriptable-object - self.assertEqual( - self.widget.set_corner_text.call_args[0][1], "\na\nb\nc") + self.send_signal(self.widget.Inputs.data, self.data.transform(new_domain)) + self.assertEqual(self.widget.view.cornerText(), "\na\nb\nc") def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_commit') as commit: + with patch.object(self.widget.commit, 'now') as commit: self.widget.auto_commit = False commit.reset_mock() self.send_signal(self.widget.Inputs.data, self.data) commit.assert_called() def test_pending_selection(self): - widget = self.create_widget(OWDataTable, stored_settings=dict( - selected_rows=[5, 6, 7, 8, 9], - selected_cols=list(range(len(self.data.domain.variables))))) - self.send_signal(widget.Inputs.data, None, 1) - self.send_signal(widget.Inputs.data, self.data, 1) + widget = self.create_widget(OWTable, stored_settings={ + "stored_selection": { + "rows": [5, 6, 7, 8, 9], + "columns": list(range(len(self.data.domain.variables))) + } + }) + self.send_signal(widget.Inputs.data, None) + self.send_signal(widget.Inputs.data, self.data) output = self.get_output(widget.Outputs.selected_data) self.assertEqual(5, len(output)) + def test_pending_sorted_selection(self): + rows = [5, 6, 7, 8, 9, 55, 56, 57, 58, 59] + widget = self.create_widget(OWTable, stored_settings={ + "stored_selection": { + "rows": rows, + "columns": list(range(len(self.data.domain.variables))) + }, + "stored_sort": [("sepal length", 1), ("sepal width", -1)] + }) + self.send_signal(widget.Inputs.data, None) + self.assertFalse(widget.restore_button.isEnabled()) + self.send_signal(widget.Inputs.data, self.data) + self.assertTrue(widget.restore_button.isEnabled()) + self.assertEqual(widget.view.horizontalHeader().sortIndicatorOrder(), + Qt.DescendingOrder) + self.assertEqual(widget.view.horizontalHeader().sortIndicatorSection(), 2) + output = self.get_output(widget.Outputs.selected_data) + self.assertEqual(len(rows), len(output)) + sepal_width = output.get_column("sepal width").tolist() + sepal_length = output.get_column("sepal length").tolist() + self.assertSequenceEqual(sepal_width, sorted(sepal_width, reverse=True)) + dd = list(zip(sepal_length, sepal_width)) + dd_sorted = sorted(dd, key=lambda t: t[0]) + dd_sorted = sorted(dd_sorted, key=lambda t: t[1], reverse=True) + self.assertSequenceEqual(dd, dd_sorted) + ids = self.data[rows].ids + self.assertSetEqual(set(output.ids), set(ids)) + + def test_missing_sort_column_shows_warning(self): + widget = self.create_widget(OWTable, stored_settings={ + "stored_sort": [("sepal length", 1), ("no such column", -1)] + }) + self.send_signal(widget.Inputs.data, self.data) + self.assertTrue(widget.Warning.missing_sort_columns.is_shown()) + self.send_signal(widget.Inputs.data, None) + self.assertFalse(widget.Warning.missing_sort_columns.is_shown()) + def test_sorting(self): self.send_signal(self.widget.Inputs.data, self.data) - self.widget.selected_rows = [0, 1, 2, 3, 4] - self.widget.selected_cols = list(range(len(self.data.domain.variables))) - self.widget.set_selection() - + self.widget.set_selection( + [0, 1, 2, 3, 4], + list(range(len(self.data.domain.variables))) + ) output = self.get_output(self.widget.Outputs.selected_data) - output, _ = output.get_column_view(0) + output = output.get_column(0) output_original = output.tolist() + self.assertFalse(self.widget.restore_button.isEnabled()) - self.widget.tabs.currentWidget().sortByColumn(1, Qt.AscendingOrder) - + self.widget.view.sortByColumn(1, Qt.AscendingOrder) + self.assertTrue(self.widget.restore_button.isEnabled()) + self.assertEqual(self.widget.stored_sort, [('sepal length', 1)]) output = self.get_output(self.widget.Outputs.selected_data) - output, _ = output.get_column_view(0) + output = output.get_column(0) output_sorted = output.tolist() # the two outputs should not be the same. @@ -117,36 +165,33 @@ def test_sorting(self): self.assertTrue(sorted(output_original) == output_sorted) self.assertTrue(sorted(output_sorted) == output_sorted) - def test_summary(self): - """Check if status bar is updated when data is received""" - info = self.widget.info - no_input, no_output = "No data on input", "No data on output" - - self.assertIsInstance(info._StateInfo__input_summary, StateInfo.Empty) - self.assertEqual(info._StateInfo__input_summary.details, no_input) - self.assertIsInstance(info._StateInfo__output_summary, StateInfo.Empty) - self.assertEqual(info._StateInfo__output_summary.details, no_output) - - data = Table("zoo") - self.send_signal(self.widget.Inputs.data, data, 1) - summary, details = f"{len(data)}", format_summary_details(data) - self.assertEqual(info._StateInfo__input_summary.brief, summary) - self.assertEqual(info._StateInfo__input_summary.details, details) - - data = Table("iris") - self.send_signal(self.widget.Inputs.data, data, 2) - summary, details = f"{len(data)}", format_summary_details(data) - self.assertEqual(info._StateInfo__input_summary.brief, summary) - self.assertEqual(info._StateInfo__input_summary.details, details) - - self.send_signal(self.widget.Inputs.data, None, 1) - summary, details = f"{len(data)}", format_summary_details(data) - self.assertEqual(info._StateInfo__input_summary.brief, summary) - self.assertEqual(info._StateInfo__input_summary.details, details) - - self.send_signal(self.widget.Inputs.data, None, 2) - self.assertIsInstance(info._StateInfo__input_summary, StateInfo.Empty) - self.assertEqual(info._StateInfo__input_summary.details, no_input) + self.widget.restore_order() + self.assertFalse(self.widget.restore_button.isEnabled()) + output = self.get_output(self.widget.Outputs.selected_data) + self.assertEqual(output.get_column(0).tolist(), output_original) + + # Check that output is the same with no sorting and cleared selection. + self.widget.set_selection([], []) + output = self.get_output(self.widget.Outputs.selected_data) + self.assertIs(output, self.data) + + def test_sort_basket_column(self): + data = self.data.to_sparse() + self.send_signal(self.widget.Inputs.data, data) + self.widget.view.sortByColumn(0, Qt.AscendingOrder) + self.assertEqual(self.widget.stored_sort, [("iris", 1)]) + self.widget.view.sortByColumn(1, Qt.AscendingOrder) + self.assertEqual(self.widget.stored_sort, + [("iris", 1), ("\\BASKET(FEATURES)", 1)]) + # test restore + w = self.create_widget(OWTable, stored_settings={ + "stored_sort": self.widget.stored_sort + }) + self.send_signal(w.Inputs.data, data) + self.assertEqual(w.stored_sort, self.widget.stored_sort) + output_a = self.get_output(self.widget.Outputs.selected_data) + output_b = self.get_output(w.Outputs.selected_data) + self.assertEqual(output_a.ids.tolist(), output_b.ids.tolist()) def test_info(self): info_text = self.widget.info_text @@ -155,8 +200,7 @@ def test_info(self): def test_show_distributions(self): w = self.widget - data = Table("heart_disease")[::3].copy() - self.send_signal(w.Inputs.data, data, 0) + self.send_signal(w.Inputs.data, self.data) # run through the delegate paint routines with excepthook_catch(): w.grab() @@ -168,6 +212,117 @@ def test_show_distributions(self): w.grab() w.controls.show_distributions.toggle() + def test_whole_rows(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data) + self.assertTrue(w.select_rows) # default value + with excepthook_catch(): + w.controls.select_rows.toggle() + self.assertFalse(w.select_rows) + w.set_selection([0, 1, 2, 3], [0, 1]) + out = self.get_output(w.Outputs.selected_data) + self.assertEqual(out.domain, + Domain([self.data.domain.attributes[0]], self.data.domain.class_var)) + with excepthook_catch(): + w.controls.select_rows.toggle() + out = self.get_output(w.Outputs.selected_data) + self.assertTrue(w.select_rows) + self.assertEqual(out.domain, self.data.domain) + + def test_show_attribute_labels(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data) + self.assertTrue(w.show_attribute_labels) # default value + with excepthook_catch(): + w.controls.show_attribute_labels.toggle() + self.assertFalse(w.show_attribute_labels) + + def test_subset_input(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data) + with patch.object(w.signalManager, "send") as m: + self.send_signal(w.Inputs.data_subset, self.data[[0, 1, 5]]) + m.assert_not_called() + w.view.grab() # cover delegate painting methods + + model = w.view.model() + self.assertTrue(model.index(0, 0).data(model.SubsetRole)) + self.assertFalse(model.index(2, 0).data(model.SubsetRole)) + self.assertTrue(model.headerData(0, Qt.Vertical, model.SubsetRole)) + self.assertFalse(model.headerData(2, Qt.Vertical, model.SubsetRole)) + + with patch.object(w.signalManager, "send") as m: + self.send_signal(w.Inputs.data_subset, None) + m.assert_not_called() + + w.view.grab() + + model = w.view.model() + self.assertFalse(model.index(0, 0).data(model.SubsetRole)) + self.assertFalse(model.headerData(0, Qt.Vertical, model.SubsetRole)) + + +class TestOWTableSQL(TestOWTable, dbt): + def setUpDB(self): + # pylint: disable=attribute-defined-outside-init + conn, iris = self.create_iris_sql_table() + data = SqlTable(conn, iris, inspect_values=True) + if self.current_db == "mssql": + # when loading data from mssql db, Sql widget returns Table (not SqlTable) + data = Table(data) + self.data = data.transform(Domain(data.domain.attributes[:-1], + data.domain.attributes[-1])) + + def tearDownDB(self): + self.drop_iris_sql_table() + + @dbt.run_on(["postgres", "mssql"]) + def test_input_data(self): + super().test_input_data() + + @unittest.skip("no data output") + def test_input_data_empty(self): + super().test_input_data_empty() + + def test_data_model(self): + super().test_data_model() + + @dbt.run_on(["postgres", "mssql"]) + def test_unconditional_commit_on_new_signal(self): + super().test_unconditional_commit_on_new_signal() + + @dbt.run_on(["postgres", "mssql"]) + def test_reset_select(self): + super().test_reset_select() + + @dbt.run_on(["postgres", "mssql"]) + def test_attrs_appear_in_corner_text(self): + super().test_attrs_appear_in_corner_text() + + @unittest.skip("no data output") + def test_pending_selection(self): + super().test_pending_selection() + + @unittest.skip("sorting not implemented") + def test_sorting(self): + super().test_sorting() + + @unittest.skip("does nothing") + def test_info(self): + super().test_info() + + @dbt.run_on(["postgres", "mssql"]) + def test_show_distributions(self): + super().test_show_distributions() + + @unittest.skip("no data output") + def test_whole_rows(self): + super().test_whole_rows() + + @dbt.run_on(["postgres", "mssql"]) + def test_show_attribute_labels(self): + super().test_show_distributions() + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owtransform.py b/Orange/widgets/data/tests/test_owtransform.py index fc9fdf37d10..ced41d0b604 100644 --- a/Orange/widgets/data/tests/test_owtransform.py +++ b/Orange/widgets/data/tests/test_owtransform.py @@ -23,49 +23,24 @@ def test_output(self): self.send_signal(self.widget.Inputs.template_data, self.disc_data) output = self.get_output(self.widget.Outputs.transformed_data) self.assertTableEqual(output, self.disc_data[::15]) - self.assertEqual("Input data with 10 instances and 4 features.", - self.widget.input_label.text()) - self.assertEqual("Template domain applied.", - self.widget.template_label.text()) - self.assertEqual("Output data includes 4 features.", - self.widget.output_label.text()) # remove template data self.send_signal(self.widget.Inputs.template_data, None) output = self.get_output(self.widget.Outputs.transformed_data) self.assertIsNone(output) - self.assertEqual("Input data with 10 instances and 4 features.", - self.widget.input_label.text()) - self.assertEqual("No template data on input.", - self.widget.template_label.text()) - self.assertEqual("", self.widget.output_label.text()) # send template data self.send_signal(self.widget.Inputs.template_data, self.disc_data) output = self.get_output(self.widget.Outputs.transformed_data) self.assertTableEqual(output, self.disc_data[::15]) - self.assertEqual("Input data with 10 instances and 4 features.", - self.widget.input_label.text()) - self.assertEqual("Template domain applied.", - self.widget.template_label.text()) - self.assertEqual("Output data includes 4 features.", - self.widget.output_label.text()) # remove data self.send_signal(self.widget.Inputs.data, None) output = self.get_output(self.widget.Outputs.transformed_data) self.assertIsNone(output) - self.assertEqual("No data on input.", self.widget.input_label.text()) - self.assertEqual("Template data includes 4 features.", - self.widget.template_label.text()) - self.assertEqual("", self.widget.output_label.text()) # remove template data self.send_signal(self.widget.Inputs.template_data, None) - self.assertEqual("No data on input.", self.widget.input_label.text()) - self.assertEqual("No template data on input.", - self.widget.template_label.text()) - self.assertEqual("", self.widget.output_label.text()) def assertTableEqual(self, table1, table2): self.assertIs(table1.domain, table2.domain) @@ -82,15 +57,15 @@ def test_input_pca_output(self): self.send_signal(self.widget.Inputs.data, self.data[::10]) self.send_signal(self.widget.Inputs.template_data, pca_out) output = self.get_output(self.widget.Outputs.transformed_data) - npt.assert_array_equal(pca_out.X[::10], output.X) + npt.assert_array_almost_equal(pca_out.X[::10], output.X) def test_error_transforming(self): data = self.data[::10] data.transform = Mock(side_effect=Exception()) self.send_signal(self.widget.Inputs.data, data) self.send_signal(self.widget.Inputs.template_data, self.disc_data) - self.assertTrue(self.widget.Error.error.is_shown()) output = self.get_output(self.widget.Outputs.transformed_data) + self.assertTrue(self.widget.Error.error.is_shown()) self.assertIsNone(output) self.send_signal(self.widget.Inputs.data, None) self.assertFalse(self.widget.Error.error.is_shown()) diff --git a/Orange/widgets/data/tests/test_owtranspose.py b/Orange/widgets/data/tests/test_owtranspose.py index 1d144a9f0ef..568855c87c4 100644 --- a/Orange/widgets/data/tests/test_owtranspose.py +++ b/Orange/widgets/data/tests/test_owtranspose.py @@ -20,21 +20,21 @@ def setUp(self): self.state.is_interruption_requested = Mock(return_value=False) def test_run(self): - result = run(self.zoo, "", "Feature", False, self.state) + result = run(self.zoo, "", "Feature name", "Feature", False, self.state) self.assert_table_equal(Table.transpose(self.zoo), result) def test_run_var(self): - result = run(self.zoo, "name", "Feature", False, self.state) + result = run(self.zoo, "name", "Feature name", "Feature", False, self.state) self.assert_table_equal(Table.transpose(self.zoo, "name"), result) def test_run_name(self): - result1 = run(self.zoo, "", "Foo", False, self.state) + result1 = run(self.zoo, "", "Feature name", "Foo", False, self.state) result2 = Table.transpose(self.zoo, feature_name="Foo") self.assert_table_equal(result1, result2) def test_run_callback(self): self.state.set_progress_value = Mock() - run(self.zoo, "", "Feature", False, self.state) + run(self.zoo, "", "Feature name", "Feature", False, self.state) self.state.set_progress_value.assert_called() @@ -74,7 +74,7 @@ def test_feature_type(self): # Test that the widget takes the correct column widget.feature_names_column = metas[4] - widget.apply() + widget.commit.now() output = self.get_output(widget.Outputs.data) self.assertTrue( all(a.name.startswith(metas[1].to_val(m)) @@ -83,7 +83,7 @@ def test_feature_type(self): # Switch to generic self.assertEqual(widget.DEFAULT_PREFIX, "Feature") widget.feature_type = widget.GENERIC - widget.apply() + widget.commit.now() output = self.get_output(widget.Outputs.data) self.assertTrue( all(x.name.startswith(widget.DEFAULT_PREFIX) @@ -91,14 +91,14 @@ def test_feature_type(self): # Check that the widget uses the supplied name widget.feature_name = "Foo" - widget.apply() + widget.commit.now() output = self.get_output(widget.Outputs.data) self.assertTrue( all(x.name.startswith("Foo ") for x in output.domain.attributes)) # Check that the widget uses default when name is not given widget.feature_name = "" - widget.apply() + widget.commit.now() output = self.get_output(widget.Outputs.data) self.assertTrue( all(x.name.startswith(widget.DEFAULT_PREFIX) @@ -136,7 +136,7 @@ def test_send_report(self): def test_gui_behaviour(self): widget = self.widget - widget.unconditional_apply = unittest.mock.Mock() + widget.commit.now = unittest.mock.Mock() # widget.apply must be called widget.auto_apply = False @@ -144,40 +144,40 @@ def test_gui_behaviour(self): # No data: type is generic, meta radio disabled self.assertEqual(widget.feature_type, widget.GENERIC) self.assertFalse(widget.meta_button.isEnabled()) - self.assertFalse(widget.unconditional_apply.called) + self.assertFalse(widget.commit.now.called) # Data with metas: default type is meta, radio enabled self.send_signal(widget.Inputs.data, self.zoo) self.assertTrue(widget.meta_button.isEnabled()) self.assertEqual(widget.feature_type, widget.FROM_VAR) self.assertIs(widget.feature_names_column, widget.feature_model[0]) - self.assertTrue(widget.unconditional_apply.called) + self.assertTrue(widget.commit.now.called) # Editing the line edit changes the radio button to generic - widget.unconditional_apply.reset_mock() + widget.commit.now.reset_mock() widget.controls.feature_name.editingFinished.emit() self.assertEqual(widget.feature_type, widget.GENERIC) - self.assertFalse(widget.unconditional_apply.called) + self.assertFalse(widget.commit.now.called) # Changing combo changes the radio button to meta - widget.unconditional_apply.reset_mock() + widget.commit.now.reset_mock() widget.feature_combo.activated.emit(0) self.assertEqual(widget.feature_type, widget.FROM_VAR) - self.assertFalse(widget.unconditional_apply.called) + self.assertFalse(widget.commit.now.called) - widget.apply = unittest.mock.Mock() + widget.commit.deferred = unittest.mock.Mock() # Editing the line edit changes the radio button to generic - widget.apply.reset_mock() + widget.commit.deferred.reset_mock() widget.controls.feature_name.editingFinished.emit() self.assertEqual(widget.feature_type, widget.GENERIC) - self.assertTrue(widget.apply.called) + self.assertTrue(widget.commit.deferred.called) # Changing combo changes the radio button to meta - widget.apply.reset_mock() + widget.commit.deferred.reset_mock() widget.feature_combo.activated.emit(0) self.assertEqual(widget.feature_type, widget.FROM_VAR) - self.assertTrue(widget.apply.called) + self.assertTrue(widget.commit.deferred.called) def test_all_whitespace(self): widget = self.widget @@ -210,12 +210,42 @@ def test_feature_names_from_cont_vars(self): self.assertTrue(self.widget.Warning.duplicate_names.is_shown()) def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_apply') as apply: + with patch.object(self.widget.commit, 'now') as apply: self.widget.auto_apply = False apply.reset_mock() self.send_signal(self.widget.Inputs.data, self.zoo) apply.assert_called() + def test_output_column_name(self): + widget = self.widget + self.send_signal(widget.Inputs.data, self.zoo) + + output = self.get_output(widget.Outputs.data) + self.assertEqual(output.domain.metas[0].name, "Column name") + + widget.output_column_name = "Custom name" + widget.commit.now() + output = self.get_output(widget.Outputs.data) + self.assertEqual(output.domain.metas[0].name, "Custom name") + + widget.output_column_name = "" + widget.commit.now() + output = self.get_output(widget.Outputs.data) + self.assertEqual(output.domain.metas[0].name, "Column name") + + def test_migration(self): + w = self.create_widget( + OWTranspose, + stored_settings={ + "__version__": 1, + "remove_redundant_inst": True, + }, + ) + self.send_signal(self.zoo) + self.assertEqual(w.feature_type, OWTranspose.GENERIC) + self.assertTrue(w.remove_redundant_inst) + self.assertEqual(w.output_column_name, "Feature name") + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owunique.py b/Orange/widgets/data/tests/test_owunique.py index 05af63ef7a6..1b4dd423476 100644 --- a/Orange/widgets/data/tests/test_owunique.py +++ b/Orange/widgets/data/tests/test_owunique.py @@ -71,22 +71,22 @@ def test_compute(self): w.selected_vars = w.var_model[:2] w.tiebreaker = "Last instance" - w.commit() + w.commit.now() out = self.get_output(w.Outputs.data) np.testing.assert_equal(out.Y, [2, 3, 4, 5]) w.tiebreaker = "First instance" - w.commit() + w.commit.now() out = self.get_output(w.Outputs.data) np.testing.assert_equal(out.Y, [0, 3, 4, 5]) w.tiebreaker = "Middle instance" - w.commit() + w.commit.now() out = self.get_output(w.Outputs.data) np.testing.assert_equal(out.Y, [1, 3, 4, 5]) w.tiebreaker = "Discard non-unique instances" - w.commit() + w.commit.now() out = self.get_output(w.Outputs.data) np.testing.assert_equal(out.Y, [3, 4, 5]) @@ -101,7 +101,7 @@ def test_use_all_when_non_selected(self): np.testing.assert_equal(out.X, data.X[2:]) w.selected_vars.clear() - w.unconditional_commit() + w.commit.now() out = self.get_output(w.Outputs.data) np.testing.assert_equal(out.X, data.X[2:]) diff --git a/Orange/widgets/data/utils/histogram.py b/Orange/widgets/data/utils/histogram.py index 7427a9527a1..6d27e9e0c66 100644 --- a/Orange/widgets/data/utils/histogram.py +++ b/Orange/widgets/data/utils/histogram.py @@ -123,7 +123,8 @@ class Histogram(QGraphicsWidget): """ def __init__(self, data, variable, parent=None, height=200, - width=300, side_padding=5, top_padding=20, bar_spacing=4, + width=300, side_padding=5, top_padding=20, bottom_padding=0, + bar_spacing=4, border=0, border_color=None, color_attribute=None, n_bins=10): super().__init__(parent) self.height, self.width = height, width @@ -133,7 +134,7 @@ def __init__(self, data, variable, parent=None, height=200, self.data = data self.attribute = data.domain[variable] - self.x = data.get_column_view(self.attribute)[0].astype(np.float64) + self.x = data.get_column(self.attribute) self.x_nans = np.isnan(self.x) self.x = self.x[~self.x_nans] @@ -155,7 +156,7 @@ def __init__(self, data, variable, parent=None, height=200, self.color_attribute = color_attribute if self.color_attribute is not None: self.target_var = data.domain[color_attribute] - self.y = data.get_column_view(color_attribute)[0] + self.y = data.get_column(color_attribute) self.y = self.y[~self.x_nans] if not np.issubdtype(self.y.dtype, np.number): self.y = self.y.astype(np.float64) @@ -191,7 +192,7 @@ def _draw_border(point_1, point_2, border_width, parent): # _plot_`dim` accounts for all the paddings and spacings self._plot_height = self.height - self._plot_height -= top_padding + self._plot_height -= top_padding + bottom_padding self._plot_height -= t / 4 + b / 4 self._plot_width = self.width @@ -204,7 +205,7 @@ def _draw_border(point_1, point_2, border_width, parent): side_padding + r / 2, top_padding + t / 2, side_padding + l / 2, - b / 2 + bottom_padding + b / 2 ) self.__layout.setSpacing(bar_spacing) diff --git a/Orange/widgets/data/utils/models.py b/Orange/widgets/data/utils/models.py new file mode 100644 index 00000000000..553041a7d22 --- /dev/null +++ b/Orange/widgets/data/utils/models.py @@ -0,0 +1,156 @@ +from math import isnan + +from AnyQt.QtCore import Qt, QIdentityProxyModel, QModelIndex + +from orangewidget.gui import OrangeUserRole + +import Orange +from Orange.widgets import gui +from Orange.widgets.utils.itemmodels import TableModel + +_BarRole = gui.TableBarItem.BarRole + + +class RichTableModel(TableModel): + """A TableModel with some extra bells and whistles/ + + (adds support for gui.BarRole, include variable labels and icons + in the header) + """ + #: Rich header data flags. + Name, Labels, Icon = 1, 2, 4 + + #: Qt.ItemData role to retrieve variable's header attributes. + LabelsItemsRole = next(OrangeUserRole) + + def __init__(self, sourcedata, parent=None): + super().__init__(sourcedata, parent) + + self._header_flags = RichTableModel.Name + self._continuous = [var.is_continuous for var in self.vars] + labels = [] + for var in self.vars: + if isinstance(var, Orange.data.Variable): + labels.extend(var.attributes.keys()) + self._labels = list(sorted( + {label for label in labels if not label.startswith("_")})) + + def data(self, index, role=Qt.DisplayRole): + # pylint: disable=arguments-differ + if role == _BarRole and self._continuous[index.column()]: + val = super().data(index, TableModel.ValueRole) + if val is None or isnan(val): + return None + + dist = super().data(index, TableModel.VariableStatsRole) + if dist is not None and dist.max > dist.min: + return (val - dist.min) / (dist.max - dist.min) + else: + return None + elif role == Qt.TextAlignmentRole and self._continuous[index.column()]: + return Qt.AlignRight | Qt.AlignVCenter + else: + return super().data(index, role) + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + var = super().headerData( + section, orientation, TableModel.VariableRole) + if var is None: + return super().headerData( + section, orientation, Qt.DisplayRole) + + lines = [] + if self._header_flags & RichTableModel.Name: + lines.append(var.name) + if self._header_flags & RichTableModel.Labels: + lines.extend(str(var.attributes.get(label, "")) + for label in self._labels) + return "\n".join(lines) + elif orientation == Qt.Horizontal and \ + role == RichTableModel.LabelsItemsRole: + var = super().headerData( + section, orientation, TableModel.VariableRole) + if var is None: + return [] + return [(label, var.attributes.get(label)) + for label in self._labels] + elif orientation == Qt.Horizontal and role == Qt.DecorationRole and \ + self._header_flags & RichTableModel.Icon: + var = super().headerData( + section, orientation, TableModel.VariableRole) + if var is not None: + return gui.attributeIconDict[var] + else: + return None + else: + return super().headerData(section, orientation, role) + + def setRichHeaderFlags(self, flags): + if flags != self._header_flags: + self._header_flags = flags + self.headerDataChanged.emit( + Qt.Horizontal, 0, self.columnCount() - 1) + + def richHeaderFlags(self): + return self._header_flags + + +# This is used for sub-setting large (SQL) models. Largely untested probably +# broken. +class TableSliceProxy(QIdentityProxyModel): + def __init__(self, parent=None, rowSlice=slice(0, None, 1), **kwargs): + super().__init__(parent, **kwargs) + self.__rowslice = slice(0, None, 1) + self.setRowSlice(rowSlice) + + def setRowSlice(self, rowslice): + if rowslice.step is not None and rowslice.step != 1: + raise ValueError("invalid stride") + + if self.__rowslice != rowslice: + self.beginResetModel() + self.__rowslice = rowslice + self.endResetModel() + + def index( + self, row: int, column: int, _parent: QModelIndex = QModelIndex() + ) -> QModelIndex: + return self.createIndex(row, column) + + def parent(self, _child: QModelIndex) -> QModelIndex: + return QModelIndex() + + def sibling(self, row: int, column: int, _idx: QModelIndex) -> QModelIndex: + return self.index(row, column) + + def mapToSource(self, proxyindex): + model = self.sourceModel() + if model is None or not proxyindex.isValid(): + return QModelIndex() + + row, col = proxyindex.row(), proxyindex.column() + row = row + self.__rowslice.start + if 0 <= row < model.rowCount(): + return model.createIndex(row, col) + else: + return QModelIndex() + + def mapFromSource(self, sourceindex): + model = self.sourceModel() + if model is None or not sourceindex.isValid(): + return QModelIndex() + row, col = sourceindex.row(), sourceindex.column() + row = row - self.__rowslice.start + if 0 <= row < self.rowCount(): + return self.createIndex(row, col) + else: + return QModelIndex() + + def rowCount(self, parent=QModelIndex()): + if parent.isValid(): + return 0 + count = super().rowCount() + start, stop, step = self.__rowslice.indices(count) + assert step == 1 + return stop - start diff --git a/Orange/widgets/data/utils/preprocess.py b/Orange/widgets/data/utils/preprocess.py index 89938ab5aad..a26f2986b08 100644 --- a/Orange/widgets/data/utils/preprocess.py +++ b/Orange/widgets/data/utils/preprocess.py @@ -16,7 +16,7 @@ ) from AnyQt.QtGui import ( - QCursor, QIcon, QPainter, QPixmap, QStandardItemModel, + QIcon, QPainter, QPixmap, QStandardItemModel, QDrag, QKeySequence ) @@ -291,7 +291,7 @@ def dragMoveEvent(self, event): if event.mimeData().hasFormat(self.MimeType) and \ self.model() is not None: event.accept() - self._setDropIndicatorAt(event.pos()) + self._setDropIndicatorAt(event.position()) return True else: return False @@ -646,8 +646,7 @@ def setIcon(self, index, icon): def dropEvent(self, event): """Reimplemented.""" layout = self.__flowlayout - index = self.__insertIndexAt(self.mapFromGlobal(QCursor.pos())) - + index = self.__insertIndexAt(event.position()) if event.mimeData().hasFormat("application/x-internal-move") and \ event.source() is self: # Complete the internal move @@ -677,8 +676,7 @@ def dragEnterEvent(self, event): def dragMoveEvent(self, event): """Reimplemented.""" - pos = self.mapFromGlobal(QCursor.pos()) - self.__setDropIndicatorAt(pos) + self.__setDropIndicatorAt(event.position()) def dragLeaveEvent(self, event): """Reimplemented.""" diff --git a/Orange/widgets/data/utils/pythoneditor/__init__.py b/Orange/widgets/data/utils/pythoneditor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py b/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py new file mode 100644 index 00000000000..859e44d16b9 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/brackethighlighter.py @@ -0,0 +1,160 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +import time + +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QTextCursor, QColor +from AnyQt.QtWidgets import QTextEdit, QApplication + +# Bracket highlighter. +# Calculates list of QTextEdit.ExtraSelection + + +class _TimeoutException(UserWarning): + """Operation timeout happened + """ + + +class BracketHighlighter: + """Bracket highliter. + Calculates list of QTextEdit.ExtraSelection + + Currently, this class might be just a set of functions. + Probably, it will contain instance specific selection colors later + """ + MATCHED_COLOR = QColor('#0b0') + UNMATCHED_COLOR = QColor('#a22') + + _MAX_SEARCH_TIME_SEC = 0.02 + + _START_BRACKETS = '({[' + _END_BRACKETS = ')}]' + _ALL_BRACKETS = _START_BRACKETS + _END_BRACKETS + _OPOSITE_BRACKET = dict(zip(_START_BRACKETS + _END_BRACKETS, _END_BRACKETS + _START_BRACKETS)) + + # instance variable. None or ((block, columnIndex), (block, columnIndex)) + currentMatchedBrackets = None + + def _iterateDocumentCharsForward(self, block, startColumnIndex): + """Traverse document forward. Yield (block, columnIndex, char) + Raise _TimeoutException if time is over + """ + # Chars in the start line + endTime = time.time() + self._MAX_SEARCH_TIME_SEC + for columnIndex, char in list(enumerate(block.text()))[startColumnIndex:]: + yield block, columnIndex, char + block = block.next() + + # Next lines + while block.isValid(): + for columnIndex, char in enumerate(block.text()): + yield block, columnIndex, char + + if time.time() > endTime: + raise _TimeoutException('Time is over') + + block = block.next() + + def _iterateDocumentCharsBackward(self, block, startColumnIndex): + """Traverse document forward. Yield (block, columnIndex, char) + Raise _TimeoutException if time is over + """ + # Chars in the start line + endTime = time.time() + self._MAX_SEARCH_TIME_SEC + for columnIndex, char in reversed(list(enumerate(block.text()[:startColumnIndex]))): + yield block, columnIndex, char + block = block.previous() + + # Next lines + while block.isValid(): + for columnIndex, char in reversed(list(enumerate(block.text()))): + yield block, columnIndex, char + + if time.time() > endTime: + raise _TimeoutException('Time is over') + + block = block.previous() + + def _findMatchingBracket(self, bracket, qpart, block, columnIndex): + """Find matching bracket for the bracket. + Return (block, columnIndex) or (None, None) + Raise _TimeoutException, if time is over + """ + if bracket in self._START_BRACKETS: + charsGenerator = self._iterateDocumentCharsForward(block, columnIndex + 1) + else: + charsGenerator = self._iterateDocumentCharsBackward(block, columnIndex) + + depth = 1 + oposite = self._OPOSITE_BRACKET[bracket] + for b, c_index, char in charsGenerator: + if qpart.isCode(b, c_index): + if char == oposite: + depth -= 1 + if depth == 0: + return b, c_index + elif char == bracket: + depth += 1 + return None, None + + def _makeMatchSelection(self, block, columnIndex, matched): + """Make matched or unmatched QTextEdit.ExtraSelection + """ + selection = QTextEdit.ExtraSelection() + darkMode = QApplication.instance().property('darkMode') + + if matched: + fgColor = self.MATCHED_COLOR + else: + fgColor = self.UNMATCHED_COLOR + + selection.format.setForeground(fgColor) + # repaint hack + selection.format.setBackground(Qt.white if not darkMode else QColor('#111111')) + selection.cursor = QTextCursor(block) + selection.cursor.setPosition(block.position() + columnIndex) + selection.cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor) + + return selection + + def _highlightBracket(self, bracket, qpart, block, columnIndex): + """Highlight bracket and matching bracket + Return tuple of QTextEdit.ExtraSelection's + """ + try: + matchedBlock, matchedColumnIndex = self._findMatchingBracket(bracket, qpart, + block, columnIndex) + except _TimeoutException: # not found, time is over + return[] # highlight nothing + + if matchedBlock is not None: + self.currentMatchedBrackets = ((block, columnIndex), (matchedBlock, matchedColumnIndex)) + return [self._makeMatchSelection(block, columnIndex, True), + self._makeMatchSelection(matchedBlock, matchedColumnIndex, True)] + else: + self.currentMatchedBrackets = None + return [self._makeMatchSelection(block, columnIndex, False)] + + def extraSelections(self, qpart, block, columnIndex): + """List of QTextEdit.ExtraSelection's, which highlighte brackets + """ + blockText = block.text() + + if columnIndex < len(blockText) and \ + blockText[columnIndex] in self._ALL_BRACKETS and \ + qpart.isCode(block, columnIndex): + return self._highlightBracket(blockText[columnIndex], qpart, block, columnIndex) + elif columnIndex > 0 and \ + blockText[columnIndex - 1] in self._ALL_BRACKETS and \ + qpart.isCode(block, columnIndex - 1): + return self._highlightBracket(blockText[columnIndex - 1], qpart, block, columnIndex - 1) + else: + self.currentMatchedBrackets = None + return [] diff --git a/Orange/widgets/data/utils/pythoneditor/completer.py b/Orange/widgets/data/utils/pythoneditor/completer.py new file mode 100644 index 00000000000..d09e1757e2e --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/completer.py @@ -0,0 +1,578 @@ +import logging +import html +import sys +from collections import namedtuple +from os.path import join, dirname + +from AnyQt.QtCore import QObject, QSize +from AnyQt.QtCore import QPoint, Qt, Signal +from AnyQt.QtGui import (QFontMetrics, QIcon, QTextDocument, + QAbstractTextDocumentLayout) +from AnyQt.QtWidgets import (QApplication, QListWidget, QListWidgetItem, + QToolTip, QStyledItemDelegate, + QStyleOptionViewItem, QStyle) + +from qtconsole.base_frontend_mixin import BaseFrontendMixin + +log = logging.getLogger(__name__) + +DEFAULT_COMPLETION_ITEM_WIDTH = 250 + +JEDI_TYPES = frozenset({'module', 'class', 'instance', 'function', 'param', + 'path', 'keyword', 'property', 'statement', None}) + + +class HTMLDelegate(QStyledItemDelegate): + """With this delegate, a QListWidgetItem or a QTableItem can render HTML. + + Taken from https://stackoverflow.com/a/5443112/2399799 + """ + + def __init__(self, parent, margin=0): + super().__init__(parent) + self._margin = margin + + def _prepare_text_document(self, option, index): + # This logic must be shared between paint and sizeHint for consitency + options = QStyleOptionViewItem(option) + self.initStyleOption(options, index) + + doc = QTextDocument() + doc.setDocumentMargin(self._margin) + doc.setHtml(options.text) + icon_height = doc.size().height() - 2 + options.decorationSize = QSize(icon_height, icon_height) + return options, doc + + def paint(self, painter, option, index): + options, doc = self._prepare_text_document(option, index) + + style = (QApplication.style() if options.widget is None + else options.widget.style()) + options.text = "" + + # Note: We need to pass the options widget as an argument of + # drawControl to make sure the delegate is painted with a style + # consistent with the widget in which it is used. + # See spyder-ide/spyder#10677. + style.drawControl(QStyle.CE_ItemViewItem, options, painter, + options.widget) + + ctx = QAbstractTextDocumentLayout.PaintContext() + + textRect = style.subElementRect(QStyle.SE_ItemViewItemText, + options, None) + painter.save() + + painter.translate(textRect.topLeft() + QPoint(0, -3)) + doc.documentLayout().draw(painter, ctx) + painter.restore() + + def sizeHint(self, option, index): + _, doc = self._prepare_text_document(option, index) + return QSize(round(doc.idealWidth()), round(doc.size().height() - 2)) + + +class CompletionWidget(QListWidget): + """ + Modelled after spyder-ide's ComlpetionWidget. + Copyright © Spyder Project Contributors + Licensed under the terms of the MIT License + (see spyder/__init__.py in spyder-ide/spyder for details) + """ + ICON_MAP = {} + + sig_show_completions = Signal(object) + + # Signal with the info about the current completion item documentation + # str: completion name + # str: completion signature/documentation, + # QPoint: QPoint where the hint should be shown + sig_completion_hint = Signal(str, str, QPoint) + + def __init__(self, parent, ancestor): + super().__init__(ancestor) + self.textedit = parent + self._language = None + self.setWindowFlags(Qt.SubWindow | Qt.FramelessWindowHint) + self.hide() + self.itemActivated.connect(self.item_selected) + # self.currentRowChanged.connect(self.row_changed) + self.is_internal_console = False + self.completion_list = None + self.completion_position = None + self.automatic = False + self.display_index = [] + + # Setup item rendering + self.setItemDelegate(HTMLDelegate(self, margin=3)) + self.setMinimumWidth(DEFAULT_COMPLETION_ITEM_WIDTH) + + # Initial item height and width + fm = QFontMetrics(self.textedit.font()) + self.item_height = fm.height() + self.item_width = self.width() + + self.setStyleSheet('QListWidget::item:selected {' + 'background-color: lightgray;' + '}') + + def setup_appearance(self, size, font): + """Setup size and font of the completion widget.""" + self.resize(*size) + self.setFont(font) + fm = QFontMetrics(font) + self.item_height = fm.height() + + def is_empty(self): + """Check if widget is empty.""" + if self.count() == 0: + return True + return False + + def show_list(self, completion_list, position, automatic): + """Show list corresponding to position.""" + if not completion_list: + self.hide() + return + + self.automatic = automatic + + if position is None: + # Somehow the position was not saved. + # Hope that the current position is still valid + self.completion_position = self.textedit.textCursor().position() + + elif self.textedit.textCursor().position() < position: + # hide the text as we moved away from the position + self.hide() + return + + else: + self.completion_position = position + + self.completion_list = completion_list + + # Check everything is in order + self.update_current() + + # If update_current called close, stop loading + if not self.completion_list: + return + + # If only one, must be chosen if not automatic + single_match = self.count() == 1 + if single_match and not self.automatic: + self.item_selected(self.item(0)) + # signal used for testing + self.sig_show_completions.emit(completion_list) + return + + self.show() + self.setFocus() + self.raise_() + + self.textedit.position_widget_at_cursor(self) + + if not self.is_internal_console: + tooltip_point = self.rect().topRight() + tooltip_point = self.mapToGlobal(tooltip_point) + + if self.completion_list is not None: + for completion in self.completion_list: + completion['point'] = tooltip_point + + # Show hint for first completion element + self.setCurrentRow(0) + + # signal used for testing + self.sig_show_completions.emit(completion_list) + + def set_language(self, language): + """Set the completion language.""" + self._language = language.lower() + + def update_list(self, current_word): + """ + Update the displayed list by filtering self.completion_list based on + the current_word under the cursor (see check_can_complete). + + If we're not updating the list with new completions, we filter out + textEdit completions, since it's difficult to apply them correctly + after the user makes edits. + + If no items are left on the list the autocompletion should stop + """ + self.clear() + + self.display_index = [] + height = self.item_height + width = self.item_width + + if current_word: + for c in self.completion_list: + c['end'] = c['start'] + len(current_word) + + for i, completion in enumerate(self.completion_list): + text = completion['text'] + if not self.check_can_complete(text, current_word): + continue + item = QListWidgetItem() + self.set_item_display( + item, completion, height=height, width=width) + item.setData(Qt.UserRole, completion) + + self.addItem(item) + self.display_index.append(i) + + if self.count() == 0: + self.hide() + + def _get_cached_icon(self, name): + if name not in JEDI_TYPES: + log.error('%s is not a valid jedi type', name) + return None + if name not in self.ICON_MAP: + if name is None: + self.ICON_MAP[name] = QIcon() + else: + icon_path = join(dirname(__file__), '..', '..', 'icons', + 'pythonscript', name + '.svg') + self.ICON_MAP[name] = QIcon(icon_path) + return self.ICON_MAP[name] + + def set_item_display(self, item_widget, item_info, height, width): + """Set item text & icons using the info available.""" + item_label = item_info['text'] + item_type = item_info['type'] + + item_text = self.get_html_item_representation( + item_label, item_type, + height=height, width=width) + + item_widget.setText(item_text) + item_widget.setIcon(self._get_cached_icon(item_type)) + + @staticmethod + def get_html_item_representation(item_completion, item_type=None, + height=14, + width=250): + """Get HTML representation of and item.""" + height = str(height) + width = str(width) + + # Unfortunately, both old- and new-style Python string formatting + # have poor performance due to being implemented as functions that + # parse the format string. + # f-strings in new versions of Python are fast due to Python + # compiling them into efficient string operations, but to be + # compatible with old versions of Python, we manually join strings. + parts = [ + '
    ', '', + + '', + ] + if item_type is not None: + parts.extend(['' + ]) + parts.extend([ + '', '
    ', + html.escape(item_completion).replace(' ', ' '), + '', + item_type, + '
    ', + ]) + + return ''.join(parts) + + def hide(self): + """Override Qt method.""" + self.completion_position = None + self.completion_list = None + self.clear() + self.textedit.setFocus() + tooltip = getattr(self.textedit, 'tooltip_widget', None) + if tooltip: + tooltip.hide() + + QListWidget.hide(self) + QToolTip.hideText() + + def keyPressEvent(self, event): + """Override Qt method to process keypress.""" + # pylint: disable=too-many-branches + text, key = event.text(), event.key() + alt = event.modifiers() & Qt.AltModifier + shift = event.modifiers() & Qt.ShiftModifier + ctrl = event.modifiers() & Qt.ControlModifier + altgr = event.modifiers() and (key == Qt.Key_AltGr) + # Needed to properly handle Neo2 and other keyboard layouts + # See spyder-ide/spyder#11293 + neo2_level4 = (key == 0) # AltGr (ISO_Level5_Shift) in Neo2 on Linux + modifier = shift or ctrl or alt or altgr or neo2_level4 + if key in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab): + # Check that what was selected can be selected, + # otherwise timing issues + item = self.currentItem() + if item is None: + item = self.item(0) + + if self.is_up_to_date(item=item): + self.item_selected(item=item) + else: + self.hide() + self.textedit.keyPressEvent(event) + elif key == Qt.Key_Escape: + self.hide() + elif key in (Qt.Key_Left, Qt.Key_Right) or text in ('.', ':'): + self.hide() + self.textedit.keyPressEvent(event) + elif key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown, + Qt.Key_Home, Qt.Key_End) and not modifier: + if key == Qt.Key_Up and self.currentRow() == 0: + self.setCurrentRow(self.count() - 1) + elif key == Qt.Key_Down and self.currentRow() == self.count() - 1: + self.setCurrentRow(0) + else: + QListWidget.keyPressEvent(self, event) + elif len(text) > 0 or key == Qt.Key_Backspace: + self.textedit.keyPressEvent(event) + self.update_current() + elif modifier: + self.textedit.keyPressEvent(event) + else: + self.hide() + QListWidget.keyPressEvent(self, event) + + def is_up_to_date(self, item=None): + """ + Check if the selection is up to date. + """ + if self.is_empty(): + return False + if not self.is_position_correct(): + return False + if item is None: + item = self.currentItem() + current_word = self.textedit.get_current_word(completion=True) + completion = item.data(Qt.UserRole) + filter_text = completion['text'] + return self.check_can_complete(filter_text, current_word) + + @staticmethod + def check_can_complete(filter_text, current_word): + """Check if current_word matches filter_text.""" + if not filter_text: + return True + + if not current_word: + return True + + return str(filter_text).lower().startswith( + str(current_word).lower()) + + def is_position_correct(self): + """Check if the position is correct.""" + + if self.completion_position is None: + return False + + cursor_position = self.textedit.textCursor().position() + + # Can only go forward from the data we have + if cursor_position < self.completion_position: + return False + + completion_text = self.textedit.get_current_word_and_position( + completion=True) + + # If no text found, we must be at self.completion_position + if completion_text is None: + return self.completion_position == cursor_position + + completion_text, text_position = completion_text + completion_text = str(completion_text) + + # The position of text must compatible with completion_position + if not text_position <= self.completion_position <= ( + text_position + len(completion_text)): + return False + + return True + + def update_current(self): + """ + Update the displayed list. + """ + if not self.is_position_correct(): + self.hide() + return + + current_word = self.textedit.get_current_word(completion=True) + self.update_list(current_word) + # self.setFocus() + # self.raise_() + self.setCurrentRow(0) + + def focusOutEvent(self, event): + """Override Qt method.""" + event.ignore() + # Don't hide it on Mac when main window loses focus because + # keyboard input is lost. + # Fixes spyder-ide/spyder#1318. + if sys.platform == "darwin": + if event.reason() != Qt.ActiveWindowFocusReason: + self.hide() + else: + # Avoid an error when running tests that show + # the completion widget + try: + self.hide() + except RuntimeError: + pass + + def item_selected(self, item=None): + """Perform the item selected action.""" + if item is None: + item = self.currentItem() + + if item is not None and self.completion_position is not None: + self.textedit.insert_completion(item.data(Qt.UserRole), + self.completion_position) + self.hide() + + def trigger_completion_hint(self, row=None): + if not self.completion_list: + return + if row is None: + row = self.currentRow() + if row < 0 or len(self.completion_list) <= row: + return + + item = self.completion_list[row] + if 'point' not in item: + return + + if 'textEdit' in item: + insert_text = item['textEdit']['newText'] + else: + insert_text = item['insertText'] + + # Split by starting $ or language specific chars + chars = ['$'] + if self._language == 'python': + chars.append('(') + + for ch in chars: + insert_text = insert_text.split(ch)[0] + + self.sig_completion_hint.emit( + insert_text, + item['documentation'], + item['point']) + + # @Slot(int) + # def row_changed(self, row): + # """Set completion hint info and show it.""" + # self.trigger_completion_hint(row) + + +class Completer(BaseFrontendMixin, QObject): + """ + Uses qtconsole's kernel to generate jedi completions, showing a list. + """ + + def __init__(self, qpart): + QObject.__init__(self, qpart) + self._request_info = {} + self.ready = False + self._qpart = qpart + self._widget = CompletionWidget(self._qpart, self._qpart.parent()) + self._opened_automatically = True + + self._complete() + + def terminate(self): + """Object deleted. Cancel timer + """ + + def isVisible(self): + return self._widget.isVisible() + + def setup_appearance(self, size, font): + self._widget.setup_appearance(size, font) + + def invokeCompletion(self): + """Invoke completion manually""" + self._opened_automatically = False + self._complete() + + def invokeCompletionIfAvailable(self): + if not self._opened_automatically: + return + self._complete() + + def _show_completions(self, matches, pos): + self._widget.show_list(matches, pos, self._opened_automatically) + + def _close_completions(self): + self._widget.hide() + + def _complete(self): + """ Performs completion at the current cursor location. + """ + if not self.ready: + return + code = self._qpart.text + cursor_pos = self._qpart.textCursor().position() + self._send_completion_request(code, cursor_pos) + + def _send_completion_request(self, code, cursor_pos): + # Send the completion request to the kernel + msg_id = self.kernel_client.complete(code=code, cursor_pos=cursor_pos) + info = self._CompletionRequest(msg_id, code, cursor_pos) + self._request_info['complete'] = info + + # --------------------------------------------------------------------------- + # 'BaseFrontendMixin' abstract interface + # --------------------------------------------------------------------------- + + _CompletionRequest = namedtuple('_CompletionRequest', + ['id', 'code', 'pos']) + + def _handle_complete_reply(self, rep): + """Support Jupyter's improved completion machinery. + """ + info = self._request_info.get('complete') + if (info and info.id == rep['parent_header']['msg_id']): + content = rep['content'] + + if 'metadata' not in content or \ + '_jupyter_types_experimental' not in content['metadata']: + log.error('Jupyter API has changed, completions are unavailable.') + return + matches = content['metadata']['_jupyter_types_experimental'] + start = content['cursor_start'] + + start = max(start, 0) + + for m in matches: + if m['type'] == '': + m['type'] = None + + self._show_completions(matches, start) + self._opened_automatically = True + + def _handle_kernel_info_reply(self, _): + """ Called when the KernelManager channels have started listening or + when the frontend is assigned an already listening KernelManager. + """ + if not self.ready: + self.ready = True + + def _handle_kernel_restarted(self): + self.ready = True + + def _handle_kernel_died(self, _): + self.ready = False diff --git a/Orange/widgets/data/utils/pythoneditor/editor.py b/Orange/widgets/data/utils/pythoneditor/editor.py new file mode 100644 index 00000000000..4a1b895483c --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/editor.py @@ -0,0 +1,1836 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +import re +import sys + +from AnyQt.QtCore import Signal, Qt, QRect, QPoint +from AnyQt.QtGui import QColor, QPainter, QPalette, QTextCursor, QKeySequence, QTextBlock, \ + QTextFormat, QBrush, QPen, QTextCharFormat +from AnyQt.QtWidgets import QPlainTextEdit, QWidget, QTextEdit, QAction, QApplication + +from pygments.token import Token +from qtconsole.pygments_highlighter import PygmentsHighlighter, PygmentsBlockUserData + +from Orange.widgets.data.utils.pythoneditor.completer import Completer +from Orange.widgets.data.utils.pythoneditor.brackethighlighter import BracketHighlighter +from Orange.widgets.data.utils.pythoneditor.indenter import Indenter +from Orange.widgets.data.utils.pythoneditor.lines import Lines +from Orange.widgets.data.utils.pythoneditor.rectangularselection import RectangularSelection +from Orange.widgets.data.utils.pythoneditor.vim import Vim, isChar, code, key_code + + +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=too-many-lines +# pylint: disable=too-many-branches +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-public-methods + + +def setPositionInBlock(cursor, positionInBlock, anchor=QTextCursor.MoveAnchor): + return cursor.setPosition(cursor.block().position() + positionInBlock, anchor) + + +def iterateBlocksFrom(block): + """Generator, which iterates QTextBlocks from block until the End of a document + """ + while block.isValid(): + yield block + block = block.next() + + +def iterateBlocksBackFrom(block): + """Generator, which iterates QTextBlocks from block until the Start of a document + """ + while block.isValid(): + yield block + block = block.previous() + + +class PythonEditor(QPlainTextEdit): + userWarning = Signal(str) + languageChanged = Signal(str) + indentWidthChanged = Signal(int) + indentUseTabsChanged = Signal(bool) + eolChanged = Signal(str) + vimModeIndicationChanged = Signal(QColor, str) + vimModeEnabledChanged = Signal(bool) + + LINT_ERROR = 'e' + LINT_WARNING = 'w' + LINT_NOTE = 'n' + + _DEFAULT_EOL = '\n' + + _DEFAULT_COMPLETION_THRESHOLD = 3 + _DEFAULT_COMPLETION_ENABLED = True + + def __init__(self, *args): + QPlainTextEdit.__init__(self, *args) + + self.setAttribute(Qt.WA_KeyCompression, False) # vim can't process compressed keys + + self._lastKeyPressProcessedByParent = False + # toPlainText() takes a lot of time on long texts, therefore it is cached + self._cachedText = None + + self._fontBackup = self.font() + + self._eol = self._DEFAULT_EOL + self._indenter = Indenter(self) + self._lineLengthEdge = None + self._lineLengthEdgeColor = QColor(255, 0, 0, 128) + self._atomicModificationDepth = 0 + + self.drawIncorrectIndentation = True + self.drawAnyWhitespace = False + self._drawIndentations = True + self._drawSolidEdge = False + self._solidEdgeLine = EdgeLine(self) + self._solidEdgeLine.setVisible(False) + + self._rectangularSelection = RectangularSelection(self) + + """Sometimes color themes will be supported. + Now black on white is hardcoded in the highlighters. + Hardcode same palette for not highlighted text + """ + palette = self.palette() + # don't clear syntax highlighting when highlighting text + palette.setBrush(QPalette.HighlightedText, QBrush(Qt.NoBrush)) + if QApplication.instance().property('darkMode'): + palette.setColor(QPalette.Base, QColor('#111111')) + palette.setColor(QPalette.Text, QColor('#ffffff')) + palette.setColor(QPalette.Highlight, QColor('#444444')) + self._currentLineColor = QColor('#111111') + else: + palette.setColor(QPalette.Base, QColor('#ffffff')) + palette.setColor(QPalette.Text, QColor('#000000')) + self._currentLineColor = QColor('#ffffff') + self.setPalette(palette) + + self._bracketHighlighter = BracketHighlighter() + + self._lines = Lines(self) + + self.completionThreshold = self._DEFAULT_COMPLETION_THRESHOLD + self.completionEnabled = self._DEFAULT_COMPLETION_ENABLED + self._completer = Completer(self) + self.auto_invoke_completions = False + self.dot_invoke_completions = False + + doc = self.document() + highlighter = PygmentsHighlighter(doc) + doc.highlighter = highlighter + + self._vim = None + + self._initActions() + + self._line_number_margin = LineNumberArea(self) + self._marginWidth = -1 + + self._nonVimExtraSelections = [] + # we draw bracket highlighting, current line and extra selections by user + self._userExtraSelections = [] + self._userExtraSelectionFormat = QTextCharFormat() + self._userExtraSelectionFormat.setBackground(QBrush(QColor('#ffee00'))) + + self._lintMarks = {} + + self.cursorPositionChanged.connect(self._updateExtraSelections) + self.textChanged.connect(self._dropUserExtraSelections) + self.textChanged.connect(self._resetCachedText) + self.textChanged.connect(self._clearLintMarks) + + self._updateExtraSelections() + + def _initActions(self): + """Init shortcuts for text editing + """ + + def createAction(text, shortcut, slot, iconFileName=None): + """Create QAction with given parameters and add to the widget + """ + action = QAction(text, self) + # if iconFileName is not None: + # action.setIcon(getIcon(iconFileName)) + + keySeq = shortcut if isinstance(shortcut, QKeySequence) else QKeySequence(shortcut) + action.setShortcut(keySeq) + action.setShortcutContext(Qt.WidgetShortcut) + action.triggered.connect(slot) + + self.addAction(action) + + return action + + # custom Orange actions + self.commentLine = createAction('Toggle comment line', 'Ctrl+/', self._onToggleCommentLine) + + # scrolling + self.scrollUpAction = createAction('Scroll up', 'Ctrl+Up', + lambda: self._onShortcutScroll(down=False), + 'go-up') + self.scrollDownAction = createAction('Scroll down', 'Ctrl+Down', + lambda: self._onShortcutScroll(down=True), + 'go-down') + self.selectAndScrollUpAction = createAction('Select and scroll Up', 'Ctrl+Shift+Up', + lambda: self._onShortcutSelectAndScroll( + down=False)) + self.selectAndScrollDownAction = createAction('Select and scroll Down', 'Ctrl+Shift+Down', + lambda: self._onShortcutSelectAndScroll( + down=True)) + + # indentation + self.increaseIndentAction = createAction('Increase indentation', 'Tab', + self._onShortcutIndent, + 'format-indent-more') + self.decreaseIndentAction = \ + createAction('Decrease indentation', 'Shift+Tab', + lambda: self._indenter.onChangeSelectedBlocksIndent( + increase=False), + 'format-indent-less') + self.autoIndentLineAction = \ + createAction('Autoindent line', 'Ctrl+I', + self._indenter.onAutoIndentTriggered) + self.indentWithSpaceAction = \ + createAction('Indent with 1 space', 'Ctrl+Shift+Space', + lambda: self._indenter.onChangeSelectedBlocksIndent( + increase=True, + withSpace=True)) + self.unIndentWithSpaceAction = \ + createAction('Unindent with 1 space', 'Ctrl+Shift+Backspace', + lambda: self._indenter.onChangeSelectedBlocksIndent( + increase=False, + withSpace=True)) + + # editing + self.undoAction = createAction('Undo', QKeySequence.Undo, + self.undo, 'edit-undo') + self.redoAction = createAction('Redo', QKeySequence.Redo, + self.redo, 'edit-redo') + + self.moveLineUpAction = createAction('Move line up', 'Alt+Up', + lambda: self._onShortcutMoveLine(down=False), + 'go-up') + self.moveLineDownAction = createAction('Move line down', 'Alt+Down', + lambda: self._onShortcutMoveLine(down=True), + 'go-down') + self.deleteLineAction = createAction('Delete line', 'Alt+Del', + self._onShortcutDeleteLine, 'edit-delete') + self.cutLineAction = createAction('Cut line', 'Alt+X', + self._onShortcutCutLine, 'edit-cut') + self.copyLineAction = createAction('Copy line', 'Alt+C', + self._onShortcutCopyLine, 'edit-copy') + self.pasteLineAction = createAction('Paste line', 'Alt+V', + self._onShortcutPasteLine, 'edit-paste') + self.duplicateLineAction = createAction('Duplicate line', 'Alt+D', + self._onShortcutDuplicateLine) + + def _onToggleCommentLine(self): + cursor: QTextCursor = self.textCursor() + cursor.beginEditBlock() + + startBlock = self.document().findBlock(cursor.selectionStart()) + endBlock = self.document().findBlock(cursor.selectionEnd()) + + def lineIndentationLength(text): + return len(text) - len(text.lstrip()) + + def isHashCommentSelected(lines): + return all(not line.strip() or line.lstrip().startswith('#') for line in lines) + + blocks = [] + lines = [] + + block = startBlock + line = block.text() + if block != endBlock or line.strip(): + blocks += [block] + lines += [line] + while block != endBlock: + block = block.next() + line = block.text() + if line.strip(): + blocks += [block] + lines += [line] + + if isHashCommentSelected(lines): + # remove the hash comment + for block, text in zip(blocks, lines): + cursor = QTextCursor(block) + cursor.setPosition(block.position() + lineIndentationLength(text)) + for _ in range(lineIndentationLength(text[lineIndentationLength(text) + 1:]) + 1): + cursor.deleteChar() + else: + # add a hash comment + for block, text in zip(blocks, lines): + cursor = QTextCursor(block) + cursor.setPosition(block.position() + lineIndentationLength(text)) + cursor.insertText('# ') + + if endBlock == self.document().lastBlock(): + if endBlock.text().strip(): + cursor = QTextCursor(endBlock) + cursor.movePosition(QTextCursor.End) + self.setTextCursor(cursor) + self._insertNewBlock() + cursorBlock = endBlock.next() + else: + cursorBlock = endBlock + else: + cursorBlock = endBlock.next() + cursor = QTextCursor(cursorBlock) + cursor.movePosition(QTextCursor.EndOfBlock) + self.setTextCursor(cursor) + cursor.endEditBlock() + + def _onShortcutIndent(self): + cursor = self.textCursor() + if cursor.hasSelection(): + self._indenter.onChangeSelectedBlocksIndent(increase=True) + elif cursor.positionInBlock() == cursor.block().length() - 1 and \ + cursor.block().text().strip(): + self._onCompletion() + else: + self._indenter.onShortcutIndentAfterCursor() + + def _onShortcutScroll(self, down): + """Ctrl+Up/Down pressed, scroll viewport + """ + value = self.verticalScrollBar().value() + if down: + value += 1 + else: + value -= 1 + self.verticalScrollBar().setValue(value) + + def _onShortcutSelectAndScroll(self, down): + """Ctrl+Shift+Up/Down pressed. + Select line and scroll viewport + """ + cursor = self.textCursor() + cursor.movePosition(QTextCursor.Down if down else QTextCursor.Up, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + self._onShortcutScroll(down) + + def _onShortcutHome(self, select): + """Home pressed. Run a state machine: + + 1. Not at the line beginning. Move to the beginning of the line or + the beginning of the indent, whichever is closest to the current + cursor position. + 2. At the line beginning. Move to the beginning of the indent. + 3. At the beginning of the indent. Go to the beginning of the block. + 4. At the beginning of the block. Go to the beginning of the indent. + """ + # Gather info for cursor state and movement. + cursor = self.textCursor() + text = cursor.block().text() + indent = len(text) - len(text.lstrip()) + anchor = QTextCursor.KeepAnchor if select else QTextCursor.MoveAnchor + + # Determine current state and move based on that. + if cursor.positionInBlock() == indent: + # We're at the beginning of the indent. Go to the beginning of the + # block. + cursor.movePosition(QTextCursor.StartOfBlock, anchor) + elif cursor.atBlockStart(): + # We're at the beginning of the block. Go to the beginning of the + # indent. + setPositionInBlock(cursor, indent, anchor) + else: + # Neither of the above. There's no way I can find to directly + # determine if we're at the beginning of a line. So, try moving and + # see if the cursor location changes. + pos = cursor.positionInBlock() + cursor.movePosition(QTextCursor.StartOfLine, anchor) + # If we didn't move, we were already at the beginning of the line. + # So, move to the indent. + if pos == cursor.positionInBlock(): + setPositionInBlock(cursor, indent, anchor) + # If we did move, check to see if the indent was closer to the + # cursor than the beginning of the indent. If so, move to the + # indent. + elif cursor.positionInBlock() < indent: + setPositionInBlock(cursor, indent, anchor) + + self.setTextCursor(cursor) + + def _selectLines(self, startBlockNumber, endBlockNumber): + """Select whole lines + """ + startBlock = self.document().findBlockByNumber(startBlockNumber) + endBlock = self.document().findBlockByNumber(endBlockNumber) + cursor = QTextCursor(startBlock) + cursor.setPosition(endBlock.position(), QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def _selectedBlocks(self): + """Return selected blocks and tuple (startBlock, endBlock) + """ + cursor = self.textCursor() + return self.document().findBlock(cursor.selectionStart()), \ + self.document().findBlock(cursor.selectionEnd()) + + def _selectedBlockNumbers(self): + """Return selected block numbers and tuple (startBlockNumber, endBlockNumber) + """ + startBlock, endBlock = self._selectedBlocks() + return startBlock.blockNumber(), endBlock.blockNumber() + + def _onShortcutMoveLine(self, down): + """Move line up or down + Actually, not a selected text, but next or previous block is moved + TODO keep bookmarks when moving + """ + startBlock, endBlock = self._selectedBlocks() + + startBlockNumber = startBlock.blockNumber() + endBlockNumber = endBlock.blockNumber() + + def _moveBlock(block, newNumber): + text = block.text() + with self: + del self.lines[block.blockNumber()] + self.lines.insert(newNumber, text) + + if down: # move next block up + blockToMove = endBlock.next() + if not blockToMove.isValid(): + return + + _moveBlock(blockToMove, startBlockNumber) + + # self._selectLines(startBlockNumber + 1, endBlockNumber + 1) + else: # move previous block down + blockToMove = startBlock.previous() + if not blockToMove.isValid(): + return + + _moveBlock(blockToMove, endBlockNumber) + + # self._selectLines(startBlockNumber - 1, endBlockNumber - 1) + + def _selectedLinesSlice(self): + """Get slice of selected lines + """ + startBlockNumber, endBlockNumber = self._selectedBlockNumbers() + return slice(startBlockNumber, endBlockNumber + 1, 1) + + def _onShortcutDeleteLine(self): + """Delete line(s) under cursor + """ + del self.lines[self._selectedLinesSlice()] + + def _onShortcutCopyLine(self): + """Copy selected lines to the clipboard + """ + lines = self.lines[self._selectedLinesSlice()] + text = self._eol.join(lines) + QApplication.clipboard().setText(text) + + def _onShortcutPasteLine(self): + """Paste lines from the clipboard + """ + text = QApplication.clipboard().text() + if text: + with self: + if self.textCursor().hasSelection(): + startBlockNumber, _ = self._selectedBlockNumbers() + del self.lines[self._selectedLinesSlice()] + self.lines.insert(startBlockNumber, text) + else: + line, col = self.cursorPosition + if col > 0: + line = line + 1 + self.lines.insert(line, text) + + def _onShortcutCutLine(self): + """Cut selected lines to the clipboard + """ + self._onShortcutCopyLine() + self._onShortcutDeleteLine() + + def _onShortcutDuplicateLine(self): + """Duplicate selected text or current line + """ + cursor = self.textCursor() + if cursor.hasSelection(): # duplicate selection + text = cursor.selectedText() + selectionStart, selectionEnd = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(selectionEnd) + cursor.insertText(text) + # restore selection + cursor.setPosition(selectionStart) + cursor.setPosition(selectionEnd, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + else: + line = cursor.blockNumber() + self.lines.insert(line + 1, self.lines[line]) + self.ensureCursorVisible() + + self._updateExtraSelections() # newly inserted text might be highlighted as braces + + def _onCompletion(self): + """Ctrl+Space handler. + Invoke completer if so configured + """ + if self._completer: + self._completer.invokeCompletion() + + @property + def kernel_client(self): + return self._completer.kernel_client + + @kernel_client.setter + def kernel_client(self, kernel_client): + self._completer.kernel_client = kernel_client + + @property + def kernel_manager(self): + return self._completer.kernel_manager + + @kernel_manager.setter + def kernel_manager(self, kernel_manager): + self._completer.kernel_manager = kernel_manager + + @property + def vimModeEnabled(self): + return self._vim is not None + + @vimModeEnabled.setter + def vimModeEnabled(self, enabled): + if enabled: + if self._vim is None: + self._vim = Vim(self) + self._vim.modeIndicationChanged.connect(self.vimModeIndicationChanged) + self.vimModeEnabledChanged.emit(True) + else: + if self._vim is not None: + self._vim.terminate() + self._vim = None + self.vimModeEnabledChanged.emit(False) + + @property + def vimModeIndication(self): + if self._vim is not None: + return self._vim.indication() + else: + return (None, None) + + @property + def selectedText(self): + text = self.textCursor().selectedText() + + # replace unicode paragraph separator with habitual \n + text = text.replace('\u2029', '\n') + + return text + + @selectedText.setter + def selectedText(self, text): + self.textCursor().insertText(text) + + @property + def cursorPosition(self): + cursor = self.textCursor() + return cursor.block().blockNumber(), cursor.positionInBlock() + + @cursorPosition.setter + def cursorPosition(self, pos): + line, col = pos + + line = min(line, len(self.lines) - 1) + lineText = self.lines[line] + + if col is not None: + col = min(col, len(lineText)) + else: + col = len(lineText) - len(lineText.lstrip()) + + cursor = QTextCursor(self.document().findBlockByNumber(line)) + setPositionInBlock(cursor, col) + self.setTextCursor(cursor) + + @property + def absCursorPosition(self): + return self.textCursor().position() + + @absCursorPosition.setter + def absCursorPosition(self, pos): + cursor = self.textCursor() + cursor.setPosition(pos) + self.setTextCursor(cursor) + + @property + def selectedPosition(self): + cursor = self.textCursor() + cursorLine, cursorCol = cursor.blockNumber(), cursor.positionInBlock() + + cursor.setPosition(cursor.anchor()) + startLine, startCol = cursor.blockNumber(), cursor.positionInBlock() + + return ((startLine, startCol), (cursorLine, cursorCol)) + + @selectedPosition.setter + def selectedPosition(self, pos): + anchorPos, cursorPos = pos + anchorLine, anchorCol = anchorPos + cursorLine, cursorCol = cursorPos + + anchorCursor = QTextCursor(self.document().findBlockByNumber(anchorLine)) + setPositionInBlock(anchorCursor, anchorCol) + + # just get absolute position + cursor = QTextCursor(self.document().findBlockByNumber(cursorLine)) + setPositionInBlock(cursor, cursorCol) + + anchorCursor.setPosition(cursor.position(), QTextCursor.KeepAnchor) + self.setTextCursor(anchorCursor) + + @property + def absSelectedPosition(self): + cursor = self.textCursor() + return cursor.anchor(), cursor.position() + + @absSelectedPosition.setter + def absSelectedPosition(self, pos): + anchorPos, cursorPos = pos + cursor = self.textCursor() + cursor.setPosition(anchorPos) + cursor.setPosition(cursorPos, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def resetSelection(self): + """Reset selection. Nothing will be selected. + """ + cursor = self.textCursor() + cursor.setPosition(cursor.position()) + self.setTextCursor(cursor) + + @property + def eol(self): + return self._eol + + @eol.setter + def eol(self, eol): + if not eol in ('\r', '\n', '\r\n'): + raise ValueError("Invalid EOL value") + if eol != self._eol: + self._eol = eol + self.eolChanged.emit(self._eol) + + @property + def indentWidth(self): + return self._indenter.width + + @indentWidth.setter + def indentWidth(self, width): + if self._indenter.width != width: + self._indenter.width = width + self._updateTabStopWidth() + self.indentWidthChanged.emit(width) + + @property + def indentUseTabs(self): + return self._indenter.useTabs + + @indentUseTabs.setter + def indentUseTabs(self, use): + if use != self._indenter.useTabs: + self._indenter.useTabs = use + self.indentUseTabsChanged.emit(use) + + @property + def lintMarks(self): + return self._lintMarks + + @lintMarks.setter + def lintMarks(self, marks): + if self._lintMarks != marks: + self._lintMarks = marks + self.update() + + def _clearLintMarks(self): + if not self._lintMarks: + self._lintMarks = {} + self.update() + + @property + def drawSolidEdge(self): + return self._drawSolidEdge + + @drawSolidEdge.setter + def drawSolidEdge(self, val): + self._drawSolidEdge = val + if val: + self._setSolidEdgeGeometry() + self.viewport().update() + self._solidEdgeLine.setVisible(val and self._lineLengthEdge is not None) + + @property + def drawIndentations(self): + return self._drawIndentations + + @drawIndentations.setter + def drawIndentations(self, val): + self._drawIndentations = val + self.viewport().update() + + @property + def lineLengthEdge(self): + return self._lineLengthEdge + + @lineLengthEdge.setter + def lineLengthEdge(self, val): + if self._lineLengthEdge != val: + self._lineLengthEdge = val + self.viewport().update() + self._solidEdgeLine.setVisible(val is not None and self._drawSolidEdge) + + @property + def lineLengthEdgeColor(self): + return self._lineLengthEdgeColor + + @lineLengthEdgeColor.setter + def lineLengthEdgeColor(self, val): + if self._lineLengthEdgeColor != val: + self._lineLengthEdgeColor = val + if self._lineLengthEdge is not None: + self.viewport().update() + + @property + def currentLineColor(self): + return self._currentLineColor + + @currentLineColor.setter + def currentLineColor(self, val): + if self._currentLineColor != val: + self._currentLineColor = val + self.viewport().update() + + def replaceText(self, pos, length, text): + """Replace length symbols from ``pos`` with new text. + + If ``pos`` is an integer, it is interpreted as absolute position, + if a tuple - as ``(line, column)`` + """ + if isinstance(pos, tuple): + pos = self.mapToAbsPosition(*pos) + + endPos = pos + length + + if not self.document().findBlock(pos).isValid(): + raise IndexError('Invalid start position %d' % pos) + + if not self.document().findBlock(endPos).isValid(): + raise IndexError('Invalid end position %d' % endPos) + + cursor = QTextCursor(self.document()) + cursor.setPosition(pos) + cursor.setPosition(endPos, QTextCursor.KeepAnchor) + + cursor.insertText(text) + + def insertText(self, pos, text): + """Insert text at position + + If ``pos`` is an integer, it is interpreted as absolute position, + if a tuple - as ``(line, column)`` + """ + return self.replaceText(pos, 0, text) + + def updateViewport(self): + """Recalculates geometry for all the margins and the editor viewport + """ + cr = self.contentsRect() + currentX = cr.left() + top = cr.top() + height = cr.height() + + marginWidth = 0 + if not self._line_number_margin.isHidden(): + width = self._line_number_margin.width() + self._line_number_margin.setGeometry(QRect(currentX, top, width, height)) + currentX += width + marginWidth += width + + if self._marginWidth != marginWidth: + self._marginWidth = marginWidth + self.updateViewportMargins() + else: + self._setSolidEdgeGeometry() + + def updateViewportMargins(self): + """Sets the viewport margins and the solid edge geometry""" + self.setViewportMargins(self._marginWidth, 0, 0, 0) + self._setSolidEdgeGeometry() + + def setDocument(self, document) -> None: + super().setDocument(document) + self._lines.setDocument(document) + # forces margins to update after setting a new document + self.blockCountChanged.emit(self.blockCount()) + + def _updateExtraSelections(self): + """Highlight current line + """ + cursorColumnIndex = self.textCursor().positionInBlock() + + bracketSelections = self._bracketHighlighter.extraSelections(self, + self.textCursor().block(), + cursorColumnIndex) + + selections = self._currentLineExtraSelections() + \ + self._rectangularSelection.selections() + \ + bracketSelections + \ + self._userExtraSelections + + self._nonVimExtraSelections = selections + + if self._vim is None: + allSelections = selections + else: + allSelections = selections + self._vim.extraSelections() + + QPlainTextEdit.setExtraSelections(self, allSelections) + + def _updateVimExtraSelections(self): + QPlainTextEdit.setExtraSelections(self, + self._nonVimExtraSelections + self._vim.extraSelections()) + + def _setSolidEdgeGeometry(self): + """Sets the solid edge line geometry if needed""" + if self._lineLengthEdge is not None: + cr = self.contentsRect() + + # contents margin usually gives 1 + # cursor rectangle left edge for the very first character usually + # gives 4 + x = self.fontMetrics().width('9' * self._lineLengthEdge) + \ + self._marginWidth + \ + self.contentsMargins().left() + \ + self.__cursorRect(self.firstVisibleBlock(), 0, offset=0).left() + self._solidEdgeLine.setGeometry(QRect(x, cr.top(), 1, cr.bottom())) + + viewport_margins_updated = Signal(float) + + def setViewportMargins(self, left, top, right, bottom): + """ + Override to align function signature with first character. + """ + super().setViewportMargins(left, top, right, bottom) + + cursor = QTextCursor(self.firstVisibleBlock()) + setPositionInBlock(cursor, 0) + cursorRect = self.cursorRect(cursor).translated(0, 0) + + first_char_indent = self._marginWidth + \ + self.contentsMargins().left() + \ + cursorRect.left() + + self.viewport_margins_updated.emit(first_char_indent) + + def textBeforeCursor(self): + """Text in current block from start to cursor position + """ + cursor = self.textCursor() + return cursor.block().text()[:cursor.positionInBlock()] + + def keyPressEvent(self, event): + """QPlainTextEdit.keyPressEvent() implementation. + Catch events, which may not be catched with QShortcut and call slots + """ + self._lastKeyPressProcessedByParent = False + + cursor = self.textCursor() + + def shouldUnindentWithBackspace(): + text = cursor.block().text() + spaceAtStartLen = len(text) - len(text.lstrip()) + + return self.textBeforeCursor().endswith(self._indenter.text()) and \ + not cursor.hasSelection() and \ + cursor.positionInBlock() == spaceAtStartLen + + def atEnd(): + return cursor.positionInBlock() == cursor.block().length() - 1 + + def shouldAutoIndent(event): + return atEnd() and \ + event.text() and \ + event.text() in self._indenter.triggerCharacters() + + def backspaceOverwrite(): + with self: + cursor.deletePreviousChar() + cursor.insertText(' ') + setPositionInBlock(cursor, cursor.positionInBlock() - 1) + self.setTextCursor(cursor) + + def typeOverwrite(text): + """QPlainTextEdit records text input in replace mode as 2 actions: + delete char, and type char. Actions are undone separately. This is + workaround for the Qt bug""" + with self: + if not atEnd(): + cursor.deleteChar() + cursor.insertText(text) + + # mac specific shortcuts, + if sys.platform == 'darwin': + # it seems weird to delete line on CTRL+Backspace on Windows, + # that's for deleting words. But Mac's CMD maps to Qt's CTRL. + if event.key() == Qt.Key_Backspace and event.modifiers() == Qt.ControlModifier: + self.deleteLineAction.trigger() + event.accept() + return + if event.matches(QKeySequence.InsertLineSeparator): + event.ignore() + return + elif event.matches(QKeySequence.InsertParagraphSeparator): + if self._vim is not None: + if self._vim.keyPressEvent(event): + return + self._insertNewBlock() + elif event.matches(QKeySequence.Copy) and self._rectangularSelection.isActive(): + self._rectangularSelection.copy() + elif event.matches(QKeySequence.Cut) and self._rectangularSelection.isActive(): + self._rectangularSelection.cut() + elif self._rectangularSelection.isDeleteKeyEvent(event): + self._rectangularSelection.delete() + elif event.key() == Qt.Key_Insert and event.modifiers() == Qt.NoModifier: + if self._vim is not None: + self._vim.keyPressEvent(event) + else: + self.setOverwriteMode(not self.overwriteMode()) + elif event.key() == Qt.Key_Backspace and \ + shouldUnindentWithBackspace(): + self._indenter.onShortcutUnindentWithBackspace() + elif event.key() == Qt.Key_Backspace and \ + not cursor.hasSelection() and \ + self.overwriteMode() and \ + cursor.positionInBlock() > 0: + backspaceOverwrite() + elif self.overwriteMode() and \ + event.text() and \ + isChar(event) and \ + not cursor.hasSelection() and \ + cursor.positionInBlock() < cursor.block().length(): + typeOverwrite(event.text()) + if self._vim is not None: + self._vim.keyPressEvent(event) + elif event.matches(QKeySequence.MoveToStartOfLine): + if self._vim is not None and \ + self._vim.keyPressEvent(event): + return + else: + self._onShortcutHome(select=False) + elif event.matches(QKeySequence.SelectStartOfLine): + self._onShortcutHome(select=True) + elif self._rectangularSelection.isExpandKeyEvent(event): + self._rectangularSelection.onExpandKeyEvent(event) + elif shouldAutoIndent(event): + with self: + super().keyPressEvent(event) + self._indenter.autoIndentBlock(cursor.block(), event.text()) + else: + if self._vim is not None: + if self._vim.keyPressEvent(event): + return + + # make action shortcuts override keyboard events (non-default Qt behaviour) + for action in self.actions(): + seq = action.shortcut() + if seq.count() == 1 and key_code(seq[0]) == code(event): + action.trigger() + break + else: + self._lastKeyPressProcessedByParent = True + super().keyPressEvent(event) + + if event.key() == Qt.Key_Escape: + event.accept() + + def terminate(self): + """ Terminate Qutepart instance. + This method MUST be called before application stop to avoid crashes and + some other interesting effects + Call it on close to free memory and stop background highlighting + """ + if self._completer: + self._completer.terminate() + + if self._vim is not None: + self._vim.terminate() + self.text = '' + + def __enter__(self): + """Context management method. + Begin atomic modification + """ + self._atomicModificationDepth = self._atomicModificationDepth + 1 + if self._atomicModificationDepth == 1: + self.textCursor().beginEditBlock() + + def __exit__(self, exc_type, exc_value, traceback): + """Context management method. + End atomic modification + """ + self._atomicModificationDepth = self._atomicModificationDepth - 1 + if self._atomicModificationDepth == 0: + self.textCursor().endEditBlock() + return exc_type is None + + def setFont(self, font): + """Set font and update tab stop width + """ + self._fontBackup = font + QPlainTextEdit.setFont(self, font) + self._updateTabStopWidth() + + # text on line numbers may overlap, if font is bigger, than code font + # Note: the line numbers margin recalculates its width and if it has + # been changed then it calls updateViewport() which in turn will + # update the solid edge line geometry. So there is no need of an + # explicit call self._setSolidEdgeGeometry() here. + lineNumbersMargin = self._line_number_margin + if lineNumbersMargin: + lineNumbersMargin.setFont(font) + + def setup_completer_appearance(self, size, font): + self._completer.setup_appearance(size, font) + + def setAutoComplete(self, enabled): + self.auto_invoke_completions = enabled + + def showEvent(self, ev): + """ Qt 5.big automatically changes font when adding document to workspace. + Workaround this bug """ + super().setFont(self._fontBackup) + return super().showEvent(ev) + + def _updateTabStopWidth(self): + """Update tabstop width after font or indentation changed + """ + self.setTabStopDistance(self.fontMetrics().horizontalAdvance(' ' * self._indenter.width)) + + @property + def lines(self): + return self._lines + + @lines.setter + def lines(self, value): + if not isinstance(value, (list, tuple)) or \ + not all(isinstance(item, str) for item in value): + raise TypeError('Invalid new value of "lines" attribute') + self.setPlainText('\n'.join(value)) + + def _resetCachedText(self): + """Reset toPlainText() result cache + """ + self._cachedText = None + + @property + def text(self): + if self._cachedText is None: + self._cachedText = self.toPlainText() + + return self._cachedText + + @text.setter + def text(self, text): + self.setPlainText(text) + + def textForSaving(self): + """Get text with correct EOL symbols. Use this method for saving a file to storage + """ + lines = self.text.splitlines() + if self.text.endswith('\n'): # splitlines ignores last \n + lines.append('') + return self.eol.join(lines) + self.eol + + def _get_token_at(self, block, column): + dataObject = block.userData() + + if not hasattr(dataObject, 'tokens'): + tokens = list(self.document().highlighter._lexer.get_tokens_unprocessed(block.text())) + dataObject = PygmentsBlockUserData(**{ + 'syntax_stack': dataObject.syntax_stack, + 'tokens': tokens + }) + block.setUserData(dataObject) + else: + tokens = dataObject.tokens + + for next_token in tokens: + c, _, _ = next_token + if c > column: + break + token = next_token + _, token_type, _ = token + + return token_type + + def isComment(self, line, column): + """Check if character at column is a comment + """ + block = self.document().findBlockByNumber(line) + + # here, pygments' highlighter is implemented, so the dataobject + # that is originally defined in Qutepart isn't the same + + # so I'm using pygments' parser, storing it in the data object + + dataObject = block.userData() + if dataObject is None: + return False + if len(dataObject.syntax_stack) > 1: + return True + + token_type = self._get_token_at(block, column) + + def recursive_is_type(token, parent_token): + if token.parent is None: + return False + if token.parent is parent_token: + return True + return recursive_is_type(token.parent, parent_token) + + return recursive_is_type(token_type, Token.Comment) + + def isCode(self, blockOrBlockNumber, column): + """Check if text at given position is a code. + + If language is not known, or text is not parsed yet, ``True`` is returned + """ + if isinstance(blockOrBlockNumber, QTextBlock): + block = blockOrBlockNumber + else: + block = self.document().findBlockByNumber(blockOrBlockNumber) + + # here, pygments' highlighter is implemented, so the dataobject + # that is originally defined in Qutepart isn't the same + + # so I'm using pygments' parser, storing it in the data object + + dataObject = block.userData() + if dataObject is None: + return True + if len(dataObject.syntax_stack) > 1: + return False + + token_type = self._get_token_at(block, column) + + def recursive_is_type(token, parent_token): + if token.parent is None: + return False + if token.parent is parent_token: + return True + return recursive_is_type(token.parent, parent_token) + + return not any(recursive_is_type(token_type, non_code_token) + for non_code_token + in (Token.Comment, Token.String)) + + def _dropUserExtraSelections(self): + if self._userExtraSelections: + self.setExtraSelections([]) + + def setExtraSelections(self, selections): + """Set list of extra selections. + Selections are list of tuples ``(startAbsolutePosition, length)``. + Extra selections are reset on any text modification. + + This is reimplemented method of QPlainTextEdit, it has different signature. + Do not use QPlainTextEdit method + """ + + def _makeQtExtraSelection(startAbsolutePosition, length): + selection = QTextEdit.ExtraSelection() + cursor = QTextCursor(self.document()) + cursor.setPosition(startAbsolutePosition) + cursor.setPosition(startAbsolutePosition + length, QTextCursor.KeepAnchor) + selection.cursor = cursor + selection.format = self._userExtraSelectionFormat + return selection + + self._userExtraSelections = [_makeQtExtraSelection(*item) for item in selections] + self._updateExtraSelections() + + def mapToAbsPosition(self, line, column): + """Convert line and column number to absolute position + """ + block = self.document().findBlockByNumber(line) + if not block.isValid(): + raise IndexError("Invalid line index %d" % line) + if column >= block.length(): + raise IndexError("Invalid column index %d" % column) + return block.position() + column + + def mapToLineCol(self, absPosition): + """Convert absolute position to ``(line, column)`` + """ + block = self.document().findBlock(absPosition) + if not block.isValid(): + raise IndexError("Invalid absolute position %d" % absPosition) + + return (block.blockNumber(), + absPosition - block.position()) + + def resizeEvent(self, event): + """QWidget.resizeEvent() implementation. + Adjust line number area + """ + QPlainTextEdit.resizeEvent(self, event) + self.updateViewport() + + def _insertNewBlock(self): + """Enter pressed. + Insert properly indented block + """ + cursor = self.textCursor() + atStartOfLine = cursor.positionInBlock() == 0 + with self: + cursor.insertBlock() + if not atStartOfLine: # if whole line is moved down - just leave it as is + self._indenter.autoIndentBlock(cursor.block()) + self.ensureCursorVisible() + + def calculate_real_position(self, point): + x = point.x() + self._line_number_margin.width() + return QPoint(x, point.y()) + + def position_widget_at_cursor(self, widget): + # Retrieve current screen height + desktop = QApplication.desktop() + srect = desktop.availableGeometry(desktop.screenNumber(widget)) + + left, top, right, bottom = (srect.left(), srect.top(), + srect.right(), srect.bottom()) + ancestor = widget.parent() + if ancestor: + left = max(left, ancestor.x()) + top = max(top, ancestor.y()) + right = min(right, ancestor.x() + ancestor.width()) + bottom = min(bottom, ancestor.y() + ancestor.height()) + + point = self.cursorRect().bottomRight() + point = self.calculate_real_position(point) + point = self.mapToGlobal(point) + # Move to left of cursor if not enough space on right + widget_right = point.x() + widget.width() + if widget_right > right: + point.setX(point.x() - widget.width()) + # Push to right if not enough space on left + if point.x() < left: + point.setX(left) + + # Moving widget above if there is not enough space below + widget_bottom = point.y() + widget.height() + x_position = point.x() + if widget_bottom > bottom: + point = self.cursorRect().topRight() + point = self.mapToGlobal(point) + point.setX(x_position) + point.setY(point.y() - widget.height()) + + if ancestor is not None: + # Useful only if we set parent to 'ancestor' in __init__ + point = ancestor.mapFromGlobal(point) + + widget.move(point) + + def insert_completion(self, completion, completion_position): + """Insert a completion into the editor. + + completion_position is where the completion was generated. + + The replacement range is computed using the (LSP) completion's + textEdit field if it exists. Otherwise, we replace from the + start of the word under the cursor. + """ + if not completion: + return + + cursor = self.textCursor() + + start = completion['start'] + end = completion['end'] + text = completion['text'] + + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.insertText(text) + self.setTextCursor(cursor) + + def keyReleaseEvent(self, event): + if self._lastKeyPressProcessedByParent and self._completer is not None: + # A hacky way to do not show completion list after a event, processed by vim + + text = event.text() + textTyped = (text and + event.modifiers() in (Qt.NoModifier, Qt.ShiftModifier)) and \ + (text.isalpha() or text.isdigit() or text == '_') + dotTyped = text == '.' + + cursor = self.textCursor() + cursor.movePosition(QTextCursor.PreviousWord, QTextCursor.KeepAnchor) + importTyped = cursor.selectedText() in ['from ', 'import '] + + if (textTyped and self.auto_invoke_completions) \ + or dotTyped or importTyped: + self._completer.invokeCompletionIfAvailable() + + super().keyReleaseEvent(event) + + def mousePressEvent(self, mouseEvent): + if mouseEvent.modifiers() in RectangularSelection.MOUSE_MODIFIERS and \ + mouseEvent.button() == Qt.LeftButton: + self._rectangularSelection.mousePressEvent(mouseEvent) + else: + super().mousePressEvent(mouseEvent) + + def mouseMoveEvent(self, mouseEvent): + if mouseEvent.modifiers() in RectangularSelection.MOUSE_MODIFIERS and \ + mouseEvent.buttons() == Qt.LeftButton: + self._rectangularSelection.mouseMoveEvent(mouseEvent) + else: + super().mouseMoveEvent(mouseEvent) + + def _chooseVisibleWhitespace(self, text): + result = [False for _ in range(len(text))] + + lastNonSpaceColumn = len(text.rstrip()) - 1 + + # Draw not trailing whitespace + if self.drawAnyWhitespace: + # Any + for column, char in enumerate(text[:lastNonSpaceColumn]): + if char.isspace() and \ + (char == '\t' or + column == 0 or + text[column - 1].isspace() or + ((column + 1) < lastNonSpaceColumn and + text[column + 1].isspace())): + result[column] = True + elif self.drawIncorrectIndentation: + # Only incorrect + if self.indentUseTabs: + # Find big space groups + firstNonSpaceColumn = len(text) - len(text.lstrip()) + bigSpaceGroup = ' ' * self.indentWidth + column = 0 + while True: + column = text.find(bigSpaceGroup, column, lastNonSpaceColumn) + if column == -1 or column >= firstNonSpaceColumn: + break + + for index in range(column, column + self.indentWidth): + result[index] = True + while index < lastNonSpaceColumn and \ + text[index] == ' ': + result[index] = True + index += 1 + column = index + else: + # Find tabs: + column = 0 + while column != -1: + column = text.find('\t', column, lastNonSpaceColumn) + if column != -1: + result[column] = True + column += 1 + + # Draw trailing whitespace + if self.drawIncorrectIndentation or self.drawAnyWhitespace: + for column in range(lastNonSpaceColumn + 1, len(text)): + result[column] = True + + return result + + def _drawIndentMarkersAndEdge(self, paintEventRect): + """Draw indentation markers + """ + painter = QPainter(self.viewport()) + + def drawWhiteSpace(block, column, char): + leftCursorRect = self.__cursorRect(block, column, 0) + rightCursorRect = self.__cursorRect(block, column + 1, 0) + if leftCursorRect.top() == rightCursorRect.top(): # if on the same visual line + middleHeight = (leftCursorRect.top() + leftCursorRect.bottom()) // 2 + if char == ' ': + painter.setPen(Qt.transparent) + painter.setBrush(QBrush(Qt.gray)) + xPos = (leftCursorRect.x() + rightCursorRect.x()) // 2 + painter.drawRect(QRect(xPos, middleHeight, 2, 2)) + else: + painter.setPen(QColor(Qt.gray).lighter(factor=120)) + painter.drawLine(leftCursorRect.x() + 3, middleHeight, + rightCursorRect.x() - 3, middleHeight) + + def effectiveEdgePos(text): + """Position of edge in a block. + Defined by self._lineLengthEdge, but visible width of \t is more than 1, + therefore effective position depends on count and position of \t symbols + Return -1 if line is too short to have edge + """ + if self._lineLengthEdge is None: + return -1 + + tabExtraWidth = self.indentWidth - 1 + fullWidth = len(text) + (text.count('\t') * tabExtraWidth) + if fullWidth <= self._lineLengthEdge: + return -1 + + currentWidth = 0 + for pos, char in enumerate(text): + if char == '\t': + # Qt indents up to indentation level, so visible \t width depends on position + currentWidth += (self.indentWidth - (currentWidth % self.indentWidth)) + else: + currentWidth += 1 + if currentWidth > self._lineLengthEdge: + return pos + # line too narrow, probably visible \t width is small + return -1 + + def drawEdgeLine(block, edgePos): + painter.setPen(QPen(QBrush(self._lineLengthEdgeColor), 0)) + rect = self.__cursorRect(block, edgePos, 0) + painter.drawLine(rect.topLeft(), rect.bottomLeft()) + + def drawIndentMarker(block, column): + painter.setPen(QColor(Qt.darkGray).lighter()) + rect = self.__cursorRect(block, column, offset=0) + painter.drawLine(rect.topLeft(), rect.bottomLeft()) + + def drawIndentMarkers(block, text, column): + # this was 6 blocks deep ~irgolic + while text.startswith(self._indenter.text()) and \ + len(text) > indentWidthChars and \ + text[indentWidthChars].isspace(): + + if column != self._lineLengthEdge and \ + (block.blockNumber(), + column) != cursorPos: # looks ugly, if both drawn + # on some fonts line is drawn below the cursor, if offset is 1 + # Looks like Qt bug + drawIndentMarker(block, column) + + text = text[indentWidthChars:] + column += indentWidthChars + + indentWidthChars = len(self._indenter.text()) + cursorPos = self.cursorPosition + + for block in iterateBlocksFrom(self.firstVisibleBlock()): + blockGeometry = self.blockBoundingGeometry(block).translated(self.contentOffset()) + if blockGeometry.top() > paintEventRect.bottom(): + break + + if block.isVisible() and blockGeometry.toRect().intersects(paintEventRect): + + # Draw indent markers, if good indentation is not drawn + if self._drawIndentations: + text = block.text() + if not self.drawAnyWhitespace: + column = indentWidthChars + drawIndentMarkers(block, text, column) + + # Draw edge, but not over a cursor + if not self._drawSolidEdge: + edgePos = effectiveEdgePos(block.text()) + if edgePos not in (-1, cursorPos[1]): + drawEdgeLine(block, edgePos) + + if self.drawAnyWhitespace or \ + self.drawIncorrectIndentation: + text = block.text() + for column, draw in enumerate(self._chooseVisibleWhitespace(text)): + if draw: + drawWhiteSpace(block, column, text[column]) + + def paintEvent(self, event): + """Paint event + Draw indentation markers after main contents is drawn + """ + super().paintEvent(event) + self._drawIndentMarkersAndEdge(event.rect()) + + def _currentLineExtraSelections(self): + """QTextEdit.ExtraSelection, which highlightes current line + """ + if self._currentLineColor is None: + return [] + + def makeSelection(cursor): + selection = QTextEdit.ExtraSelection() + selection.format.setBackground(self._currentLineColor) + selection.format.setProperty(QTextFormat.FullWidthSelection, True) + cursor.clearSelection() + selection.cursor = cursor + return selection + + rectangularSelectionCursors = self._rectangularSelection.cursors() + if rectangularSelectionCursors: + return [makeSelection(cursor) \ + for cursor in rectangularSelectionCursors] + else: + return [makeSelection(self.textCursor())] + + def insertFromMimeData(self, source): + if source.hasFormat(self._rectangularSelection.MIME_TYPE): + self._rectangularSelection.paste(source) + elif source.hasUrls(): + cursor = self.textCursor() + filenames = [url.toLocalFile() for url in source.urls()] + text = ', '.join("'" + f.replace("'", "'\"'\"'") + "'" + for f in filenames) + cursor.insertText(text) + else: + super().insertFromMimeData(source) + + def __cursorRect(self, block, column, offset): + cursor = QTextCursor(block) + setPositionInBlock(cursor, column) + return self.cursorRect(cursor).translated(offset, 0) + + def get_current_word_and_position(self, completion=False, help_req=False, + valid_python_variable=True): + """ + Return current word, i.e. word at cursor position, and the start + position. + """ + cursor = self.textCursor() + cursor_pos = cursor.position() + + if cursor.hasSelection(): + # Removes the selection and moves the cursor to the left side + # of the selection: this is required to be able to properly + # select the whole word under cursor (otherwise, the same word is + # not selected when the cursor is at the right side of it): + cursor.setPosition(min([cursor.selectionStart(), + cursor.selectionEnd()])) + else: + # Checks if the first character to the right is a white space + # and if not, moves the cursor one word to the left (otherwise, + # if the character to the left do not match the "word regexp" + # (see below), the word to the left of the cursor won't be + # selected), but only if the first character to the left is not a + # white space too. + def is_space(move): + curs = self.textCursor() + curs.movePosition(move, QTextCursor.KeepAnchor) + return not str(curs.selectedText()).strip() + + def is_special_character(move): + """Check if a character is a non-letter including numbers.""" + curs = self.textCursor() + curs.movePosition(move, QTextCursor.KeepAnchor) + text_cursor = str(curs.selectedText()).strip() + return len( + re.findall(r'([^\d\W]\w*)', text_cursor, re.UNICODE)) == 0 + + if help_req: + if is_special_character(QTextCursor.PreviousCharacter): + cursor.movePosition(QTextCursor.NextCharacter) + elif is_special_character(QTextCursor.NextCharacter): + cursor.movePosition(QTextCursor.PreviousCharacter) + elif not completion: + if is_space(QTextCursor.NextCharacter): + if is_space(QTextCursor.PreviousCharacter): + return None + cursor.movePosition(QTextCursor.WordLeft) + else: + if is_space(QTextCursor.PreviousCharacter): + return None + if is_special_character(QTextCursor.NextCharacter): + cursor.movePosition(QTextCursor.WordLeft) + + cursor.select(QTextCursor.WordUnderCursor) + text = str(cursor.selectedText()) + startpos = cursor.selectionStart() + + # Find a valid Python variable name + if valid_python_variable: + match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE) + if not match: + return None + else: + text = match[0] + + if completion: + text = text[:cursor_pos - startpos] + + return text, startpos + + def get_current_word(self, completion=False, help_req=False, + valid_python_variable=True): + """Return current word, i.e. word at cursor position.""" + ret = self.get_current_word_and_position( + completion=completion, + help_req=help_req, + valid_python_variable=valid_python_variable + ) + + if ret is not None: + return ret[0] + return None + + +class EdgeLine(QWidget): + def __init__(self, editor): + QWidget.__init__(self, editor) + self.__editor = editor + self.setAttribute(Qt.WA_TransparentForMouseEvents) + + def paintEvent(self, event): + painter = QPainter(self) + painter.fillRect(event.rect(), self.__editor.lineLengthEdgeColor) + + +class LineNumberArea(QWidget): + _LEFT_MARGIN = 5 + _RIGHT_MARGIN = 5 + + def __init__(self, parent): + """qpart: reference to the editor + name: margin identifier + bit_count: number of bits to be used by the margin + """ + super().__init__(parent) + + self._editor = parent + self._name = 'line_numbers' + self._bit_count = 0 + self._bitRange = None + self.__allocateBits() + + self._countCache = (-1, -1) + self._editor.updateRequest.connect(self.__updateRequest) + + self.__width = self.__calculateWidth() + self._editor.blockCountChanged.connect(self.__updateWidth) + + def __updateWidth(self, newBlockCount=None): + newWidth = self.__calculateWidth() + if newWidth != self.__width: + self.__width = newWidth + self._editor.updateViewport() + + def paintEvent(self, event): + """QWidget.paintEvent() implementation + """ + painter = QPainter(self) + painter.fillRect(event.rect(), self.palette().color(QPalette.Window)) + painter.setPen(Qt.black) + + block = self._editor.firstVisibleBlock() + blockNumber = block.blockNumber() + top = int( + self._editor.blockBoundingGeometry(block).translated( + self._editor.contentOffset()).top()) + bottom = top + int(self._editor.blockBoundingRect(block).height()) + + boundingRect = self._editor.blockBoundingRect(block) + availableWidth = self.__width - self._RIGHT_MARGIN - self._LEFT_MARGIN + availableHeight = self._editor.fontMetrics().height() + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + number = str(blockNumber + 1) + painter.drawText(self._LEFT_MARGIN, top, + availableWidth, availableHeight, + Qt.AlignRight, number) + # if boundingRect.height() >= singleBlockHeight * 2: # wrapped block + # painter.fillRect(1, top + singleBlockHeight, + # self.__width - 2, + # boundingRect.height() - singleBlockHeight - 2, + # Qt.darkGreen) + + block = block.next() + boundingRect = self._editor.blockBoundingRect(block) + top = bottom + bottom = top + int(boundingRect.height()) + blockNumber += 1 + + def __calculateWidth(self): + digits = len(str(max(1, self._editor.blockCount()))) + return self._LEFT_MARGIN + self._editor.fontMetrics().horizontalAdvance( + '9') * digits + self._RIGHT_MARGIN + + def width(self): + """Desired width. Includes text and margins + """ + return self.__width + + def setFont(self, font): + super().setFont(font) + self.__updateWidth() + + def __allocateBits(self): + """Allocates the bit range depending on the required bit count + """ + if self._bit_count < 0: + raise Exception("A margin cannot request negative number of bits") + if self._bit_count == 0: + return + + # Build a list of occupied ranges + margins = [self._editor._line_number_margin] + + occupiedRanges = [] + for margin in margins: + bitRange = margin.getBitRange() + if bitRange is not None: + # pick the right position + added = False + for index, r in enumerate(occupiedRanges): + r = occupiedRanges[index] + if bitRange[1] < r[0]: + occupiedRanges.insert(index, bitRange) + added = True + break + if not added: + occupiedRanges.append(bitRange) + + vacant = 0 + for r in occupiedRanges: + if r[0] - vacant >= self._bit_count: + self._bitRange = (vacant, vacant + self._bit_count - 1) + return + vacant = r[1] + 1 + # Not allocated, i.e. grab the tail bits + self._bitRange = (vacant, vacant + self._bit_count - 1) + + def __updateRequest(self, rect, dy): + """Repaint line number area if necessary + """ + if dy: + self.scroll(0, dy) + elif self._countCache[0] != self._editor.blockCount() or \ + self._countCache[1] != self._editor.textCursor().block().lineCount(): + + # if block height not added to rect, last line number sometimes is not drawn + blockHeight = self._editor.blockBoundingRect(self._editor.firstVisibleBlock()).height() + + self.update(0, rect.y(), self.width(), rect.height() + round(blockHeight)) + self._countCache = ( + self._editor.blockCount(), self._editor.textCursor().block().lineCount()) + + if rect.contains(self._editor.viewport().rect()): + self._editor.updateViewportMargins() + + def getName(self): + """Provides the margin identifier + """ + return self._name + + def getBitRange(self): + """None or inclusive bits used pair, + e.g. (2,4) => 3 bits used 2nd, 3rd and 4th + """ + return self._bitRange + + def setBlockValue(self, block, value): + """Sets the required value to the block without damaging the other bits + """ + if self._bit_count == 0: + raise Exception("The margin '" + self._name + + "' did not allocate any bits for the values") + if value < 0: + raise Exception("The margin '" + self._name + + "' must be a positive integer") + + if value >= 2 ** self._bit_count: + raise Exception("The margin '" + self._name + + "' value exceeds the allocated bit range") + + newMarginValue = value << self._bitRange[0] + currentUserState = block.userState() + + if currentUserState in [0, -1]: + block.setUserState(newMarginValue) + else: + marginMask = 2 ** self._bit_count - 1 + otherMarginsValue = currentUserState & ~marginMask + block.setUserState(newMarginValue | otherMarginsValue) + + def getBlockValue(self, block): + """Provides the previously set block value respecting the bits range. + 0 value and not marked block are treated the same way and 0 is + provided. + """ + if self._bit_count == 0: + raise Exception("The margin '" + self._name + + "' did not allocate any bits for the values") + val = block.userState() + if val in [0, -1]: + return 0 + + # Shift the value to the right + val >>= self._bitRange[0] + + # Apply the mask to the value + mask = 2 ** self._bit_count - 1 + val &= mask + return val + + def hide(self): + """Override the QWidget::hide() method to properly recalculate the + editor viewport. + """ + if not self.isHidden(): + super().hide() + self._editor.updateViewport() + + def show(self): + """Override the QWidget::show() method to properly recalculate the + editor viewport. + """ + if self.isHidden(): + super().show() + self._editor.updateViewport() + + def setVisible(self, val): + """Override the QWidget::setVisible(bool) method to properly + recalculate the editor viewport. + """ + if val != self.isVisible(): + if val: + super().setVisible(True) + else: + super().setVisible(False) + self._editor.updateViewport() + + # Convenience methods + + def clear(self): + """Convenience method to reset all the block values to 0 + """ + if self._bit_count == 0: + return + + block = self._editor.document().begin() + while block.isValid(): + if self.getBlockValue(block): + self.setBlockValue(block, 0) + block = block.next() + + # Methods for 1-bit margins + def isBlockMarked(self, block): + return self.getBlockValue(block) != 0 + + def toggleBlockMark(self, block): + self.setBlockValue(block, 0 if self.isBlockMarked(block) else 1) diff --git a/Orange/widgets/data/utils/pythoneditor/indenter.py b/Orange/widgets/data/utils/pythoneditor/indenter.py new file mode 100644 index 00000000000..bf2fd326130 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/indenter.py @@ -0,0 +1,530 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +from AnyQt.QtGui import QTextCursor + +# pylint: disable=pointless-string-statement + +MAX_SEARCH_OFFSET_LINES = 128 + + +class Indenter: + """Qutepart functionality, related to indentation + + Public attributes: + width Indent width + useTabs Indent uses Tabs (instead of spaces) + """ + _DEFAULT_INDENT_WIDTH = 4 + _DEFAULT_INDENT_USE_TABS = False + + def __init__(self, qpart): + self._qpart = qpart + + self.width = self._DEFAULT_INDENT_WIDTH + self.useTabs = self._DEFAULT_INDENT_USE_TABS + + self._smartIndenter = IndentAlgPython(qpart, self) + + def text(self): + """Get indent text as \t or string of spaces + """ + if self.useTabs: + return '\t' + else: + return ' ' * self.width + + def triggerCharacters(self): + """Trigger characters for smart indentation""" + return self._smartIndenter.TRIGGER_CHARACTERS + + def autoIndentBlock(self, block, char='\n'): + """Indent block after Enter pressed or trigger character typed + """ + currentText = block.text() + spaceAtStartLen = len(currentText) - len(currentText.lstrip()) + currentIndent = currentText[:spaceAtStartLen] + indent = self._smartIndenter.computeIndent(block, char) + if indent is not None and indent != currentIndent: + self._qpart.replaceText(block.position(), spaceAtStartLen, indent) + + def onChangeSelectedBlocksIndent(self, increase, withSpace=False): + """Tab or Space pressed and few blocks are selected, or Shift+Tab pressed + Insert or remove text from the beginning of blocks + """ + + def blockIndentation(block): + text = block.text() + return text[:len(text) - len(text.lstrip())] + + def cursorAtSpaceEnd(block): + cursor = QTextCursor(block) + cursor.setPosition(block.position() + len(blockIndentation(block))) + return cursor + + def indentBlock(block): + cursor = cursorAtSpaceEnd(block) + cursor.insertText(' ' if withSpace else self.text()) + + def spacesCount(text): + return len(text) - len(text.rstrip(' ')) + + def unIndentBlock(block): + currentIndent = blockIndentation(block) + + if currentIndent.endswith('\t'): + charsToRemove = 1 + elif withSpace: + charsToRemove = 1 if currentIndent else 0 + else: + if self.useTabs: + charsToRemove = min(spacesCount(currentIndent), self.width) + else: # spaces + if currentIndent.endswith(self.text()): # remove indent level + charsToRemove = self.width + else: # remove all spaces + charsToRemove = min(spacesCount(currentIndent), self.width) + + if charsToRemove: + cursor = cursorAtSpaceEnd(block) + cursor.setPosition(cursor.position() - charsToRemove, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + + cursor = self._qpart.textCursor() + + startBlock = self._qpart.document().findBlock(cursor.selectionStart()) + endBlock = self._qpart.document().findBlock(cursor.selectionEnd()) + if (cursor.selectionStart() != cursor.selectionEnd() and + endBlock.position() == cursor.selectionEnd() and + endBlock.previous().isValid()): + # do not indent not selected line if indenting multiple lines + endBlock = endBlock.previous() + + indentFunc = indentBlock if increase else unIndentBlock + + if startBlock != endBlock: # indent multiply lines + stopBlock = endBlock.next() + + block = startBlock + + with self._qpart: + while block != stopBlock: + indentFunc(block) + block = block.next() + + newCursor = QTextCursor(startBlock) + newCursor.setPosition(endBlock.position() + len(endBlock.text()), + QTextCursor.KeepAnchor) + self._qpart.setTextCursor(newCursor) + else: # indent 1 line + indentFunc(startBlock) + + def onShortcutIndentAfterCursor(self): + """Tab pressed and no selection. Insert text after cursor + """ + cursor = self._qpart.textCursor() + + def insertIndent(): + if self.useTabs: + cursor.insertText('\t') + else: # indent to integer count of indents from line start + charsToInsert = self.width - (len(self._qpart.textBeforeCursor()) % self.width) + cursor.insertText(' ' * charsToInsert) + + if cursor.positionInBlock() == 0: # if no any indent - indent smartly + block = cursor.block() + self.autoIndentBlock(block, '') + + # if no smart indentation - just insert one indent + if self._qpart.textBeforeCursor() == '': + insertIndent() + else: + insertIndent() + + def onShortcutUnindentWithBackspace(self): + """Backspace pressed, unindent + """ + assert self._qpart.textBeforeCursor().endswith(self.text()) + + charsToRemove = len(self._qpart.textBeforeCursor()) % len(self.text()) + if charsToRemove == 0: + charsToRemove = len(self.text()) + + cursor = self._qpart.textCursor() + cursor.setPosition(cursor.position() - charsToRemove, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + + def onAutoIndentTriggered(self): + """Indent current line or selected lines + """ + cursor = self._qpart.textCursor() + + startBlock = self._qpart.document().findBlock(cursor.selectionStart()) + endBlock = self._qpart.document().findBlock(cursor.selectionEnd()) + + if startBlock != endBlock: # indent multiply lines + stopBlock = endBlock.next() + + block = startBlock + + with self._qpart: + while block != stopBlock: + self.autoIndentBlock(block, '') + block = block.next() + else: # indent 1 line + self.autoIndentBlock(startBlock, '') + + +class IndentAlgBase: + """Base class for indenters + """ + TRIGGER_CHARACTERS = "" # indenter is called, when user types Enter of one of trigger chars + + def __init__(self, qpart, indenter): + self._qpart = qpart + self._indenter = indenter + + def indentBlock(self, block): + """Indent the block + """ + self._setBlockIndent(block, self.computeIndent(block, '')) + + def computeIndent(self, block, char): + """Compute indent for the block. + Basic alorightm, which knows nothing about programming languages + May be used by child classes + """ + prevBlockText = block.previous().text() # invalid block returns empty text + if char == '\n' and \ + prevBlockText.strip() == '': # continue indentation, if no text + return self._prevBlockIndent(block) + else: # be smart + return self.computeSmartIndent(block, char) + + def computeSmartIndent(self, block, char): + """Compute smart indent. + Block is current block. + Char is typed character. \n or one of trigger chars + Return indentation text, or None, if indentation shall not be modified + + Implementation might return self._prevNonEmptyBlockIndent(), if doesn't have + any ideas, how to indent text better + """ + raise NotImplementedError() + + def _qpartIndent(self): + """Return text previous block, which is non empty (contains something, except spaces) + Return '', if not found + """ + return self._indenter.text() + + def _increaseIndent(self, indent): + """Add 1 indentation level + """ + return indent + self._qpartIndent() + + def _decreaseIndent(self, indent): + """Remove 1 indentation level + """ + if indent.endswith(self._qpartIndent()): + return indent[:-len(self._qpartIndent())] + else: # oops, strange indentation, just return previous indent + return indent + + def _makeIndentFromWidth(self, width): + """Make indent text with specified with. + Contains width count of spaces, or tabs and spaces + """ + if self._indenter.useTabs: + tabCount, spaceCount = divmod(width, self._indenter.width) + return ('\t' * tabCount) + (' ' * spaceCount) + else: + return ' ' * width + + def _makeIndentAsColumn(self, block, column, offset=0): + """ Make indent equal to column indent. + Shiftted by offset + """ + blockText = block.text() + textBeforeColumn = blockText[:column] + tabCount = textBeforeColumn.count('\t') + + visibleColumn = column + (tabCount * (self._indenter.width - 1)) + return self._makeIndentFromWidth(visibleColumn + offset) + + def _setBlockIndent(self, block, indent): + """Set blocks indent. Modify text in qpart + """ + currentIndent = self._blockIndent(block) + self._qpart.replaceText((block.blockNumber(), 0), len(currentIndent), indent) + + @staticmethod + def iterateBlocksFrom(block): + """Generator, which iterates QTextBlocks from block until the End of a document + But, yields not more than MAX_SEARCH_OFFSET_LINES + """ + count = 0 + while block.isValid() and count < MAX_SEARCH_OFFSET_LINES: + yield block + block = block.next() + count += 1 + + @staticmethod + def iterateBlocksBackFrom(block): + """Generator, which iterates QTextBlocks from block until the Start of a document + But, yields not more than MAX_SEARCH_OFFSET_LINES + """ + count = 0 + while block.isValid() and count < MAX_SEARCH_OFFSET_LINES: + yield block + block = block.previous() + count += 1 + + @classmethod + def iterateCharsBackwardFrom(cls, block, column): + if column is not None: + text = block.text()[:column] + for index, char in enumerate(reversed(text)): + yield block, len(text) - index - 1, char + block = block.previous() + + for b in cls.iterateBlocksBackFrom(block): + for index, char in enumerate(reversed(b.text())): + yield b, len(b.text()) - index - 1, char + + def findBracketBackward(self, block, column, bracket): + """Search for a needle and return (block, column) + Raise ValueError, if not found + """ + if bracket in ('(', ')'): + opening = '(' + closing = ')' + elif bracket in ('[', ']'): + opening = '[' + closing = ']' + elif bracket in ('{', '}'): + opening = '{' + closing = '}' + else: + raise AssertionError('Invalid bracket "%s"' % bracket) + + depth = 1 + for foundBlock, foundColumn, char in self.iterateCharsBackwardFrom(block, column): + if not self._qpart.isComment(foundBlock.blockNumber(), foundColumn): + if char == opening: + depth = depth - 1 + elif char == closing: + depth = depth + 1 + + if depth == 0: + return foundBlock, foundColumn + raise ValueError('Not found') + + def findAnyBracketBackward(self, block, column): + """Search for a needle and return (block, column) + Raise ValueError, if not found + + NOTE this methods ignores strings and comments + """ + depth = {'()': 1, + '[]': 1, + '{}': 1 + } + + for foundBlock, foundColumn, char in self.iterateCharsBackwardFrom(block, column): + if self._qpart.isCode(foundBlock.blockNumber(), foundColumn): + for brackets in depth: + opening, closing = brackets + if char == opening: + depth[brackets] -= 1 + if depth[brackets] == 0: + return foundBlock, foundColumn + elif char == closing: + depth[brackets] += 1 + raise ValueError('Not found') + + @staticmethod + def _lastNonSpaceChar(block): + textStripped = block.text().rstrip() + if textStripped: + return textStripped[-1] + else: + return '' + + @staticmethod + def _firstNonSpaceChar(block): + textStripped = block.text().lstrip() + if textStripped: + return textStripped[0] + else: + return '' + + @staticmethod + def _firstNonSpaceColumn(text): + return len(text) - len(text.lstrip()) + + @staticmethod + def _lastNonSpaceColumn(text): + return len(text.rstrip()) + + @classmethod + def _lineIndent(cls, text): + return text[:cls._firstNonSpaceColumn(text)] + + @classmethod + def _blockIndent(cls, block): + if block.isValid(): + return cls._lineIndent(block.text()) + else: + return '' + + @classmethod + def _prevBlockIndent(cls, block): + prevBlock = block.previous() + + if not block.isValid(): + return '' + + return cls._lineIndent(prevBlock.text()) + + @classmethod + def _prevNonEmptyBlockIndent(cls, block): + return cls._blockIndent(cls._prevNonEmptyBlock(block)) + + @staticmethod + def _prevNonEmptyBlock(block): + if not block.isValid(): + return block + + block = block.previous() + while block.isValid() and \ + len(block.text().strip()) == 0: + block = block.previous() + return block + + @staticmethod + def _nextNonEmptyBlock(block): + if not block.isValid(): + return block + + block = block.next() + while block.isValid() and \ + len(block.text().strip()) == 0: + block = block.next() + return block + + @staticmethod + def _nextNonSpaceColumn(block, column): + """Returns the column with a non-whitespace characters + starting at the given cursor position and searching forwards. + """ + textAfter = block.text()[column:] + if textAfter.strip(): + spaceLen = len(textAfter) - len(textAfter.lstrip()) + return column + spaceLen + else: + return -1 + + +class IndentAlgPython(IndentAlgBase): + """Indenter for Python language. + """ + + def _computeSmartIndent(self, block, column): + """Compute smart indent for case when cursor is on (block, column) + """ + lineStripped = block.text()[:column].strip() # empty text from invalid block is ok + spaceLen = len(block.text()) - len(block.text().lstrip()) + + """Move initial search position to bracket start, if bracket was closed + l = [1, + 2]| + """ + if lineStripped and \ + lineStripped[-1] in ')]}': + try: + backward = self.findBracketBackward(block, spaceLen + len(lineStripped) - 1, + lineStripped[-1]) + foundBlock, foundColumn = backward + except ValueError: + pass + else: + return self._computeSmartIndent(foundBlock, foundColumn) + + """Unindent if hanging indentation finished + func(a, + another_func(a, + b),| + """ + if len(lineStripped) > 1 and \ + lineStripped[-1] == ',' and \ + lineStripped[-2] in ')]}': + + try: + foundBlock, foundColumn = self.findBracketBackward(block, + len(block.text()[ + :column].rstrip()) - 2, + lineStripped[-2]) + except ValueError: + pass + else: + return self._computeSmartIndent(foundBlock, foundColumn) + + """Check hanging indentation + call_func(x, + y, + z + But + call_func(x, + y, + z + """ + try: + foundBlock, foundColumn = self.findAnyBracketBackward(block, + column) + except ValueError: + pass + else: + # indent this way only line, which contains 'y', not 'z' + if foundBlock.blockNumber() == block.blockNumber(): + return self._makeIndentAsColumn(foundBlock, foundColumn + 1) + + # finally, a raise, pass, and continue should unindent + if lineStripped in ('continue', 'break', 'pass', 'raise', 'return') or \ + lineStripped.startswith('raise ') or \ + lineStripped.startswith('return '): + return self._decreaseIndent(self._blockIndent(block)) + + """ + for: + + func(a, + b): + """ + if lineStripped.endswith(':'): + newColumn = spaceLen + len(lineStripped) - 1 + prevIndent = self._computeSmartIndent(block, newColumn) + return self._increaseIndent(prevIndent) + + """ Generally, when a brace is on its own at the end of a regular line + (i.e a data structure is being started), indent is wanted. + For example: + dictionary = { + 'foo': 'bar', + } + """ + if lineStripped.endswith('{['): + return self._increaseIndent(self._blockIndent(block)) + + return self._blockIndent(block) + + def computeSmartIndent(self, block, char): + block = self._prevNonEmptyBlock(block) + column = len(block.text()) + return self._computeSmartIndent(block, column) diff --git a/Orange/widgets/data/utils/pythoneditor/lines.py b/Orange/widgets/data/utils/pythoneditor/lines.py new file mode 100644 index 00000000000..bed65ec6bd2 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/lines.py @@ -0,0 +1,189 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +from AnyQt.QtGui import QTextCursor + +# Lines class. +# list-like object for access text document lines + + +def _iterateBlocksFrom(block): + while block.isValid(): + yield block + block = block.next() + + +def _atomicModification(func): + """Decorator + Make document modification atomic + """ + def wrapper(*args, **kwargs): + self = args[0] + with self._qpart: # pylint: disable=protected-access + func(*args, **kwargs) + return wrapper + + +class Lines: + """list-like object for access text document lines + """ + def __init__(self, qpart): + self._qpart = qpart + self._doc = qpart.document() + + def setDocument(self, document): + self._doc = document + + def _toList(self): + """Convert to Python list + """ + return [block.text() \ + for block in _iterateBlocksFrom(self._doc.firstBlock())] + + def __str__(self): + """Serialize + """ + return str(self._toList()) + + def __len__(self): + """Get lines count + """ + return self._doc.blockCount() + + def _checkAndConvertIndex(self, index): + """Check integer index, convert from less than zero notation + """ + if index < 0: + index = len(self) + index + if index < 0 or index >= self._doc.blockCount(): + raise IndexError('Invalid block index', index) + return index + + def __getitem__(self, index): + """Get item by index + """ + def _getTextByIndex(blockIndex): + return self._doc.findBlockByNumber(blockIndex).text() + + if isinstance(index, int): + index = self._checkAndConvertIndex(index) + return _getTextByIndex(index) + elif isinstance(index, slice): + start, stop, step = index.indices(self._doc.blockCount()) + return [_getTextByIndex(blockIndex) \ + for blockIndex in range(start, stop, step)] + + @_atomicModification + def __setitem__(self, index, value): + """Set item by index + """ + def _setBlockText(blockIndex, text): + cursor = QTextCursor(self._doc.findBlockByNumber(blockIndex)) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + cursor.insertText(text) + + if isinstance(index, int): + index = self._checkAndConvertIndex(index) + _setBlockText(index, value) + elif isinstance(index, slice): + # List of indexes is reversed for make sure + # not processed indexes are not shifted during document modification + start, stop, step = index.indices(self._doc.blockCount()) + if step > 0: + start, stop, step = stop - 1, start - 1, step * -1 + + blockIndexes = list(range(start, stop, step)) + + if len(blockIndexes) != len(value): + raise ValueError('Attempt to replace %d lines with %d lines' % + (len(blockIndexes), len(value))) + + for blockIndex, text in zip(blockIndexes, value[::-1]): + _setBlockText(blockIndex, text) + + @_atomicModification + def __delitem__(self, index): + """Delete item by index + """ + def _removeBlock(blockIndex): + block = self._doc.findBlockByNumber(blockIndex) + if block.next().isValid(): # not the last + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + elif block.previous().isValid(): # the last, not the first + cursor = QTextCursor(block.previous()) + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + else: # only one block + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + + if isinstance(index, int): + index = self._checkAndConvertIndex(index) + _removeBlock(index) + elif isinstance(index, slice): + # List of indexes is reversed for make sure + # not processed indexes are not shifted during document modification + start, stop, step = index.indices(self._doc.blockCount()) + if step > 0: + start, stop, step = stop - 1, start - 1, step * -1 + + for blockIndex in range(start, stop, step): + _removeBlock(blockIndex) + + class _Iterator: + """Blocks iterator. Returns text + """ + def __init__(self, block): + self._block = block + + def __iter__(self): + return self + + def __next__(self): + if self._block.isValid(): + self._block, result = self._block.next(), self._block.text() + return result + else: + raise StopIteration() + + def __iter__(self): + """Return iterator object + """ + return self._Iterator(self._doc.firstBlock()) + + @_atomicModification + def append(self, text): + """Append line to the end + """ + cursor = QTextCursor(self._doc) + cursor.movePosition(QTextCursor.End) + cursor.insertBlock() + cursor.insertText(text) + + @_atomicModification + def insert(self, index, text): + """Insert line to the document + """ + if index < 0 or index > self._doc.blockCount(): + raise IndexError('Invalid block index', index) + + if index == 0: # first + cursor = QTextCursor(self._doc.firstBlock()) + cursor.insertText(text) + cursor.insertBlock() + elif index != self._doc.blockCount(): # not the last + cursor = QTextCursor(self._doc.findBlockByNumber(index).previous()) + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.insertBlock() + cursor.insertText(text) + else: # last append to the end + self.append(text) diff --git a/Orange/widgets/data/utils/pythoneditor/rectangularselection.py b/Orange/widgets/data/utils/pythoneditor/rectangularselection.py new file mode 100644 index 00000000000..1977649e55e --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/rectangularselection.py @@ -0,0 +1,263 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +from AnyQt.QtCore import Qt, QMimeData +from AnyQt.QtWidgets import QApplication, QTextEdit +from AnyQt.QtGui import QKeyEvent, QKeySequence, QPalette, QTextCursor + + +class RectangularSelection: + """This class does not replresent any object, but is part of Qutepart + It just groups together Qutepart rectangular selection methods and fields + """ + + MIME_TYPE = 'text/rectangular-selection' + + # any of this modifiers with mouse select text + MOUSE_MODIFIERS = (Qt.AltModifier | Qt.ControlModifier, + Qt.AltModifier | Qt.ShiftModifier, + Qt.AltModifier) + + _MAX_SIZE = 256 + + def __init__(self, qpart): + self._qpart = qpart + self._start = None + + qpart.cursorPositionChanged.connect(self._reset) # disconnected during Alt+Shift+... + qpart.textChanged.connect(self._reset) + qpart.selectionChanged.connect(self._reset) # disconnected during Alt+Shift+... + + def _reset(self): + """Cursor moved while Alt is not pressed, or text modified. + Reset rectangular selection""" + if self._start is not None: + self._start = None + self._qpart._updateExtraSelections() # pylint: disable=protected-access + + def isDeleteKeyEvent(self, keyEvent): + """Check if key event should be handled as Delete command""" + return self._start is not None and \ + (keyEvent.matches(QKeySequence.Delete) or \ + (keyEvent.key() == Qt.Key_Backspace and keyEvent.modifiers() == Qt.NoModifier)) + + def delete(self): + """Del or Backspace pressed. Delete selection""" + with self._qpart: + for cursor in self.cursors(): + if cursor.hasSelection(): + cursor.deleteChar() + + @staticmethod + def isExpandKeyEvent(keyEvent): + """Check if key event should expand rectangular selection""" + return keyEvent.modifiers() & Qt.ShiftModifier and \ + keyEvent.modifiers() & Qt.AltModifier and \ + keyEvent.key() in (Qt.Key_Left, Qt.Key_Right, Qt.Key_Down, Qt.Key_Up, + Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End) + + def onExpandKeyEvent(self, keyEvent): + """One of expand selection key events""" + if self._start is None: + currentBlockText = self._qpart.textCursor().block().text() + line = self._qpart.cursorPosition[0] + visibleColumn = self._realToVisibleColumn(currentBlockText, + self._qpart.cursorPosition[1]) + self._start = (line, visibleColumn) + modifiersWithoutAltShift = keyEvent.modifiers() & (~(Qt.AltModifier | Qt.ShiftModifier)) + newEvent = QKeyEvent(QKeyEvent.Type(keyEvent.type()), + keyEvent.key(), + modifiersWithoutAltShift, + keyEvent.text(), + keyEvent.isAutoRepeat(), + keyEvent.count()) + + self._qpart.cursorPositionChanged.disconnect(self._reset) + self._qpart.selectionChanged.disconnect(self._reset) + super(self._qpart.__class__, self._qpart).keyPressEvent(newEvent) + self._qpart.cursorPositionChanged.connect(self._reset) + self._qpart.selectionChanged.connect(self._reset) + # extra selections will be updated, because cursor has been moved + + def _visibleCharPositionGenerator(self, text): + currentPos = 0 + yield currentPos + + for char in text: + if char == '\t': + currentPos += self._qpart.indentWidth + # trim reminder. If width('\t') == 4, width('abc\t') == 4 + currentPos = currentPos // self._qpart.indentWidth * self._qpart.indentWidth + else: + currentPos += 1 + yield currentPos + + def _realToVisibleColumn(self, text, realColumn): + """If \t is used, real position of symbol in block and visible position differs + This function converts real to visible + """ + generator = self._visibleCharPositionGenerator(text) + for _ in range(realColumn): + val = next(generator) + val = next(generator) + return val + + def _visibleToRealColumn(self, text, visiblePos): + """If \t is used, real position of symbol in block and visible position differs + This function converts visible to real. + Bigger value is returned, if visiblePos is in the middle of \t, None if text is too short + """ + if visiblePos == 0: + return 0 + elif not '\t' in text: + return visiblePos + else: + currentIndex = 1 + for currentVisiblePos in self._visibleCharPositionGenerator(text): + if currentVisiblePos >= visiblePos: + return currentIndex - 1 + currentIndex += 1 + + return None + + def cursors(self): + """Cursors for rectangular selection. + 1 cursor for every line + """ + cursors = [] + if self._start is not None: + startLine, startVisibleCol = self._start + currentLine, currentCol = self._qpart.cursorPosition + if abs(startLine - currentLine) > self._MAX_SIZE or \ + abs(startVisibleCol - currentCol) > self._MAX_SIZE: + # Too big rectangular selection freezes the GUI + self._qpart.userWarning.emit('Rectangular selection area is too big') + self._start = None + return [] + + currentBlockText = self._qpart.textCursor().block().text() + currentVisibleCol = self._realToVisibleColumn(currentBlockText, currentCol) + + for lineNumber in range(min(startLine, currentLine), + max(startLine, currentLine) + 1): + block = self._qpart.document().findBlockByNumber(lineNumber) + cursor = QTextCursor(block) + realStartCol = self._visibleToRealColumn(block.text(), startVisibleCol) + realCurrentCol = self._visibleToRealColumn(block.text(), currentVisibleCol) + if realStartCol is None: + realStartCol = block.length() # out of range value + if realCurrentCol is None: + realCurrentCol = block.length() # out of range value + + cursor.setPosition(cursor.block().position() + + min(realStartCol, block.length() - 1)) + cursor.setPosition(cursor.block().position() + + min(realCurrentCol, block.length() - 1), + QTextCursor.KeepAnchor) + cursors.append(cursor) + + return cursors + + def selections(self): + """Build list of extra selections for rectangular selection""" + selections = [] + cursors = self.cursors() + if cursors: + background = self._qpart.palette().color(QPalette.Highlight) + foreground = self._qpart.palette().color(QPalette.HighlightedText) + for cursor in cursors: + selection = QTextEdit.ExtraSelection() + selection.format.setBackground(background) + selection.format.setForeground(foreground) + selection.cursor = cursor + + selections.append(selection) + + return selections + + def isActive(self): + """Some rectangle is selected""" + return self._start is not None + + def copy(self): + """Copy to the clipboard""" + data = QMimeData() + text = '\n'.join([cursor.selectedText() \ + for cursor in self.cursors()]) + data.setText(text) + data.setData(self.MIME_TYPE, text.encode('utf8')) + QApplication.clipboard().setMimeData(data) + + def cut(self): + """Cut action. Copy and delete + """ + cursorPos = self._qpart.cursorPosition + topLeft = (min(self._start[0], cursorPos[0]), + min(self._start[1], cursorPos[1])) + self.copy() + self.delete() + + # Move cursor to top-left corner of the selection, + # so that if text gets pasted again, original text will be restored + self._qpart.cursorPosition = topLeft + + def _indentUpTo(self, text, width): + """Add space to text, so text width will be at least width. + Return text, which must be added + """ + visibleTextWidth = self._realToVisibleColumn(text, len(text)) + diff = width - visibleTextWidth + if diff <= 0: + return '' + elif self._qpart.indentUseTabs and \ + all(char == '\t' for char in text): # if using tabs and only tabs in text + return '\t' * (diff // self._qpart.indentWidth) + \ + ' ' * (diff % self._qpart.indentWidth) + else: + return ' ' * int(diff) + + def paste(self, mimeData): + """Paste recrangular selection. + Add space at the beginning of line, if necessary + """ + if self.isActive(): + self.delete() + elif self._qpart.textCursor().hasSelection(): + self._qpart.textCursor().deleteChar() + + text = bytes(mimeData.data(self.MIME_TYPE)).decode('utf8') + lines = text.splitlines() + cursorLine, cursorCol = self._qpart.cursorPosition + if cursorLine + len(lines) > len(self._qpart.lines): + for _ in range(cursorLine + len(lines) - len(self._qpart.lines)): + self._qpart.lines.append('') + + with self._qpart: + for index, line in enumerate(lines): + currentLine = self._qpart.lines[cursorLine + index] + newLine = currentLine[:cursorCol] + \ + self._indentUpTo(currentLine, cursorCol) + \ + line + \ + currentLine[cursorCol:] + self._qpart.lines[cursorLine + index] = newLine + self._qpart.cursorPosition = cursorLine, cursorCol + + def mousePressEvent(self, mouseEvent): + cursor = self._qpart.cursorForPosition(mouseEvent.pos()) + self._start = cursor.block().blockNumber(), cursor.positionInBlock() + + def mouseMoveEvent(self, mouseEvent): + cursor = self._qpart.cursorForPosition(mouseEvent.pos()) + + self._qpart.cursorPositionChanged.disconnect(self._reset) + self._qpart.selectionChanged.disconnect(self._reset) + self._qpart.setTextCursor(cursor) + self._qpart.cursorPositionChanged.connect(self._reset) + self._qpart.selectionChanged.connect(self._reset) + # extra selections will be updated, because cursor has been moved diff --git a/Orange/widgets/data/utils/pythoneditor/tests/__init__.py b/Orange/widgets/data/utils/pythoneditor/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/data/utils/pythoneditor/tests/base.py b/Orange/widgets/data/utils/pythoneditor/tests/base.py new file mode 100644 index 00000000000..a6625e3674c --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/base.py @@ -0,0 +1,45 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +from AnyQt.QtGui import QKeySequence +from AnyQt.QtTest import QTest +from AnyQt.QtCore import Qt + +from orangewidget.utils import enum_as_int + +from Orange.widgets.data.utils.pythoneditor.editor import PythonEditor +from Orange.widgets.data.utils.pythoneditor.vim import key_code +from Orange.widgets.tests.base import GuiTest + + +class EditorTest(GuiTest): + def setUp(self) -> None: + super().setUp() + self.qpart = PythonEditor() + + def tearDown(self) -> None: + self.qpart.terminate() + del self.qpart + super().tearDown() + + +def keySequenceClicks(widget_, keySequence, extraModifiers=Qt.NoModifier): + """Use QTest.keyClick to send a QKeySequence to a widget.""" + # pylint: disable=line-too-long + # This is based on a simplified version of http://stackoverflow.com/questions/14034209/convert-string-representation-of-keycode-to-qtkey-or-any-int-and-back. I added code to handle the case in which the resulting key contains a modifier (for example, Shift+Home). When I execute QTest.keyClick(widget, keyWithModifier), I get the error "ASSERT: "false" in file .\qasciikey.cpp, line 495". To fix this, the following code splits the key into a key and its modifier. + # Bitmask for all modifier keys. + modifierMask = enum_as_int(Qt.KeyboardModifierMask) + ks = QKeySequence(keySequence) + # For now, we don't handle a QKeySequence("Ctrl") or any other modified by itself. + assert ks.count() > 0 + for _, key in enumerate(ks): + key = key_code(key) + modifiers = Qt.KeyboardModifiers((key & modifierMask) | enum_as_int(extraModifiers)) + key = key & ~modifierMask + QTest.keyClick(widget_, Qt.Key(key), modifiers, 10) diff --git a/Orange/widgets/data/utils/pythoneditor/tests/run_all.py b/Orange/widgets/data/utils/pythoneditor/tests/run_all.py new file mode 100644 index 00000000000..016bc0caec7 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/run_all.py @@ -0,0 +1,27 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest +import sys + +if __name__ == "__main__": + # Look for all tests. Using test_* instead of + # test_*.py finds modules (test_syntax and test_indenter). + suite = unittest.TestLoader().discover('.', pattern="test_*") + print("Suite created") + result = unittest.TextTestRunner(verbosity=2).run(suite) + print("Run done") + + # Indicate success or failure via the exit code: success = 0, failure = 1. + if result.wasSuccessful(): + print("OK") + sys.exit(0) + else: + print("Failed") + sys.exit(not result.wasSuccessful()) diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_api.py b/Orange/widgets/data/utils/pythoneditor/tests/test_api.py new file mode 100755 index 00000000000..765fe58a5d5 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_api.py @@ -0,0 +1,274 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from Orange.widgets.data.utils.pythoneditor.tests.base import EditorTest + +# pylint: disable=protected-access + + +class _BaseTest(EditorTest): + """Base class for tests + """ + + +class Selection(_BaseTest): + + def test_resetSelection(self): + # Reset selection + self.qpart.text = 'asdf fdsa' + self.qpart.absSelectedPosition = 1, 3 + self.assertTrue(self.qpart.textCursor().hasSelection()) + self.qpart.resetSelection() + self.assertFalse(self.qpart.textCursor().hasSelection()) + + def test_setSelection(self): + self.qpart.text = 'asdf fdsa' + + self.qpart.selectedPosition = ((0, 3), (0, 7)) + + self.assertEqual(self.qpart.selectedText, "f fd") + self.assertEqual(self.qpart.selectedPosition, ((0, 3), (0, 7))) + + def test_selected_multiline_text(self): + self.qpart.text = "a\nb" + self.qpart.selectedPosition = ((0, 0), (1, 1)) + self.assertEqual(self.qpart.selectedText, "a\nb") + + +class ReplaceText(_BaseTest): + def test_replaceText1(self): + # Basic case + self.qpart.text = '123456789' + self.qpart.replaceText(3, 4, 'xyz') + self.assertEqual(self.qpart.text, '123xyz89') + + def test_replaceText2(self): + # Replace uses (line, col) position + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((1, 4), 3, 'Z') + self.assertEqual(self.qpart.text, '12345\n6789Zbcde') + + def test_replaceText3(self): + # Edge cases + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((0, 0), 3, 'Z') + self.assertEqual(self.qpart.text, 'Z45\n67890\nabcde') + + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((2, 4), 1, 'Z') + self.assertEqual(self.qpart.text, '12345\n67890\nabcdZ') + + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((0, 0), 0, 'Z') + self.assertEqual(self.qpart.text, 'Z12345\n67890\nabcde') + + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText((2, 5), 0, 'Z') + self.assertEqual(self.qpart.text, '12345\n67890\nabcdeZ') + + def test_replaceText4(self): + # Replace nothing with something + self.qpart.text = '12345\n67890\nabcde' + self.qpart.replaceText(2, 0, 'XYZ') + self.assertEqual(self.qpart.text, '12XYZ345\n67890\nabcde') + + def test_replaceText5(self): + # Make sure exceptions are raised for invalid params + self.qpart.text = '12345\n67890\nabcde' + self.assertRaises(IndexError, self.qpart.replaceText, -1, 1, 'Z') + self.assertRaises(IndexError, self.qpart.replaceText, len(self.qpart.text) + 1, 0, 'Z') + self.assertRaises(IndexError, self.qpart.replaceText, len(self.qpart.text), 1, 'Z') + self.assertRaises(IndexError, self.qpart.replaceText, (0, 7), 1, 'Z') + self.assertRaises(IndexError, self.qpart.replaceText, (7, 0), 1, 'Z') + + +class InsertText(_BaseTest): + def test_1(self): + # Basic case + self.qpart.text = '123456789' + self.qpart.insertText(3, 'xyz') + self.assertEqual(self.qpart.text, '123xyz456789') + + def test_2(self): + # (line, col) position + self.qpart.text = '12345\n67890\nabcde' + self.qpart.insertText((1, 4), 'Z') + self.assertEqual(self.qpart.text, '12345\n6789Z0\nabcde') + + def test_3(self): + # Edge cases + self.qpart.text = '12345\n67890\nabcde' + self.qpart.insertText((0, 0), 'Z') + self.assertEqual(self.qpart.text, 'Z12345\n67890\nabcde') + + self.qpart.text = '12345\n67890\nabcde' + self.qpart.insertText((2, 5), 'Z') + self.assertEqual(self.qpart.text, '12345\n67890\nabcdeZ') + + +class IsCodeOrComment(_BaseTest): + def test_1(self): + # Basic case + self.qpart.text = 'a + b # comment' + self.assertEqual([self.qpart.isCode(0, i) for i in range(len(self.qpart.text))], + [True, True, True, True, True, True, False, False, False, False, + False, False, False, False, False]) + self.assertEqual([self.qpart.isComment(0, i) for i in range(len(self.qpart.text))], + [False, False, False, False, False, False, True, True, True, True, + True, True, True, True, True]) + + def test_2(self): + self.qpart.text = '#' + + self.assertFalse(self.qpart.isCode(0, 0)) + self.assertTrue(self.qpart.isComment(0, 0)) + + +class ToggleCommentTest(_BaseTest): + def test_single_line(self): + self.qpart.text = 'a = 2' + self.qpart._onToggleCommentLine() + self.assertEqual('# a = 2\n', self.qpart.text) + self.qpart._onToggleCommentLine() + self.assertEqual('# a = 2\n', self.qpart.text) + self.qpart._selectLines(0, 0) + self.qpart._onToggleCommentLine() + self.assertEqual('a = 2\n', self.qpart.text) + + def test_two_lines(self): + self.qpart.text = 'a = 2\nb = 3' + self.qpart._selectLines(0, 1) + self.qpart._onToggleCommentLine() + self.assertEqual('# a = 2\n# b = 3\n', self.qpart.text) + self.qpart.undo() + self.assertEqual('a = 2\nb = 3', self.qpart.text) + + +class Signals(_BaseTest): + def test_indent_width_changed(self): + newValue = [None] + + def setNeVal(val): + newValue[0] = val + + self.qpart.indentWidthChanged.connect(setNeVal) + + self.qpart.indentWidth = 7 + self.assertEqual(newValue[0], 7) + + def test_use_tabs_changed(self): + newValue = [None] + + def setNeVal(val): + newValue[0] = val + + self.qpart.indentUseTabsChanged.connect(setNeVal) + + self.qpart.indentUseTabs = True + self.assertEqual(newValue[0], True) + + def test_eol_changed(self): + newValue = [None] + + def setNeVal(val): + newValue[0] = val + + self.qpart.eolChanged.connect(setNeVal) + + self.qpart.eol = '\r\n' + self.assertEqual(newValue[0], '\r\n') + + +class Lines(_BaseTest): + def setUp(self): + super().setUp() + self.qpart.text = 'abcd\nefgh\nklmn\nopqr' + + def test_accessByIndex(self): + self.assertEqual(self.qpart.lines[0], 'abcd') + self.assertEqual(self.qpart.lines[1], 'efgh') + self.assertEqual(self.qpart.lines[-1], 'opqr') + + def test_modifyByIndex(self): + self.qpart.lines[2] = 'new text' + self.assertEqual(self.qpart.text, 'abcd\nefgh\nnew text\nopqr') + + def test_getSlice(self): + self.assertEqual(self.qpart.lines[0], 'abcd') + self.assertEqual(self.qpart.lines[1], 'efgh') + self.assertEqual(self.qpart.lines[3], 'opqr') + self.assertEqual(self.qpart.lines[-4], 'abcd') + self.assertEqual(self.qpart.lines[1:4], ['efgh', 'klmn', 'opqr']) + self.assertEqual(self.qpart.lines[1:7], + ['efgh', 'klmn', 'opqr']) # Python list behaves this way + self.assertEqual(self.qpart.lines[0:0], []) + self.assertEqual(self.qpart.lines[0:1], ['abcd']) + self.assertEqual(self.qpart.lines[:2], ['abcd', 'efgh']) + self.assertEqual(self.qpart.lines[0:-2], ['abcd', 'efgh']) + self.assertEqual(self.qpart.lines[-2:], ['klmn', 'opqr']) + self.assertEqual(self.qpart.lines[-4:-2], ['abcd', 'efgh']) + + with self.assertRaises(IndexError): + self.qpart.lines[4] # pylint: disable=pointless-statement + with self.assertRaises(IndexError): + self.qpart.lines[-5] # pylint: disable=pointless-statement + + def test_setSlice_1(self): + self.qpart.lines[0] = 'xyz' + self.assertEqual(self.qpart.text, 'xyz\nefgh\nklmn\nopqr') + + def test_setSlice_2(self): + self.qpart.lines[1] = 'xyz' + self.assertEqual(self.qpart.text, 'abcd\nxyz\nklmn\nopqr') + + def test_setSlice_3(self): + self.qpart.lines[-4] = 'xyz' + self.assertEqual(self.qpart.text, 'xyz\nefgh\nklmn\nopqr') + + def test_setSlice_4(self): + self.qpart.lines[0:4] = ['st', 'uv', 'wx', 'z'] + self.assertEqual(self.qpart.text, 'st\nuv\nwx\nz') + + def test_setSlice_5(self): + self.qpart.lines[0:47] = ['st', 'uv', 'wx', 'z'] + self.assertEqual(self.qpart.text, 'st\nuv\nwx\nz') + + def test_setSlice_6(self): + self.qpart.lines[1:3] = ['st', 'uv'] + self.assertEqual(self.qpart.text, 'abcd\nst\nuv\nopqr') + + def test_setSlice_61(self): + with self.assertRaises(ValueError): + self.qpart.lines[1:3] = ['st', 'uv', 'wx', 'z'] + + def test_setSlice_7(self): + self.qpart.lines[-3:3] = ['st', 'uv'] + self.assertEqual(self.qpart.text, 'abcd\nst\nuv\nopqr') + + def test_setSlice_8(self): + self.qpart.lines[-3:-1] = ['st', 'uv'] + self.assertEqual(self.qpart.text, 'abcd\nst\nuv\nopqr') + + def test_setSlice_9(self): + with self.assertRaises(IndexError): + self.qpart.lines[4] = 'st' + with self.assertRaises(IndexError): + self.qpart.lines[-5] = 'st' + + +class LinesWin(Lines): + def setUp(self): + super().setUp() + self.qpart.eol = '\r\n' + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_bracket_highlighter.py b/Orange/widgets/data/utils/pythoneditor/tests/test_bracket_highlighter.py new file mode 100755 index 00000000000..15374c3f308 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_bracket_highlighter.py @@ -0,0 +1,60 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from Orange.widgets.data.utils.pythoneditor.brackethighlighter import BracketHighlighter +from Orange.widgets.data.utils.pythoneditor.tests.base import EditorTest + + +class Test(EditorTest): + def _verify(self, actual, expected): + converted = [] + for item in actual: + if item.format.foreground().color() == BracketHighlighter.MATCHED_COLOR: + matched = True + elif item.format.foreground().color() == BracketHighlighter.UNMATCHED_COLOR: + matched = False + else: + self.fail("Invalid color") + start = item.cursor.selectionStart() + end = item.cursor.selectionEnd() + converted.append((start, end, matched)) + + self.assertEqual(converted, expected) + + def test_1(self): + self.qpart.lines = \ + ['func(param,', + ' "text ( param"))'] + + firstBlock = self.qpart.document().firstBlock() + secondBlock = firstBlock.next() + + bh = BracketHighlighter() + + self._verify(bh.extraSelections(self.qpart, firstBlock, 1), + []) + + self._verify(bh.extraSelections(self.qpart, firstBlock, 4), + [(4, 5, True), (31, 32, True)]) + self._verify(bh.extraSelections(self.qpart, firstBlock, 5), + [(4, 5, True), (31, 32, True)]) + self._verify(bh.extraSelections(self.qpart, secondBlock, 11), + []) + self._verify(bh.extraSelections(self.qpart, secondBlock, 19), + [(31, 32, True), (4, 5, True)]) + self._verify(bh.extraSelections(self.qpart, secondBlock, 20), + [(32, 33, False)]) + self._verify(bh.extraSelections(self.qpart, secondBlock, 21), + [(32, 33, False)]) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_draw_whitespace.py b/Orange/widgets/data/utils/pythoneditor/tests/test_draw_whitespace.py new file mode 100755 index 00000000000..bb82c36cbbe --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_draw_whitespace.py @@ -0,0 +1,91 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from Orange.widgets.data.utils.pythoneditor.tests.base import EditorTest + + +class Test(EditorTest): + def _ws_test(self, + text, + expectedResult, + drawAny=None, + drawIncorrect=None, + useTab=None, + indentWidth=None): + if drawAny is None: + drawAny = [True, False] + if drawIncorrect is None: + drawIncorrect = [True, False] + if useTab is None: + useTab = [True, False] + if indentWidth is None: + indentWidth = [1, 2, 3, 4, 8] + for drawAnyVal in drawAny: + self.qpart.drawAnyWhitespace = drawAnyVal + + for drawIncorrectVal in drawIncorrect: + self.qpart.drawIncorrectIndentation = drawIncorrectVal + + for useTabVal in useTab: + self.qpart.indentUseTabs = useTabVal + + for indentWidthVal in indentWidth: + self.qpart.indentWidth = indentWidthVal + try: + self._verify(text, expectedResult) + except: + print("Failed params:\n\tany {}\n\tincorrect {}\n\ttabs {}\n\twidth {}" + .format(self.qpart.drawAnyWhitespace, + self.qpart.drawIncorrectIndentation, + self.qpart.indentUseTabs, + self.qpart.indentWidth)) + raise + + def _verify(self, text, expectedResult): + res = self.qpart._chooseVisibleWhitespace(text) # pylint: disable=protected-access + for index, value in enumerate(expectedResult): + if value == '1': + if not res[index]: + self.fail("Item {} is not True:\n\t{}".format(index, res)) + elif value == '0': + if res[index]: + self.fail("Item {} is not False:\n\t{}".format(index, res)) + else: + assert value == ' ' + + def test_1(self): + # Trailing + self._ws_test(' m xyz\t ', + ' 0 00011', + drawIncorrect=[True]) + + def test_2(self): + # Tabs in space mode + self._ws_test('\txyz\t', + '10001', + drawIncorrect=[True], useTab=[False]) + + def test_3(self): + # Spaces in tab mode + self._ws_test(' 2 3 5', + '111100000000000', + drawIncorrect=[True], drawAny=[False], indentWidth=[3], useTab=[True]) + + def test_4(self): + # Draw any + self._ws_test(' 1 1 2 3 5\t', + '100011011101111101', + drawAny=[True], + indentWidth=[2, 3, 4, 8]) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_edit.py b/Orange/widgets/data/utils/pythoneditor/tests/test_edit.py new file mode 100755 index 00000000000..def27aa76e8 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_edit.py @@ -0,0 +1,100 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QKeySequence +from AnyQt.QtTest import QTest + +from Orange.widgets.data.utils.pythoneditor.tests import base +from Orange.widgets.data.utils.pythoneditor.tests.base import EditorTest + + +class Test(EditorTest): + def test_overwrite_edit(self): + self.qpart.show() + self.qpart.text = 'abcd' + QTest.keyClicks(self.qpart, "stu") + self.assertEqual(self.qpart.text, 'stuabcd') + QTest.keyClick(self.qpart, Qt.Key_Insert) + QTest.keyClicks(self.qpart, "xy") + self.assertEqual(self.qpart.text, 'stuxycd') + QTest.keyClick(self.qpart, Qt.Key_Insert) + QTest.keyClicks(self.qpart, "z") + self.assertEqual(self.qpart.text, 'stuxyzcd') + + def test_overwrite_backspace(self): + self.qpart.show() + self.qpart.text = 'abcd' + QTest.keyClick(self.qpart, Qt.Key_Insert) + for _ in range(3): + QTest.keyClick(self.qpart, Qt.Key_Right) + for _ in range(2): + QTest.keyClick(self.qpart, Qt.Key_Backspace) + self.assertEqual(self.qpart.text, 'a d') + + def test_overwrite_undo(self): + self.qpart.show() + self.qpart.text = 'abcd' + QTest.keyClick(self.qpart, Qt.Key_Insert) + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_X) + QTest.keyClick(self.qpart, Qt.Key_X) + self.assertEqual(self.qpart.text, 'axxd') + # Ctrl+Z doesn't work. Wtf??? + self.qpart.document().undo() + self.qpart.document().undo() + self.assertEqual(self.qpart.text, 'abcd') + + def test_home1(self): + """ Test the operation of the home key. """ + + self.qpart.show() + self.qpart.text = ' xx' + # Move to the end of this string. + self.qpart.cursorPosition = (100, 100) + # Press home the first time. This should move to the beginning of the + # indent: line 0, column 4. + self.assertEqual(self.qpart.cursorPosition, (0, 4)) + + def column(self): + """ Return the column at which the cursor is located.""" + return self.qpart.cursorPosition[1] + + def test_home2(self): + """ Test the operation of the home key. """ + + self.qpart.show() + self.qpart.text = '\n\n ' + 'x'*10000 + # Move to the end of this string. + self.qpart.cursorPosition = (100, 100) + # Press home. We should either move to the line beginning or indent. Use + # a QKeySequence because there's no home key on some Macs, so use + # whatever means home on that platform. + base.keySequenceClicks(self.qpart, QKeySequence.MoveToStartOfLine) + # There's no way I can find of determine what the line beginning should + # be. So, just press home again if we're not at the indent. + if self.column() != 4: + # Press home again to move to the beginning of the indent. + base.keySequenceClicks(self.qpart, QKeySequence.MoveToStartOfLine) + # We're at the indent. + self.assertEqual(self.column(), 4) + + # Move to the beginning of the line. + base.keySequenceClicks(self.qpart, QKeySequence.MoveToStartOfLine) + self.assertEqual(self.column(), 0) + + # Move back to the beginning of the indent. + base.keySequenceClicks(self.qpart, QKeySequence.MoveToStartOfLine) + self.assertEqual(self.column(), 4) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_indent.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indent.py new file mode 100755 index 00000000000..0c51f33b2e0 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indent.py @@ -0,0 +1,129 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from AnyQt.QtCore import Qt +from AnyQt.QtTest import QTest + +from Orange.widgets.data.utils.pythoneditor.tests.base import EditorTest + + +class Test(EditorTest): + def test_1(self): + # Indent with Tab + self.qpart.indentUseTabs = True + self.qpart.text = 'ab\ncd' + QTest.keyClick(self.qpart, Qt.Key_Down) + QTest.keyClick(self.qpart, Qt.Key_Tab) + self.assertEqual(self.qpart.text, 'ab\n\tcd') + + self.qpart.indentUseTabs = False + QTest.keyClick(self.qpart, Qt.Key_Backspace) + QTest.keyClick(self.qpart, Qt.Key_Tab) + self.assertEqual(self.qpart.text, 'ab\n cd') + + def test_2(self): + # Unindent Tab + self.qpart.indentUseTabs = True + self.qpart.text = 'ab\n\t\tcd' + self.qpart.cursorPosition = (1, 2) + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab\n\tcd') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab\ncd') + + def test_3(self): + # Unindent Spaces + self.qpart.indentUseTabs = False + + self.qpart.text = 'ab\n cd' + self.qpart.cursorPosition = (1, 6) + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab\n cd') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab\ncd') + + def test_4(self): + # (Un)indent multiline with Tab + self.qpart.indentUseTabs = False + + self.qpart.text = ' ab\n cd' + self.qpart.selectedPosition = ((0, 2), (1, 3)) + + QTest.keyClick(self.qpart, Qt.Key_Tab) + self.assertEqual(self.qpart.text, ' ab\n cd') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, ' ab\n cd') + + def test_4b(self): + # Indent multiline including line with zero selection + self.qpart.indentUseTabs = True + + self.qpart.text = 'ab\ncd\nef' + self.qpart.position = (0, 0) + + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Tab) + self.assertEqual(self.qpart.text, '\tab\ncd\nef') + + @unittest.skip # Fantom crashes happen when running multiple tests. TODO find why + def test_5(self): + # (Un)indent multiline with Space + self.qpart.indentUseTabs = False + + self.qpart.text = ' ab\n cd' + self.qpart.selectedPosition = ((0, 2), (1, 3)) + + QTest.keyClick(self.qpart, Qt.Key_Space, Qt.ShiftModifier | Qt.ControlModifier) + self.assertEqual(self.qpart.text, ' ab\n cd') + + QTest.keyClick(self.qpart, Qt.Key_Backspace, Qt.ShiftModifier | Qt.ControlModifier) + self.assertEqual(self.qpart.text, ' ab\n cd') + + def test_6(self): + # (Unindent Tab/Space mix + self.qpart.indentUseTabs = False + + self.qpart.text = ' \t \tab' + self.qpart.cursorPosition = ((0, 8)) + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, ' \t ab') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, ' \tab') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, ' ab') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab') + + self.qpart.decreaseIndentAction.trigger() + self.assertEqual(self.qpart.text, 'ab') + + def test_7(self): + """Smartly indent python""" + QTest.keyClicks(self.qpart, "def main():") + QTest.keyClick(self.qpart, Qt.Key_Enter) + self.assertEqual(self.qpart.cursorPosition, (1, 4)) + + QTest.keyClicks(self.qpart, "return 7") + QTest.keyClick(self.qpart, Qt.Key_Enter) + self.assertEqual(self.qpart.cursorPosition, (2, 0)) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py new file mode 100644 index 00000000000..9e07399d969 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/__init__.py @@ -0,0 +1,9 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/indenttest.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/indenttest.py new file mode 100644 index 00000000000..bd0863b9fdc --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/indenttest.py @@ -0,0 +1,60 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code + +from AnyQt.QtCore import Qt +from AnyQt.QtTest import QTest + +from Orange.widgets.data.utils.pythoneditor.tests.base import EditorTest + +# pylint: disable=protected-access + + +class IndentTest(EditorTest): + """Base class for tests + """ + + def setUp(self): + super().setUp() + if hasattr(self, 'INDENT_WIDTH'): + self.qpart.indentWidth = self.INDENT_WIDTH + + def setOrigin(self, text): + self.qpart.text = '\n'.join(text) + + def verifyExpected(self, text): + lines = self.qpart.text.split('\n') + self.assertEqual(text, lines) + + def setCursorPosition(self, line, col): + self.qpart.cursorPosition = line, col + + def enter(self): + QTest.keyClick(self.qpart, Qt.Key_Enter) + + def tab(self): + QTest.keyClick(self.qpart, Qt.Key_Tab) + + def type(self, text): + QTest.keyClicks(self.qpart, text) + + def writeCursorPosition(self): + line, col = self.qpart.cursorPosition + text = '(%d,%d)' % (line, col) + self.type(text) + + def writeln(self): + self.qpart.textCursor().insertText('\n') + + def alignLine(self, index): + self.qpart._indenter.autoIndentBlock(self.qpart.document().findBlockByNumber(index), '') + + def alignAll(self): + QTest.keyClick(self.qpart, Qt.Key_A, Qt.ControlModifier) + self.qpart.autoIndentLineAction.trigger() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/test_python.py b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/test_python.py new file mode 100755 index 00000000000..beb9c719003 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_indenter/test_python.py @@ -0,0 +1,337 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from Orange.widgets.data.utils.pythoneditor.tests.test_indenter.indenttest import IndentTest + + +class Test(IndentTest): + LANGUAGE = 'Python' + INDENT_WIDTH = 2 + + def test_dedentReturn(self): + origin = [ + "def some_function():", + " return"] + expected = [ + "def some_function():", + " return", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 11) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_dedentContinue(self): + origin = [ + "while True:", + " continue"] + expected = [ + "while True:", + " continue", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 11) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_keepIndent2(self): + origin = [ + "class my_class():", + " def my_fun():", + ' print "Foo"', + " print 3"] + expected = [ + "class my_class():", + " def my_fun():", + ' print "Foo"', + " print 3", + " pass"] + + self.setOrigin(origin) + + self.setCursorPosition(3, 12) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_keepIndent4(self): + origin = [ + "def some_function():"] + expected = [ + "def some_function():", + " pass", + "", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(0, 22) + self.enter() + self.type("pass") + self.enter() + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_dedentRaise(self): + origin = [ + "try:", + " raise"] + expected = [ + "try:", + " raise", + "except:"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 9) + self.enter() + self.type("except:") + self.verifyExpected(expected) + + def test_indentColon1(self): + origin = [ + "def some_function(param, param2):"] + expected = [ + "def some_function(param, param2):", + " pass"] + + self.setOrigin(origin) + + self.setCursorPosition(0, 34) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_indentColon2(self): + origin = [ + "def some_function(1,", + " 2):" + ] + expected = [ + "def some_function(1,", + " 2):", + " pass" + ] + + self.setOrigin(origin) + + self.setCursorPosition(1, 21) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_indentColon3(self): + """Do not indent colon if hanging indentation used + """ + origin = [ + " a = {1:" + ] + expected = [ + " a = {1:", + " x" + ] + + self.setOrigin(origin) + + self.setCursorPosition(0, 12) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_dedentPass(self): + origin = [ + "def some_function():", + " pass"] + expected = [ + "def some_function():", + " pass", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 8) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_dedentBreak(self): + origin = [ + "def some_function():", + " return"] + expected = [ + "def some_function():", + " return", + "pass"] + + self.setOrigin(origin) + + self.setCursorPosition(1, 11) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_keepIndent3(self): + origin = [ + "while True:", + " returnFunc()", + " myVar = 3"] + expected = [ + "while True:", + " returnFunc()", + " myVar = 3", + " pass"] + + self.setOrigin(origin) + + self.setCursorPosition(2, 12) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_keepIndent1(self): + origin = [ + "def some_function(param, param2):", + " a = 5", + " b = 7"] + expected = [ + "def some_function(param, param2):", + " a = 5", + " b = 7", + " pass"] + + self.setOrigin(origin) + + self.setCursorPosition(2, 8) + self.enter() + self.type("pass") + self.verifyExpected(expected) + + def test_autoIndentAfterEmpty(self): + origin = [ + "while True:", + " returnFunc()", + "", + " myVar = 3"] + expected = [ + "while True:", + " returnFunc()", + "", + " x", + " myVar = 3"] + + self.setOrigin(origin) + + self.setCursorPosition(2, 0) + self.enter() + self.tab() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation(self): + origin = [ + " return func (something,", + ] + expected = [ + " return func (something,", + " x", + ] + + self.setOrigin(origin) + + self.setCursorPosition(0, 28) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation2(self): + origin = [ + " return func (", + " something,", + ] + expected = [ + " return func (", + " something,", + " x", + ] + + self.setOrigin(origin) + + self.setCursorPosition(1, 19) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation3(self): + origin = [ + " a = func (", + " something)", + ] + expected = [ + " a = func (", + " something)", + " x", + ] + + self.setOrigin(origin) + + self.setCursorPosition(1, 19) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation4(self): + origin = [ + " return func(a,", + " another_func(1,", + " 2),", + ] + expected = [ + " return func(a,", + " another_func(1,", + " 2),", + " x" + ] + + self.setOrigin(origin) + + self.setCursorPosition(2, 33) + self.enter() + self.type("x") + self.verifyExpected(expected) + + def test_hangingIndentation5(self): + origin = [ + " return func(another_func(1,", + " 2),", + ] + expected = [ + " return func(another_func(1,", + " 2),", + " x" + ] + + self.setOrigin(origin) + + self.setCursorPosition(2, 33) + self.enter() + self.type("x") + self.verifyExpected(expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py b/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py new file mode 100755 index 00000000000..8df3d20313b --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py @@ -0,0 +1,248 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +# pylint: disable=line-too-long +# pylint: disable=protected-access +# pylint: disable=unused-variable + +from AnyQt.QtCore import Qt +from AnyQt.QtTest import QTest +from AnyQt.QtGui import QKeySequence + +from Orange.widgets.data.utils.pythoneditor.tests import base +from Orange.widgets.data.utils.pythoneditor.tests.base import EditorTest + + +class _Test(EditorTest): + def test_real_to_visible(self): + self.qpart.text = 'abcdfg' + self.assertEqual(0, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 0)) + self.assertEqual(2, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 2)) + self.assertEqual(6, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 6)) + + self.qpart.text = '\tab\tcde\t' + self.assertEqual(0, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 0)) + self.assertEqual(4, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 1)) + self.assertEqual(5, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 2)) + self.assertEqual(8, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 4)) + self.assertEqual(12, self.qpart._rectangularSelection._realToVisibleColumn(self.qpart.text, 8)) + + def test_visible_to_real(self): + self.qpart.text = 'abcdfg' + self.assertEqual(0, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 0)) + self.assertEqual(2, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 2)) + self.assertEqual(6, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 6)) + + self.qpart.text = '\tab\tcde\t' + self.assertEqual(0, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 0)) + self.assertEqual(1, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 4)) + self.assertEqual(2, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 5)) + self.assertEqual(4, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 8)) + self.assertEqual(8, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 12)) + + self.assertEqual(None, self.qpart._rectangularSelection._visibleToRealColumn(self.qpart.text, 13)) + + def test_basic(self): + self.qpart.show() + for key in [Qt.Key_Delete, Qt.Key_Backspace]: + self.qpart.text = 'abcd\nef\nghkl\nmnop' + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, key) + self.assertEqual(self.qpart.text, 'ad\ne\ngl\nmnop') + + def test_reset_by_move(self): + self.qpart.show() + self.qpart.text = 'abcd\nef\nghkl\nmnop' + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Left) + QTest.keyClick(self.qpart, Qt.Key_Backspace) + self.assertEqual(self.qpart.text, 'abcd\nef\ngkl\nmnop') + + def test_reset_by_edit(self): + self.qpart.show() + self.qpart.text = 'abcd\nef\nghkl\nmnop' + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClicks(self.qpart, 'x') + QTest.keyClick(self.qpart, Qt.Key_Backspace) + self.assertEqual(self.qpart.text, 'abcd\nef\nghkl\nmnop') + + def test_with_tabs(self): + self.qpart.show() + self.qpart.text = 'abcdefghhhhh\n\tklm\n\t\txyz' + self.qpart.cursorPosition = (0, 6) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Delete) + + # 3 variants, Qt behavior differs on different systems + self.assertIn(self.qpart.text, ('abcdefhh\n\tkl\n\t\tz', + 'abcdefh\n\tkl\n\t\t', + 'abcdefhhh\n\tkl\n\t\tyz')) + + def test_delete(self): + self.qpart.show() + self.qpart.text = 'this is long\nshort\nthis is long' + self.qpart.cursorPosition = (0, 8) + for i in range(2): + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + + QTest.keyClick(self.qpart, Qt.Key_Delete) + self.assertEqual(self.qpart.text, 'this is \nshort\nthis is ') + + def test_copy_paste(self): + self.qpart.indentUseTabs = True + self.qpart.show() + self.qpart.text = 'xx 123 yy\n' + \ + 'xx 456 yy\n' + \ + 'xx 789 yy\n' + \ + '\n' + \ + 'asdfghijlmn\n' + \ + 'x\t\n' + \ + '\n' + \ + '\t\t\n' + \ + 'end\n' + self.qpart.cursorPosition = 0, 3 + for i in range(3): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + for i in range(2): + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + self.qpart.cursorPosition = 4, 10 + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'xx 123 yy\nxx 456 yy\nxx 789 yy\n\nasdfghijlm123n\nx\t 456\n\t\t 789\n\t\t\nend\n') + + def test_copy_paste_utf8(self): + self.qpart.show() + self.qpart.text = 'фыва' + for i in range(3): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + QTest.keyClick(self.qpart, Qt.Key_Right) + QTest.keyClick(self.qpart, Qt.Key_Space) + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'фыва фыв') + + def test_paste_replace_selection(self): + self.qpart.show() + self.qpart.text = 'asdf' + + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + QTest.keyClick(self.qpart, Qt.Key_End) + QTest.keyClick(self.qpart, Qt.Key_Left, Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'asdasdf') + + def test_paste_replace_rectangular_selection(self): + self.qpart.show() + self.qpart.text = 'asdf' + + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + QTest.keyClick(self.qpart, Qt.Key_Left) + QTest.keyClick(self.qpart, Qt.Key_Left, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'asasdff') + + def test_paste_new_lines(self): + self.qpart.show() + self.qpart.text = 'a\nb\nc\nd' + + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_C, Qt.ControlModifier) + + self.qpart.text = 'x\ny' + self.qpart.cursorPosition = (1, 1) + + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + + self.assertEqual(self.qpart.text, + 'x\nya\n b\n c\n d') + + def test_cut(self): + self.qpart.show() + self.qpart.text = 'asdf' + + for i in range(4): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + QTest.keyClick(self.qpart, Qt.Key_X, Qt.ControlModifier) + self.assertEqual(self.qpart.text, '') + + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + self.assertEqual(self.qpart.text, 'asdf') + + def test_cut_paste(self): + # Cursor must be moved to top-left after cut, and original text is restored after paste + + self.qpart.show() + self.qpart.text = 'abcd\nefgh\nklmn' + + QTest.keyClick(self.qpart, Qt.Key_Right) + for i in range(2): + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.AltModifier | Qt.ShiftModifier) + for i in range(2): + QTest.keyClick(self.qpart, Qt.Key_Down, Qt.AltModifier | Qt.ShiftModifier) + + QTest.keyClick(self.qpart, Qt.Key_X, Qt.ControlModifier) + self.assertEqual(self.qpart.cursorPosition, (0, 1)) + + QTest.keyClick(self.qpart, Qt.Key_V, Qt.ControlModifier) + self.assertEqual(self.qpart.text, 'abcd\nefgh\nklmn') + + def test_warning(self): + self.qpart.show() + self.qpart.text = 'a\n' * 3000 + warning = [None] + def _saveWarning(text): + warning[0] = text + self.qpart.userWarning.connect(_saveWarning) + + base.keySequenceClicks(self.qpart, QKeySequence.SelectEndOfDocument, Qt.AltModifier) + + self.assertEqual(warning[0], 'Rectangular selection area is too big') + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/tests/test_vim.py b/Orange/widgets/data/utils/pythoneditor/tests/test_vim.py new file mode 100755 index 00000000000..96554284c50 --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/tests/test_vim.py @@ -0,0 +1,1039 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" # pylint: disable=duplicate-code +import unittest + +from AnyQt.QtCore import Qt +from AnyQt.QtTest import QTest + +from Orange.widgets.data.utils.pythoneditor.tests.base import EditorTest +from Orange.widgets.data.utils.pythoneditor.vim import _globalClipboard + +# pylint: disable=too-many-lines + + +class _Test(EditorTest): + """Base class for tests + """ + + def setUp(self): + super().setUp() + self.qpart.lines = ['The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back'] + self.qpart.vimModeIndicationChanged.connect(self._onVimModeChanged) + + self.qpart.vimModeEnabled = True + self.vimMode = 'normal' + + def tearDown(self): + self.qpart.hide() + super().tearDown() + + def _onVimModeChanged(self, _, mode): + self.vimMode = mode + + def click(self, keys): + if isinstance(keys, str): + for key in keys: + if key.isupper() or key in '$%^<>': + QTest.keyClick(self.qpart, key, Qt.ShiftModifier) + else: + QTest.keyClicks(self.qpart, key) + else: + QTest.keyClick(self.qpart, keys) + + +class Modes(_Test): + def test_01(self): + """Switch modes insert/normal + """ + self.assertEqual(self.vimMode, 'normal') + self.click("i123") + self.assertEqual(self.vimMode, 'insert') + self.click(Qt.Key_Escape) + self.assertEqual(self.vimMode, 'normal') + self.click("i4") + self.assertEqual(self.vimMode, 'insert') + self.assertEqual(self.qpart.lines[0], + '1234The quick brown fox') + + def test_02(self): + """Append with A + """ + self.qpart.cursorPosition = (2, 0) + self.click("A") + self.assertEqual(self.vimMode, 'insert') + self.click("XY") + + self.assertEqual(self.qpart.lines[2], + 'lazy dogXY') + + def test_03(self): + """Append with a + """ + self.qpart.cursorPosition = (2, 0) + self.click("a") + self.assertEqual(self.vimMode, 'insert') + self.click("XY") + + self.assertEqual(self.qpart.lines[2], + 'lXYazy dog') + + def test_04(self): + """Mode line shows composite command start + """ + self.assertEqual(self.vimMode, 'normal') + self.click('d') + self.assertEqual(self.vimMode, 'd') + self.click('w') + self.assertEqual(self.vimMode, 'normal') + + def test_05(self): + """ Replace mode + """ + self.assertEqual(self.vimMode, 'normal') + self.click('R') + self.assertEqual(self.vimMode, 'replace') + self.click('asdf') + self.assertEqual(self.qpart.lines[0], + 'asdfquick brown fox') + self.click(Qt.Key_Escape) + self.assertEqual(self.vimMode, 'normal') + + self.click('R') + self.assertEqual(self.vimMode, 'replace') + self.click(Qt.Key_Insert) + self.assertEqual(self.vimMode, 'insert') + + def test_05a(self): + """ Replace mode - at end of line + """ + self.click('$') + self.click('R') + self.click('asdf') + self.assertEqual(self.qpart.lines[0], + 'The quick brown foxasdf') + + def test_06(self): + """ Visual mode + """ + self.assertEqual(self.vimMode, 'normal') + + self.click('v') + self.assertEqual(self.vimMode, 'visual') + self.click(Qt.Key_Escape) + self.assertEqual(self.vimMode, 'normal') + + self.click('v') + self.assertEqual(self.vimMode, 'visual') + self.click('i') + self.assertEqual(self.vimMode, 'insert') + + def test_07(self): + """ Switch to visual on selection + """ + QTest.keyClick(self.qpart, Qt.Key_Right, Qt.ShiftModifier) + self.assertEqual(self.vimMode, 'visual') + + def test_08(self): + """ From VISUAL to VISUAL LINES + """ + self.click('v') + self.click('kkk') + self.click('V') + self.assertEqual(self.qpart.selectedText, + 'The quick brown fox') + self.assertEqual(self.vimMode, 'visual lines') + + def test_09(self): + """ From VISUAL LINES to VISUAL + """ + self.click('V') + self.click('v') + self.assertEqual(self.qpart.selectedText, + 'The quick brown fox') + self.assertEqual(self.vimMode, 'visual') + + def test_10(self): + """ Insert mode with I + """ + self.qpart.lines[1] = ' indented line' + self.click('j8lI') + self.click('Z') + self.assertEqual(self.qpart.lines[1], + ' Zindented line') + + +class Move(_Test): + def test_01(self): + """Move hjkl + """ + self.click("ll") + self.assertEqual(self.qpart.cursorPosition, (0, 2)) + + self.click("jjj") + self.assertEqual(self.qpart.cursorPosition, (3, 2)) + + self.click("h") + self.assertEqual(self.qpart.cursorPosition, (3, 1)) + + self.click("k") + # (2, 1) on monospace, (2, 2) on non-monospace font + self.assertIn(self.qpart.cursorPosition, ((2, 1), (2, 2))) + + def test_02(self): + """w + """ + self.qpart.lines[0] = 'word, comma, word' + self.qpart.cursorPosition = (0, 0) + for column in (4, 6, 11, 13, 17, 0): + self.click('w') + self.assertEqual(self.qpart.cursorPosition[1], column) + + self.assertEqual(self.qpart.cursorPosition, (1, 0)) + + def test_03(self): + """e + """ + self.qpart.lines[0] = ' word, comma, word' + self.qpart.cursorPosition = (0, 0) + for column in (6, 7, 13, 14, 19, 5): + self.click('e') + self.assertEqual(self.qpart.cursorPosition[1], column) + + self.assertEqual(self.qpart.cursorPosition, (1, 5)) + + def test_04(self): + """$ + """ + self.click('$') + self.assertEqual(self.qpart.cursorPosition, (0, 19)) + self.click('$') + self.assertEqual(self.qpart.cursorPosition, (0, 19)) + + def test_05(self): + """0 + """ + self.qpart.cursorPosition = (0, 10) + self.click('0') + self.assertEqual(self.qpart.cursorPosition, (0, 0)) + + def test_06(self): + """G + """ + self.qpart.cursorPosition = (0, 10) + self.click('G') + self.assertEqual(self.qpart.cursorPosition, (3, 0)) + + def test_07(self): + """gg + """ + self.qpart.cursorPosition = (2, 10) + self.click('gg') + self.assertEqual(self.qpart.cursorPosition, (00, 0)) + + def test_08(self): + """ b word back + """ + self.qpart.cursorPosition = (0, 19) + self.click('b') + self.assertEqual(self.qpart.cursorPosition, (0, 16)) + + self.click('b') + self.assertEqual(self.qpart.cursorPosition, (0, 10)) + + def test_09(self): + """ % to jump to next braket + """ + self.qpart.lines[0] = '(asdf fdsa) xxx' + self.qpart.cursorPosition = (0, 0) + self.click('%') + self.assertEqual(self.qpart.cursorPosition, + (0, 10)) + + def test_10(self): + """ ^ to jump to the first non-space char + """ + self.qpart.lines[0] = ' indented line' + self.qpart.cursorPosition = (0, 14) + self.click('^') + self.assertEqual(self.qpart.cursorPosition, (0, 4)) + + def test_11(self): + """ f to search forward + """ + self.click('fv') + self.assertEqual(self.qpart.cursorPosition, + (1, 7)) + + def test_12(self): + """ F to search backward + """ + self.qpart.cursorPosition = (2, 0) + self.click('Fv') + self.assertEqual(self.qpart.cursorPosition, + (1, 7)) + + def test_13(self): + """ t to search forward + """ + self.click('tv') + self.assertEqual(self.qpart.cursorPosition, + (1, 6)) + + def test_14(self): + """ T to search backward + """ + self.qpart.cursorPosition = (2, 0) + self.click('Tv') + self.assertEqual(self.qpart.cursorPosition, + (1, 8)) + + def test_15(self): + """ f in a composite command + """ + self.click('dff') + self.assertEqual(self.qpart.lines[0], + 'ox') + + def test_16(self): + """ E + """ + self.qpart.lines[0] = 'asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z' + self.qpart.cursorPosition = (0, 0) + for pos in (5, 6, 8, 9): + self.click('e') + self.assertEqual(self.qpart.cursorPosition[1], + pos) + self.qpart.cursorPosition = (0, 0) + for pos in (10, 22, 34, 45, 5): + self.click('E') + self.assertEqual(self.qpart.cursorPosition[1], + pos) + + def test_17(self): + """ W + """ + self.qpart.lines[0] = 'asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z' + self.qpart.cursorPosition = (0, 0) + for pos in ((0, 12), (0, 24), (0, 35), (1, 0), (1, 6)): + self.click('W') + self.assertEqual(self.qpart.cursorPosition, + pos) + + def test_18(self): + """ B + """ + self.qpart.lines[0] = 'asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z' + self.qpart.cursorPosition = (1, 8) + for pos in ((1, 6), (1, 0), (0, 35), (0, 24), (0, 12)): + self.click('B') + self.assertEqual(self.qpart.cursorPosition, + pos) + + def test_19(self): + """ Enter, Return + """ + self.qpart.lines[1] = ' indented line' + self.qpart.lines[2] = ' more indented line' + self.click(Qt.Key_Enter) + self.assertEqual(self.qpart.cursorPosition, (1, 3)) + self.click(Qt.Key_Return) + self.assertEqual(self.qpart.cursorPosition, (2, 5)) + + +class Del(_Test): + def test_01a(self): + """Delete with x + """ + self.qpart.cursorPosition = (0, 4) + self.click("xxxxx") + + self.assertEqual(self.qpart.lines[0], + 'The brown fox') + self.assertEqual(_globalClipboard.value, 'k') + + def test_01b(self): + """Delete with x. Use count + """ + self.qpart.cursorPosition = (0, 4) + self.click("5x") + + self.assertEqual(self.qpart.lines[0], + 'The brown fox') + self.assertEqual(_globalClipboard.value, 'quick') + + def test_02(self): + """Composite delete with d. Left and right + """ + self.qpart.cursorPosition = (1, 1) + self.click("dl") + self.assertEqual(self.qpart.lines[1], + 'jmps over the') + + self.click("dh") + self.assertEqual(self.qpart.lines[1], + 'mps over the') + + def test_03(self): + """Composite delete with d. Down + """ + self.qpart.cursorPosition = (0, 2) + self.click('dj') + self.assertEqual(self.qpart.lines[:], + ['lazy dog', + 'back']) + self.assertEqual(self.qpart.cursorPosition[1], 0) + + # nothing deleted, if having only one line + self.qpart.cursorPosition = (1, 1) + self.click('dj') + self.assertEqual(self.qpart.lines[:], + ['lazy dog', + 'back']) + + + self.click('k') + self.click('dj') + self.assertEqual(self.qpart.lines[:], + ['']) + self.assertEqual(_globalClipboard.value, + ['lazy dog', + 'back']) + + def test_04(self): + """Composite delete with d. Up + """ + self.qpart.cursorPosition = (0, 2) + self.click('dk') + self.assertEqual(len(self.qpart.lines), 4) + + self.qpart.cursorPosition = (2, 1) + self.click('dk') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'back']) + self.assertEqual(_globalClipboard.value, + ['jumps over the', + 'lazy dog']) + + self.assertEqual(self.qpart.cursorPosition[1], 0) + + def test_05(self): + """Delete Count times + """ + self.click('3dw') + self.assertEqual(self.qpart.lines[0], 'fox') + self.assertEqual(_globalClipboard.value, + 'The quick brown ') + + def test_06(self): + """Delete line + dd + """ + self.qpart.cursorPosition = (1, 0) + self.click('dd') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'lazy dog', + 'back']) + + def test_07(self): + """Delete until end of file + G + """ + self.qpart.cursorPosition = (2, 0) + self.click('dG') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the']) + + def test_08(self): + """Delete until start of file + gg + """ + self.qpart.cursorPosition = (1, 0) + self.click('dgg') + self.assertEqual(self.qpart.lines[:], + ['lazy dog', + 'back']) + + def test_09(self): + """Delete with X + """ + self.click("llX") + + self.assertEqual(self.qpart.lines[0], + 'Te quick brown fox') + + def test_10(self): + """Delete with D + """ + self.click("jll") + self.click("2D") + + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'ju', + 'back']) + + +class Edit(_Test): + def test_01(self): + """Undo + """ + oldText = self.qpart.text + self.click('ddu') + modifiedText = self.qpart.text # pylint: disable=unused-variable + self.assertEqual(self.qpart.text, oldText) + # NOTE this part of test doesn't work. Don't know why. + # self.click('U') + # self.assertEqual(self.qpart.text, modifiedText) + + def test_02(self): + """Change with C + """ + self.click("lllCpig") + + self.assertEqual(self.qpart.lines[0], + 'Thepig') + + def test_03(self): + """ Substitute with s + """ + self.click('j4sz') + self.assertEqual(self.qpart.lines[1], + 'zs over the') + + def test_04(self): + """Replace char with r + """ + self.qpart.cursorPosition = (0, 4) + self.click('rZ') + self.assertEqual(self.qpart.lines[0], + 'The Zuick brown fox') + + self.click('rW') + self.assertEqual(self.qpart.lines[0], + 'The Wuick brown fox') + + def test_05(self): + """Change 2 words with c + """ + self.click('c2e') + self.click('asdf') + self.assertEqual(self.qpart.lines[0], + 'asdf brown fox') + + def test_06(self): + """Open new line with o + """ + self.qpart.lines = [' indented line', + ' next indented line'] + self.click('o') + self.click('asdf') + self.assertEqual(self.qpart.lines[:], + [' indented line', + ' asdf', + ' next indented line']) + + def test_07(self): + """Open new line with O + + Check indentation + """ + self.qpart.lines = [' indented line', + ' next indented line'] + self.click('j') + self.click('O') + self.click('asdf') + self.assertEqual(self.qpart.lines[:], + [' indented line', + ' asdf', + ' next indented line']) + + def test_08(self): + """ Substitute with S + """ + self.qpart.lines = [' indented line', + ' next indented line'] + self.click('ljS') + self.click('xyz') + self.assertEqual(self.qpart.lines[:], + [' indented line', + ' xyz']) + + def test_09(self): + """ % to jump to next braket + """ + self.qpart.lines[0] = '(asdf fdsa) xxx' + self.qpart.cursorPosition = (0, 0) + self.click('d%') + self.assertEqual(self.qpart.lines[0], + ' xxx') + + def test_10(self): + """ J join lines + """ + self.click('2J') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox jumps over the lazy dog', + 'back']) + + +class Indent(_Test): + def test_01(self): + """ Increase indent with >j, decrease with 2j') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + ' lazy dog', + 'back']) + + self.click('>, decrease with << + """ + self.click('>>') + self.click('>>') + self.assertEqual(self.qpart.lines[0], + ' The quick brown fox') + + self.click('<<') + self.assertEqual(self.qpart.lines[0], + ' The quick brown fox') + + def test_03(self): + """ Autoindent with =j + """ + self.click('i ') + self.click(Qt.Key_Escape) + self.click('j') + self.click('=j') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + ' lazy dog', + 'back']) + + def test_04(self): + """ Autoindent with == + """ + self.click('i ') + self.click(Qt.Key_Escape) + self.click('j') + self.click('==') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + 'lazy dog', + 'back']) + + def test_11(self): + """ Increase indent with >, decrease with < in visual mode + """ + self.click('v2>') + self.assertEqual(self.qpart.lines[:2], + [' The quick brown fox', + 'jumps over the']) + + self.click('v<') + self.assertEqual(self.qpart.lines[:2], + [' The quick brown fox', + 'jumps over the']) + + def test_12(self): + """ Autoindent with = in visual mode + """ + self.click('i ') + self.click(Qt.Key_Escape) + self.click('j') + self.click('Vj=') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + ' lazy dog', + 'back']) + + +class CopyPaste(_Test): + def test_02(self): + """Paste text with p + """ + self.qpart.cursorPosition = (0, 4) + self.click("5x") + self.assertEqual(self.qpart.lines[0], + 'The brown fox') + + self.click("p") + self.assertEqual(self.qpart.lines[0], + 'The quickbrown fox') + + def test_03(self): + """Paste lines with p + """ + self.qpart.cursorPosition = (1, 2) + self.click("2dd") + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'back']) + + self.click("kkk") + self.click("p") + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back']) + + def test_04(self): + """Paste lines with P + """ + self.qpart.cursorPosition = (1, 2) + self.click("2dd") + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'back']) + + self.click("P") + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back']) + + def test_05(self): + """ Yank line with yy + """ + self.click('y2y') + self.click('jll') + self.click('p') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the', + 'The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back']) + + def test_06(self): + """ Yank until the end of line + """ + self.click('2wYo') + self.click(Qt.Key_Escape) + self.click('P') + self.assertEqual(self.qpart.lines[1], + 'brown fox') + + def test_08(self): + """ Composite yank with y, paste with P + """ + self.click('y2w') + self.click('P') + self.assertEqual(self.qpart.lines[0], + 'The quick The quick brown fox') + + + + +class Visual(_Test): + def test_01(self): + """ x + """ + self.click('v') + self.assertEqual(self.vimMode, 'visual') + self.click('2w') + self.assertEqual(self.qpart.selectedText, 'The quick ') + self.click('x') + self.assertEqual(self.qpart.lines[0], + 'brown fox') + self.assertEqual(self.vimMode, 'normal') + + def test_02(self): + """Append with a + """ + self.click("vllA") + self.click("asdf ") + self.assertEqual(self.qpart.lines[0], + 'The asdf quick brown fox') + + def test_03(self): + """Replace with r + """ + self.qpart.cursorPosition = (0, 16) + self.click("v8l") + self.click("rz") + self.assertEqual(self.qpart.lines[0:2], + ['The quick brown zzz', + 'zzzzz over the']) + + def test_04(self): + """Replace selected lines with R + """ + self.click("vjl") + self.click("R") + self.click("Z") + self.assertEqual(self.qpart.lines[:], + ['Z', + 'lazy dog', + 'back']) + + def test_05(self): + """Reset selection with u + """ + self.qpart.cursorPosition = (1, 3) + self.click('vjl') + self.click('u') + self.assertEqual(self.qpart.selectedPosition, ((1, 3), (1, 3))) + + def test_06(self): + """Yank with y and paste with p + """ + self.qpart.cursorPosition = (0, 4) + self.click("ve") + #print self.qpart.selectedText + self.click("y") + self.click(Qt.Key_Escape) + self.qpart.cursorPosition = (0, 16) + self.click("ve") + self.click("p") + self.assertEqual(self.qpart.lines[0], + 'The quick brown quick') + + def test_07(self): + """ Replace word when pasting + """ + self.click("vey") # copy word + self.click('ww') # move + self.click('vep') # replace word + self.assertEqual(self.qpart.lines[0], + 'The quick The fox') + + def test_08(self): + """Change with c + """ + self.click("w") + self.click("vec") + self.click("slow") + self.assertEqual(self.qpart.lines[0], + 'The slow brown fox') + + def test_09(self): + """ Delete lines with X and D + """ + self.click('jvlX') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'lazy dog', + 'back']) + + self.click('u') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the', + 'lazy dog', + 'back']) + + self.click('vjD') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'back']) + + def test_10(self): + """ Check if f works + """ + self.click('vfo') + self.assertEqual(self.qpart.selectedText, + 'The quick bro') + + def test_11(self): + """ J join lines + """ + self.click('jvjJ') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + 'jumps over the lazy dog', + 'back']) + + +class VisualLines(_Test): + def test_01(self): + """ x Delete + """ + self.click('V') + self.assertEqual(self.vimMode, 'visual lines') + self.click('x') + self.click('p') + self.assertEqual(self.qpart.lines[:], + ['jumps over the', + 'The quick brown fox', + 'lazy dog', + 'back']) + self.assertEqual(self.vimMode, 'normal') + + def test_02(self): + """ Replace text when pasting + """ + self.click('Vy') + self.click('j') + self.click('Vp') + self.assertEqual(self.qpart.lines[0:3], + ['The quick brown fox', + 'The quick brown fox', + 'lazy dog',]) + + def test_06(self): + """Yank with y and paste with p + """ + self.qpart.cursorPosition = (0, 4) + self.click("V") + self.click("y") + self.click(Qt.Key_Escape) + self.qpart.cursorPosition = (0, 16) + self.click("p") + self.assertEqual(self.qpart.lines[0:3], + ['The quick brown fox', + 'The quick brown fox', + 'jumps over the']) + + def test_07(self): + """Change with c + """ + self.click("Vc") + self.click("slow") + self.assertEqual(self.qpart.lines[0], + 'slow') + + +class Repeat(_Test): + def test_01(self): + """ Repeat o + """ + self.click('o') + self.click(Qt.Key_Escape) + self.click('j2.') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + '', + 'jumps over the', + '', + '', + 'lazy dog', + 'back']) + + def test_02(self): + """ Repeat o. Use count from previous command + """ + self.click('2o') + self.click(Qt.Key_Escape) + self.click('j.') + self.assertEqual(self.qpart.lines[:], + ['The quick brown fox', + '', + '', + 'jumps over the', + '', + '', + 'lazy dog', + 'back']) + + def test_03(self): + """ Repeat O + """ + self.click('O') + self.click(Qt.Key_Escape) + self.click('2j2.') + self.assertEqual(self.qpart.lines[:], + ['', + 'The quick brown fox', + '', + '', + 'jumps over the', + 'lazy dog', + 'back']) + + def test_04(self): + """ Repeat p + """ + self.click('ylp.') + self.assertEqual(self.qpart.lines[0], + 'TTThe quick brown fox') + + def test_05(self): + """ Repeat p + """ + self.click('x...') + self.assertEqual(self.qpart.lines[0], + 'quick brown fox') + + def test_06(self): + """ Repeat D + """ + self.click('Dj.') + self.assertEqual(self.qpart.lines[:], + ['', + '', + 'lazy dog', + 'back']) + + def test_07(self): + """ Repeat dw + """ + self.click('dw') + self.click('j0.') + self.assertEqual(self.qpart.lines[:], + ['quick brown fox', + 'over the', + 'lazy dog', + 'back']) + + def test_08(self): + """ Repeat Visual x + """ + self.qpart.lines.append('one more') + self.click('Vjx') + self.click('.') + self.assertEqual(self.qpart.lines[:], + ['one more']) + + def test_09(self): + """ Repeat visual X + """ + self.qpart.lines.append('one more') + self.click('vjX') + self.click('.') + self.assertEqual(self.qpart.lines[:], + ['one more']) + + def test_10(self): + """ Repeat Visual > + """ + self.qpart.lines.append('one more') + self.click('Vj>') + self.click('3j') + self.click('.') + self.assertEqual(self.qpart.lines[:], + [' The quick brown fox', + ' jumps over the', + 'lazy dog', + ' back', + ' one more']) + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/data/utils/pythoneditor/vim.py b/Orange/widgets/data/utils/pythoneditor/vim.py new file mode 100644 index 00000000000..a14da7b50cb --- /dev/null +++ b/Orange/widgets/data/utils/pythoneditor/vim.py @@ -0,0 +1,1297 @@ +""" +Adapted from a code editor component created +for Enki editor as replacement for QScintilla. +Copyright (C) 2020 Andrei Kopats + +Originally licensed under the terms of GNU Lesser General Public License +as published by the Free Software Foundation, version 2.1 of the license. +This is compatible with Orange3's GPL-3.0 license. +""" +import sys + +from AnyQt.QtCore import Qt, pyqtSignal, QObject +from AnyQt.QtWidgets import QTextEdit +from AnyQt.QtGui import QColor, QTextCursor + +from orangewidget.utils import enum_as_int + +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=too-many-lines +# pylint: disable=too-many-branches + +# This magic code sets variables like _a and _A in the global scope +# pylint: disable=undefined-variable +thismodule = sys.modules[__name__] +for charCode in range(ord('a'), ord('z') + 1): + shortName = chr(charCode) + longName = 'Key_' + shortName.upper() + qtCode = enum_as_int(getattr(Qt, longName)) + setattr(thismodule, '_' + shortName, qtCode) + setattr(thismodule, '_' + shortName.upper(), enum_as_int(Qt.ShiftModifier) | qtCode) + + +def key_code(comb): + try: + return comb.toCombined() + except AttributeError: + return enum_as_int(comb) + + +_0 = key_code(Qt.Key_0) +_Dollar = key_code(Qt.ShiftModifier | Qt.Key_Dollar) +_Percent = key_code(Qt.ShiftModifier | Qt.Key_Percent) +_Caret = key_code(Qt.ShiftModifier | Qt.Key_AsciiCircum) +_Esc = key_code(Qt.Key_Escape) +_Insert = key_code(Qt.Key_Insert) +_Down = key_code(Qt.Key_Down) +_Up = key_code(Qt.Key_Up) +_Left = key_code(Qt.Key_Left) +_Right = key_code(Qt.Key_Right) +_Space = key_code(Qt.Key_Space) +_BackSpace = key_code(Qt.Key_Backspace) +_Equal = key_code(Qt.Key_Equal) +_Less = key_code(Qt.ShiftModifier | Qt.Key_Less) +_Greater = key_code(Qt.ShiftModifier | Qt.Key_Greater) +_Home = key_code(Qt.Key_Home) +_End = key_code(Qt.Key_End) +_PageDown = key_code(Qt.Key_PageDown) +_PageUp = key_code(Qt.Key_PageUp) +_Period = key_code(Qt.Key_Period) +_Enter = key_code(Qt.Key_Enter) +_Return = key_code(Qt.Key_Return) + + +def code(ev): + modifiers = ev.modifiers() + modifiers &= ~Qt.KeypadModifier # ignore keypad modifier to handle both main and numpad numbers + return enum_as_int(modifiers) | ev.key() + + +def isChar(ev): + """ Check if an event may be a typed character + """ + text = ev.text() + if len(text) != 1: + return False + + if ev.modifiers() not in (Qt.ShiftModifier, Qt.KeypadModifier, Qt.NoModifier): + return False + + asciiCode = ord(text) + if asciiCode <= 31 or asciiCode == 0x7f: # control characters + return False + + if text == ' ' and ev.modifiers() == Qt.ShiftModifier: + return False # Shift+Space is a shortcut, not a text + + return True + + +NORMAL = 'normal' +INSERT = 'insert' +REPLACE_CHAR = 'replace character' + +MODE_COLORS = {NORMAL: QColor('#33cc33'), + INSERT: QColor('#ff9900'), + REPLACE_CHAR: QColor('#ff3300')} + + +class _GlobalClipboard: + def __init__(self): + self.value = '' + + +_globalClipboard = _GlobalClipboard() + + +class Vim(QObject): + """Vim mode implementation. + Listens events and does actions + """ + modeIndicationChanged = pyqtSignal(QColor, str) + + def __init__(self, qpart): + QObject.__init__(self) + self._qpart = qpart + self._mode = Normal(self, qpart) + + self._qpart.selectionChanged.connect(self._onSelectionChanged) + self._qpart.document().modificationChanged.connect(self._onModificationChanged) + + self._processingKeyPress = False + + self.updateIndication() + + self.lastEditCmdFunc = None + + def terminate(self): + self._qpart.selectionChanged.disconnect(self._onSelectionChanged) + try: + self._qpart.document().modificationChanged.disconnect(self._onModificationChanged) + except TypeError: + pass + + def indication(self): + return self._mode.color, self._mode.text() + + def updateIndication(self): + self.modeIndicationChanged.emit(*self.indication()) + + def keyPressEvent(self, ev): + """Check the event. Return True if processed and False otherwise + """ + if ev.key() in (Qt.Key_Shift, Qt.Key_Control, + Qt.Key_Meta, Qt.Key_Alt, + Qt.Key_AltGr, Qt.Key_CapsLock, + Qt.Key_NumLock, Qt.Key_ScrollLock): + return False # ignore modifier pressing. Will process key pressing later + + self._processingKeyPress = True + try: + ret = self._mode.keyPressEvent(ev) + finally: + self._processingKeyPress = False + return ret + + def inInsertMode(self): + return isinstance(self._mode, Insert) + + def mode(self): + return self._mode + + def setMode(self, mode): + self._mode = mode + + self._qpart._updateVimExtraSelections() + + self.updateIndication() + + def extraSelections(self): + """ In normal mode - QTextEdit.ExtraSelection which highlightes the cursor + """ + if not isinstance(self._mode, Normal): + return [] + + selection = QTextEdit.ExtraSelection() + selection.format.setBackground(QColor('#ffcc22')) + selection.format.setForeground(QColor('#000000')) + selection.cursor = self._qpart.textCursor() + selection.cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + + return [selection] + + def _onSelectionChanged(self): + if not self._processingKeyPress: + if self._qpart.selectedText: + if not isinstance(self._mode, (Visual, VisualLines)): + self.setMode(Visual(self, self._qpart)) + else: + self.setMode(Normal(self, self._qpart)) + + def _onModificationChanged(self, modified): + if not modified and isinstance(self._mode, Insert): + self.setMode(Normal(self, self._qpart)) + + +class Mode: + # pylint: disable=no-self-use + color = None + + def __init__(self, vim, qpart): + self._vim = vim + self._qpart = qpart + + def text(self): + return None + + def keyPressEvent(self, ev): + pass + + def switchMode(self, modeClass, *args): + mode = modeClass(self._vim, self._qpart, *args) + self._vim.setMode(mode) + + def switchModeAndProcess(self, text, modeClass, *args): + mode = modeClass(self._vim, self._qpart, *args) + self._vim.setMode(mode) + return mode.keyPressEvent(text) + + +class Insert(Mode): + color = QColor('#ff9900') + + def text(self): + return 'insert' + + def keyPressEvent(self, ev): + if ev.key() == Qt.Key_Escape: + self.switchMode(Normal) + return True + + return False + + +class ReplaceChar(Mode): + color = QColor('#ee7777') + + def text(self): + return 'replace char' + + def keyPressEvent(self, ev): + if isChar(ev): # a char + self._qpart.setOverwriteMode(False) + line, col = self._qpart.cursorPosition + if col > 0: + # return the cursor back after replacement + self._qpart.cursorPosition = (line, col - 1) + self.switchMode(Normal) + return True + else: + self._qpart.setOverwriteMode(False) + self.switchMode(Normal) + return False + + +class Replace(Mode): + color = QColor('#ee7777') + + def text(self): + return 'replace' + + def keyPressEvent(self, ev): + if ev.key() == _Insert: + self._qpart.setOverwriteMode(False) + self.switchMode(Insert) + return True + elif ev.key() == _Esc: + self._qpart.setOverwriteMode(False) + self.switchMode(Normal) + return True + else: + return False + + +class BaseCommandMode(Mode): + """ Base class for Normal and Visual modes + """ + + def __init__(self, *args): + Mode.__init__(self, *args) + self._reset() + + def keyPressEvent(self, ev): + self._typedText += ev.text() + try: + self._processCharCoroutine.send(ev) + except StopIteration as ex: + retVal = ex.value + self._reset() + else: + retVal = True + + self._vim.updateIndication() + + return retVal + + def text(self): + return self._typedText or self.name + + def _reset(self): + self._processCharCoroutine = self._processChar() + next(self._processCharCoroutine) # run until the first yield + self._typedText = '' + + _MOTIONS = (_0, _Home, + _Dollar, _End, + _Percent, _Caret, + _b, _B, + _e, _E, + _G, + _j, _Down, + _l, _Right, _Space, + _k, _Up, + _h, _Left, _BackSpace, + _w, _W, + 'gg', + _f, _F, _t, _T, + _PageDown, _PageUp, + _Enter, _Return, + ) + + @staticmethod + def moveToFirstNonSpace(cursor, moveMode): + text = cursor.block().text() + spaceLen = len(text) - len(text.lstrip()) + cursor.setPosition(cursor.block().position() + spaceLen, moveMode) + + def _moveCursor(self, motion, count, searchChar=None, select=False): + """ Move cursor. + Used by Normal and Visual mode + """ + cursor = self._qpart.textCursor() + + effectiveCount = count or 1 + + moveMode = QTextCursor.KeepAnchor if select else QTextCursor.MoveAnchor + + moveOperation = {_b: QTextCursor.WordLeft, + _j: QTextCursor.Down, + _Down: QTextCursor.Down, + _k: QTextCursor.Up, + _Up: QTextCursor.Up, + _h: QTextCursor.Left, + _Left: QTextCursor.Left, + _BackSpace: QTextCursor.Left, + _l: QTextCursor.Right, + _Right: QTextCursor.Right, + _Space: QTextCursor.Right, + _w: QTextCursor.WordRight, + _Dollar: QTextCursor.EndOfBlock, + _End: QTextCursor.EndOfBlock, + _0: QTextCursor.StartOfBlock, + _Home: QTextCursor.StartOfBlock, + 'gg': QTextCursor.Start, + _G: QTextCursor.End + } + + if motion == _G: + if count == 0: # default - go to the end + cursor.movePosition(QTextCursor.End, moveMode) + else: # if count is set - move to line + block = self._qpart.document().findBlockByNumber(count - 1) + if not block.isValid(): + return + cursor.setPosition(block.position(), moveMode) + self.moveToFirstNonSpace(cursor, moveMode) + elif motion in moveOperation: + for _ in range(effectiveCount): + cursor.movePosition(moveOperation[motion], moveMode) + elif motion in (_e, _E): + for _ in range(effectiveCount): + # skip spaces + text = cursor.block().text() + pos = cursor.positionInBlock() + for char in text[pos:]: + if char.isspace(): + cursor.movePosition(QTextCursor.NextCharacter, moveMode) + else: + break + + if cursor.positionInBlock() == len(text): # at the end of line + # move to the next line + cursor.movePosition(QTextCursor.NextCharacter, moveMode) + + # now move to the end of word + if motion == _e: + cursor.movePosition(QTextCursor.EndOfWord, moveMode) + else: + text = cursor.block().text() + pos = cursor.positionInBlock() + for char in text[pos:]: + if not char.isspace(): + cursor.movePosition(QTextCursor.NextCharacter, moveMode) + else: + break + elif motion == _B: + cursor.movePosition(QTextCursor.WordLeft, moveMode) + while cursor.positionInBlock() != 0 and \ + (not cursor.block().text()[cursor.positionInBlock() - 1].isspace()): + cursor.movePosition(QTextCursor.WordLeft, moveMode) + elif motion == _W: + cursor.movePosition(QTextCursor.WordRight, moveMode) + while cursor.positionInBlock() != 0 and \ + (not cursor.block().text()[cursor.positionInBlock() - 1].isspace()): + cursor.movePosition(QTextCursor.WordRight, moveMode) + elif motion == _Percent: + # Percent move is done only once + if self._qpart._bracketHighlighter.currentMatchedBrackets is not None: + ((startBlock, startCol), (endBlock, endCol)) = \ + self._qpart._bracketHighlighter.currentMatchedBrackets + startPos = startBlock.position() + startCol + endPos = endBlock.position() + endCol + if select and \ + (endPos > startPos): + endPos += 1 # to select the bracket, not only chars before it + cursor.setPosition(endPos, moveMode) + elif motion == _Caret: + # Caret move is done only once + self.moveToFirstNonSpace(cursor, moveMode) + elif motion in (_f, _F, _t, _T): + if motion in (_f, _t): + iterator = self._iterateDocumentCharsForward(cursor.block(), cursor.columnNumber()) + stepForward = QTextCursor.Right + stepBack = QTextCursor.Left + else: + iterator = self._iterateDocumentCharsBackward(cursor.block(), cursor.columnNumber()) + stepForward = QTextCursor.Left + stepBack = QTextCursor.Right + + for block, columnIndex, char in iterator: + if char == searchChar: + cursor.setPosition(block.position() + columnIndex, moveMode) + if motion in (_t, _T): + cursor.movePosition(stepBack, moveMode) + if select: + cursor.movePosition(stepForward, moveMode) + break + elif motion in (_PageDown, _PageUp): + cursorHeight = self._qpart.cursorRect().height() + qpartHeight = self._qpart.height() + visibleLineCount = qpartHeight / cursorHeight + direction = QTextCursor.Down if motion == _PageDown else QTextCursor.Up + for _ in range(int(visibleLineCount)): + cursor.movePosition(direction, moveMode) + elif motion in (_Enter, _Return): + if cursor.block().next().isValid(): # not the last line + for _ in range(effectiveCount): + cursor.movePosition(QTextCursor.NextBlock, moveMode) + self.moveToFirstNonSpace(cursor, moveMode) + else: + assert 0, 'Not expected motion ' + str(motion) + + self._qpart.setTextCursor(cursor) + + @staticmethod + def _iterateDocumentCharsForward(block, startColumnIndex): + """Traverse document forward. Yield (block, columnIndex, char) + Raise _TimeoutException if time is over + """ + # Chars in the start line + for columnIndex, char in list(enumerate(block.text()))[startColumnIndex:]: + yield block, columnIndex, char + block = block.next() + + # Next lines + while block.isValid(): + for columnIndex, char in enumerate(block.text()): + yield block, columnIndex, char + + block = block.next() + + @staticmethod + def _iterateDocumentCharsBackward(block, startColumnIndex): + """Traverse document forward. Yield (block, columnIndex, char) + Raise _TimeoutException if time is over + """ + # Chars in the start line + for columnIndex, char in reversed(list(enumerate(block.text()[:startColumnIndex]))): + yield block, columnIndex, char + block = block.previous() + + # Next lines + while block.isValid(): + for columnIndex, char in reversed(list(enumerate(block.text()))): + yield block, columnIndex, char + + block = block.previous() + + def _resetSelection(self, moveToTop=False): + """ Reset selection. + If moveToTop is True - move cursor to the top position + """ + ancor, pos = self._qpart.selectedPosition + dst = min(ancor, pos) if moveToTop else pos + self._qpart.cursorPosition = dst + + def _expandSelection(self): + cursor = self._qpart.textCursor() + anchor = cursor.anchor() + pos = cursor.position() + + if pos >= anchor: + anchorSide = QTextCursor.StartOfBlock + cursorSide = QTextCursor.EndOfBlock + else: + anchorSide = QTextCursor.EndOfBlock + cursorSide = QTextCursor.StartOfBlock + + cursor.setPosition(anchor) + cursor.movePosition(anchorSide) + cursor.setPosition(pos, QTextCursor.KeepAnchor) + cursor.movePosition(cursorSide, QTextCursor.KeepAnchor) + + self._qpart.setTextCursor(cursor) + + +class BaseVisual(BaseCommandMode): + color = QColor('#6699ff') + _selectLines = NotImplementedError() + + def _processChar(self): + ev = yield None + + # Get count + typedCount = 0 + + if ev.key() != _0: + char = ev.text() + while char.isdigit(): + digit = int(char) + typedCount = (typedCount * 10) + digit + ev = yield + char = ev.text() + + count = typedCount if typedCount else 1 + + # Now get the action + action = code(ev) + if action in self._SIMPLE_COMMANDS: + cmdFunc = self._SIMPLE_COMMANDS[action] + for _ in range(count): + cmdFunc(self, action) + if action not in (_v, _V): # if not switched to another visual mode + self._resetSelection(moveToTop=True) + if self._vim.mode() is self: # if the command didn't switch the mode + self.switchMode(Normal) + + return True + elif action == _Esc: + self._resetSelection() + self.switchMode(Normal) + return True + elif action == _g: + ev = yield + if code(ev) == _g: + self._moveCursor('gg', 1, select=True) + if self._selectLines: + self._expandSelection() + return True + elif action in (_f, _F, _t, _T): + ev = yield + if not isChar(ev): + return True + + searchChar = ev.text() + self._moveCursor(action, typedCount, searchChar=searchChar, select=True) + return True + elif action == _z: + ev = yield + if code(ev) == _z: + self._qpart.centerCursor() + return True + elif action in self._MOTIONS: + if self._selectLines and action in (_k, _Up, _j, _Down): + # There is a bug in visual mode: + # If a line is wrapped, cursor moves up, but stays on same line. + # Then selection is expanded and cursor returns to previous position. + # So user can't move the cursor up. So, in Visual mode we move cursor up until it + # moved to previous line. The same bug when moving down + cursorLine = self._qpart.cursorPosition[0] + if (action in (_k, _Up) and cursorLine > 0) or \ + (action in (_j, _Down) and (cursorLine + 1) < len(self._qpart.lines)): + while self._qpart.cursorPosition[0] == cursorLine: + self._moveCursor(action, typedCount, select=True) + else: + self._moveCursor(action, typedCount, select=True) + + if self._selectLines: + self._expandSelection() + return True + elif action == _r: + ev = yield + newChar = ev.text() + if newChar: + newChars = [newChar if char != '\n' else '\n' \ + for char in self._qpart.selectedText + ] + newText = ''.join(newChars) + self._qpart.selectedText = newText + self.switchMode(Normal) + return True + elif isChar(ev): + return True # ignore unknown character + else: + return False # but do not ignore not-a-character keys + + assert 0 # must StopIteration on if + + def _selectedLinesRange(self): + """ Selected lines range for line manipulation methods + """ + (startLine, _), (endLine, _) = self._qpart.selectedPosition + start = min(startLine, endLine) + end = max(startLine, endLine) + return start, end + + def _selectRangeForRepeat(self, repeatLineCount): + start = self._qpart.cursorPosition[0] + self._qpart.selectedPosition = ((start, 0), + (start + repeatLineCount - 1, 0)) + cursor = self._qpart.textCursor() + # expand until the end of line + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + self._qpart.setTextCursor(cursor) + + def _saveLastEditLinesCmd(self, cmd, lineCount): + self._vim.lastEditCmdFunc = lambda: self._SIMPLE_COMMANDS[cmd](self, cmd, lineCount) + + # + # Simple commands + # + + def cmdDelete(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + + cursor = self._qpart.textCursor() + if cursor.selectedText(): + if self._selectLines: + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + _globalClipboard.value = self._qpart.lines[start:end + 1] + del self._qpart.lines[start:end + 1] + else: + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + + def cmdDeleteLines(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + + _globalClipboard.value = self._qpart.lines[start:end + 1] + del self._qpart.lines[start:end + 1] + + def cmdInsertMode(self, cmd): + self.switchMode(Insert) + + def cmdJoinLines(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + + start, end = self._selectedLinesRange() + count = end - start + + if not count: # nothing to join + return + + self._saveLastEditLinesCmd(cmd, end - start + 1) + + cursor = QTextCursor(self._qpart.document().findBlockByNumber(start)) + with self._qpart: + for _ in range(count): + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + self.moveToFirstNonSpace(cursor, QTextCursor.KeepAnchor) + nonEmptyBlock = cursor.block().length() > 1 + cursor.removeSelectedText() + if nonEmptyBlock: + cursor.insertText(' ') + + self._qpart.setTextCursor(cursor) + + def cmdAppendAfterChar(self, cmd): + cursor = self._qpart.textCursor() + cursor.clearSelection() + cursor.movePosition(QTextCursor.Right) + self._qpart.setTextCursor(cursor) + self.switchMode(Insert) + + def cmdReplaceSelectedLines(self, cmd): + start, end = self._selectedLinesRange() + _globalClipboard.value = self._qpart.lines[start:end + 1] + + lastLineLen = len(self._qpart.lines[end]) + self._qpart.selectedPosition = ((start, 0), (end, lastLineLen)) + self._qpart.selectedText = '' + + self.switchMode(Insert) + + def cmdResetSelection(self, cmd): + self._qpart.cursorPosition = self._qpart.selectedPosition[0] + + def cmdInternalPaste(self, cmd): + if not _globalClipboard.value: + return + + with self._qpart: + cursor = self._qpart.textCursor() + + if self._selectLines: + start, end = self._selectedLinesRange() + del self._qpart.lines[start:end + 1] + else: + cursor.removeSelectedText() + + if isinstance(_globalClipboard.value, str): + self._qpart.textCursor().insertText(_globalClipboard.value) + elif isinstance(_globalClipboard.value, list): + currentLineIndex = self._qpart.cursorPosition[0] + text = '\n'.join(_globalClipboard.value) + index = currentLineIndex if self._selectLines else currentLineIndex + 1 + self._qpart.lines.insert(index, text) + + def cmdVisualMode(self, cmd): + if not self._selectLines: + self._resetSelection() + return # already in visual mode + + self.switchMode(Visual) + + def cmdVisualLinesMode(self, cmd): + if self._selectLines: + self._resetSelection() + return # already in visual lines mode + + self.switchMode(VisualLines) + + def cmdYank(self, cmd): + if self._selectLines: + start, end = self._selectedLinesRange() + _globalClipboard.value = self._qpart.lines[start:end + 1] + else: + _globalClipboard.value = self._qpart.selectedText + + self._qpart.copy() + + def cmdChange(self, cmd): + cursor = self._qpart.textCursor() + if cursor.selectedText(): + if self._selectLines: + _globalClipboard.value = cursor.selectedText().splitlines() + else: + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + self.switchMode(Insert) + + def cmdUnIndent(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + else: + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + + self._qpart._indenter.onChangeSelectedBlocksIndent(increase=False, withSpace=False) + + if repeatLineCount: + self._resetSelection(moveToTop=True) + + def cmdIndent(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + else: + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + + self._qpart._indenter.onChangeSelectedBlocksIndent(increase=True, withSpace=False) + + if repeatLineCount: + self._resetSelection(moveToTop=True) + + def cmdAutoIndent(self, cmd, repeatLineCount=None): + if repeatLineCount is not None: + self._selectRangeForRepeat(repeatLineCount) + else: + start, end = self._selectedLinesRange() + self._saveLastEditLinesCmd(cmd, end - start + 1) + + self._qpart._indenter.onAutoIndentTriggered() + + if repeatLineCount: + self._resetSelection(moveToTop=True) + + _SIMPLE_COMMANDS = { + _A: cmdAppendAfterChar, + _c: cmdChange, + _C: cmdReplaceSelectedLines, + _d: cmdDelete, + _D: cmdDeleteLines, + _i: cmdInsertMode, + _J: cmdJoinLines, + _R: cmdReplaceSelectedLines, + _p: cmdInternalPaste, + _u: cmdResetSelection, + _x: cmdDelete, + _s: cmdChange, + _S: cmdReplaceSelectedLines, + _v: cmdVisualMode, + _V: cmdVisualLinesMode, + _X: cmdDeleteLines, + _y: cmdYank, + _Less: cmdUnIndent, + _Greater: cmdIndent, + _Equal: cmdAutoIndent, + } + + +class Visual(BaseVisual): + name = 'visual' + + _selectLines = False + + +class VisualLines(BaseVisual): + name = 'visual lines' + + _selectLines = True + + def __init__(self, *args): + BaseVisual.__init__(self, *args) + self._expandSelection() + + +class Normal(BaseCommandMode): + color = QColor('#33cc33') + name = 'normal' + + def _processChar(self): + ev = yield None + # Get action count + typedCount = 0 + + if ev.key() != _0: + char = ev.text() + while char.isdigit(): + digit = int(char) + typedCount = (typedCount * 10) + digit + ev = yield + char = ev.text() + + effectiveCount = typedCount or 1 + + # Now get the action + action = code(ev) + + if action in self._SIMPLE_COMMANDS: + cmdFunc = self._SIMPLE_COMMANDS[action] + cmdFunc(self, action, effectiveCount) + return True + elif action == _g: + ev = yield + if code(ev) == _g: + self._moveCursor('gg', 1) + + return True + elif action in (_f, _F, _t, _T): + ev = yield + if not isChar(ev): + return True + + searchChar = ev.text() + self._moveCursor(action, effectiveCount, searchChar=searchChar, select=False) + return True + elif action == _Period: # repeat command + if self._vim.lastEditCmdFunc is not None: + if typedCount: + self._vim.lastEditCmdFunc(typedCount) + else: + self._vim.lastEditCmdFunc() + return True + elif action in self._MOTIONS: + self._moveCursor(action, typedCount, select=False) + return True + elif action in self._COMPOSITE_COMMANDS: + moveCount = 0 + ev = yield + + if ev.key() != _0: # 0 is a command, not a count + char = ev.text() + while char.isdigit(): + digit = int(char) + moveCount = (moveCount * 10) + digit + ev = yield + char = ev.text() + + if moveCount == 0: + moveCount = 1 + + count = effectiveCount * moveCount + + # Get motion for a composite command + motion = code(ev) + searchChar = None + + if motion == _g: + ev = yield + if code(ev) == _g: + motion = 'gg' + else: + return True + elif motion in (_f, _F, _t, _T): + ev = yield + if not isChar(ev): + return True + + searchChar = ev.text() + + if (action != _z and motion in self._MOTIONS) or \ + (action, motion) in ((_d, _d), + (_y, _y), + (_Less, _Less), + (_Greater, _Greater), + (_Equal, _Equal), + (_z, _z)): + cmdFunc = self._COMPOSITE_COMMANDS[action] + cmdFunc(self, action, motion, searchChar, count) + + return True + elif isChar(ev): + return True # ignore unknown character + else: + return False # but do not ignore not-a-character keys + + assert 0 # must StopIteration on if + + def _repeat(self, count, func): + """ Repeat action 1 or more times. + If more than one - do it as 1 undoble action + """ + if count != 1: + with self._qpart: + for _ in range(count): + func() + else: + func() + + def _saveLastEditSimpleCmd(self, cmd, count): + def doCmd(count=count): + self._SIMPLE_COMMANDS[cmd](self, cmd, count) + + self._vim.lastEditCmdFunc = doCmd + + def _saveLastEditCompositeCmd(self, cmd, motion, searchChar, count): + def doCmd(count=count): + self._COMPOSITE_COMMANDS[cmd](self, cmd, motion, searchChar, count) + + self._vim.lastEditCmdFunc = doCmd + + # + # Simple commands + # + + def cmdInsertMode(self, cmd, count): + self.switchMode(Insert) + + def cmdInsertAtLineStartMode(self, cmd, count): + cursor = self._qpart.textCursor() + text = cursor.block().text() + spaceLen = len(text) - len(text.lstrip()) + cursor.setPosition(cursor.block().position() + spaceLen) + self._qpart.setTextCursor(cursor) + + self.switchMode(Insert) + + def cmdJoinLines(self, cmd, count): + cursor = self._qpart.textCursor() + if not cursor.block().next().isValid(): # last block + return + + with self._qpart: + for _ in range(count): + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + self.moveToFirstNonSpace(cursor, QTextCursor.KeepAnchor) + nonEmptyBlock = cursor.block().length() > 1 + cursor.removeSelectedText() + if nonEmptyBlock: + cursor.insertText(' ') + + if not cursor.block().next().isValid(): # last block + break + + self._qpart.setTextCursor(cursor) + + def cmdReplaceMode(self, cmd, count): + self.switchMode(Replace) + self._qpart.setOverwriteMode(True) + + def cmdReplaceCharMode(self, cmd, count): + self.switchMode(ReplaceChar) + self._qpart.setOverwriteMode(True) + + def cmdAppendAfterLine(self, cmd, count): + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.EndOfBlock) + self._qpart.setTextCursor(cursor) + self.switchMode(Insert) + + def cmdAppendAfterChar(self, cmd, count): + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.Right) + self._qpart.setTextCursor(cursor) + self.switchMode(Insert) + + def cmdUndo(self, cmd, count): + for _ in range(count): + self._qpart.undo() + + def cmdRedo(self, cmd, count): + for _ in range(count): + self._qpart.redo() + + def cmdNewLineBelow(self, cmd, count): + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.EndOfBlock) + self._qpart.setTextCursor(cursor) + self._repeat(count, self._qpart._insertNewBlock) + + self._saveLastEditSimpleCmd(cmd, count) + + self.switchMode(Insert) + + def cmdNewLineAbove(self, cmd, count): + cursor = self._qpart.textCursor() + + def insert(): + cursor.movePosition(QTextCursor.StartOfBlock) + self._qpart.setTextCursor(cursor) + self._qpart._insertNewBlock() + cursor.movePosition(QTextCursor.Up) + self._qpart._indenter.autoIndentBlock(cursor.block()) + + self._repeat(count, insert) + self._qpart.setTextCursor(cursor) + + self._saveLastEditSimpleCmd(cmd, count) + + self.switchMode(Insert) + + def cmdInternalPaste(self, cmd, count): + if not _globalClipboard.value: + return + + if isinstance(_globalClipboard.value, str): + cursor = self._qpart.textCursor() + if cmd == _p: + cursor.movePosition(QTextCursor.Right) + self._qpart.setTextCursor(cursor) + + self._repeat(count, + lambda: cursor.insertText(_globalClipboard.value)) + cursor.movePosition(QTextCursor.Left) + self._qpart.setTextCursor(cursor) + + elif isinstance(_globalClipboard.value, list): + index = self._qpart.cursorPosition[0] + if cmd == _p: + index += 1 + + self._repeat(count, + lambda: self._qpart.lines.insert(index, '\n'.join(_globalClipboard.value))) + + self._saveLastEditSimpleCmd(cmd, count) + + def cmdSubstitute(self, cmd, count): + """ s + """ + cursor = self._qpart.textCursor() + for _ in range(count): + cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor) + + if cursor.selectedText(): + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + + self._saveLastEditSimpleCmd(cmd, count) + self.switchMode(Insert) + + def cmdSubstituteLines(self, cmd, count): + """ S + """ + lineIndex = self._qpart.cursorPosition[0] + availableCount = len(self._qpart.lines) - lineIndex + effectiveCount = min(availableCount, count) + + _globalClipboard.value = self._qpart.lines[lineIndex:lineIndex + effectiveCount] + with self._qpart: + del self._qpart.lines[lineIndex:lineIndex + effectiveCount] + self._qpart.lines.insert(lineIndex, '') + self._qpart.cursorPosition = (lineIndex, 0) + self._qpart._indenter.autoIndentBlock(self._qpart.textCursor().block()) + + self._saveLastEditSimpleCmd(cmd, count) + self.switchMode(Insert) + + def cmdVisualMode(self, cmd, count): + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + self._qpart.setTextCursor(cursor) + self.switchMode(Visual) + + def cmdVisualLinesMode(self, cmd, count): + self.switchMode(VisualLines) + + def cmdDelete(self, cmd, count): + """ x + """ + cursor = self._qpart.textCursor() + direction = QTextCursor.Left if cmd == _X else QTextCursor.Right + for _ in range(count): + cursor.movePosition(direction, QTextCursor.KeepAnchor) + + if cursor.selectedText(): + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + + self._saveLastEditSimpleCmd(cmd, count) + + def cmdDeleteUntilEndOfBlock(self, cmd, count): + """ C and D + """ + cursor = self._qpart.textCursor() + for _ in range(count - 1): + cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + _globalClipboard.value = cursor.selectedText() + cursor.removeSelectedText() + if cmd == _C: + self.switchMode(Insert) + + self._saveLastEditSimpleCmd(cmd, count) + + def cmdYankUntilEndOfLine(self, cmd, count): + oldCursor = self._qpart.textCursor() + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + _globalClipboard.value = cursor.selectedText() + self._qpart.setTextCursor(cursor) + self._qpart.copy() + self._qpart.setTextCursor(oldCursor) + + _SIMPLE_COMMANDS = {_A: cmdAppendAfterLine, + _a: cmdAppendAfterChar, + _C: cmdDeleteUntilEndOfBlock, + _D: cmdDeleteUntilEndOfBlock, + _i: cmdInsertMode, + _I: cmdInsertAtLineStartMode, + _J: cmdJoinLines, + _r: cmdReplaceCharMode, + _R: cmdReplaceMode, + _v: cmdVisualMode, + _V: cmdVisualLinesMode, + _o: cmdNewLineBelow, + _O: cmdNewLineAbove, + _p: cmdInternalPaste, + _P: cmdInternalPaste, + _s: cmdSubstitute, + _S: cmdSubstituteLines, + _u: cmdUndo, + _U: cmdRedo, + _x: cmdDelete, + _X: cmdDelete, + _Y: cmdYankUntilEndOfLine, + } + + # + # Composite commands + # + + def cmdCompositeDelete(self, cmd, motion, searchChar, count): + if motion in (_j, _Down): + lineIndex = self._qpart.cursorPosition[0] + availableCount = len(self._qpart.lines) - lineIndex + if availableCount < 2: # last line + return + + effectiveCount = min(availableCount, count) + + _globalClipboard.value = self._qpart.lines[lineIndex:lineIndex + effectiveCount + 1] + del self._qpart.lines[lineIndex:lineIndex + effectiveCount + 1] + elif motion in (_k, _Up): + lineIndex = self._qpart.cursorPosition[0] + if lineIndex == 0: # first line + return + + effectiveCount = min(lineIndex, count) + + _globalClipboard.value = self._qpart.lines[lineIndex - effectiveCount:lineIndex + 1] + del self._qpart.lines[lineIndex - effectiveCount:lineIndex + 1] + elif motion == _d: # delete whole line + lineIndex = self._qpart.cursorPosition[0] + availableCount = len(self._qpart.lines) - lineIndex + + effectiveCount = min(availableCount, count) + + _globalClipboard.value = self._qpart.lines[lineIndex:lineIndex + effectiveCount] + del self._qpart.lines[lineIndex:lineIndex + effectiveCount] + elif motion == _G: + currentLineIndex = self._qpart.cursorPosition[0] + _globalClipboard.value = self._qpart.lines[currentLineIndex:] + del self._qpart.lines[currentLineIndex:] + elif motion == 'gg': + currentLineIndex = self._qpart.cursorPosition[0] + _globalClipboard.value = self._qpart.lines[:currentLineIndex + 1] + del self._qpart.lines[:currentLineIndex + 1] + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + + selText = self._qpart.textCursor().selectedText() + if selText: + _globalClipboard.value = selText + self._qpart.textCursor().removeSelectedText() + + self._saveLastEditCompositeCmd(cmd, motion, searchChar, count) + + def cmdCompositeChange(self, cmd, motion, searchChar, count): + # TODO deletion and next insertion should be undo-ble as 1 action + self.cmdCompositeDelete(cmd, motion, searchChar, count) + self.switchMode(Insert) + + def cmdCompositeYank(self, cmd, motion, searchChar, count): + oldCursor = self._qpart.textCursor() + if motion == _y: + cursor = self._qpart.textCursor() + cursor.movePosition(QTextCursor.StartOfBlock) + for _ in range(count - 1): + cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + self._qpart.setTextCursor(cursor) + _globalClipboard.value = [self._qpart.selectedText] + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + _globalClipboard.value = self._qpart.selectedText + + self._qpart.copy() + self._qpart.setTextCursor(oldCursor) + + def cmdCompositeUnIndent(self, cmd, motion, searchChar, count): + if motion == _Less: + pass # current line is already selected + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + self._expandSelection() + + self._qpart._indenter.onChangeSelectedBlocksIndent(increase=False, withSpace=False) + self._resetSelection(moveToTop=True) + + self._saveLastEditCompositeCmd(cmd, motion, searchChar, count) + + def cmdCompositeIndent(self, cmd, motion, searchChar, count): + if motion == _Greater: + pass # current line is already selected + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + self._expandSelection() + + self._qpart._indenter.onChangeSelectedBlocksIndent(increase=True, withSpace=False) + self._resetSelection(moveToTop=True) + + self._saveLastEditCompositeCmd(cmd, motion, searchChar, count) + + def cmdCompositeAutoIndent(self, cmd, motion, searchChar, count): + if motion == _Equal: + pass # current line is already selected + else: + self._moveCursor(motion, count, select=True, searchChar=searchChar) + self._expandSelection() + + self._qpart._indenter.onAutoIndentTriggered() + self._resetSelection(moveToTop=True) + + self._saveLastEditCompositeCmd(cmd, motion, searchChar, count) + + def cmdCompositeScrollView(self, cmd, motion, searchChar, count): + if motion == _z: + self._qpart.centerCursor() + + _COMPOSITE_COMMANDS = {_c: cmdCompositeChange, + _d: cmdCompositeDelete, + _y: cmdCompositeYank, + _Less: cmdCompositeUnIndent, + _Greater: cmdCompositeIndent, + _Equal: cmdCompositeAutoIndent, + _z: cmdCompositeScrollView, + } diff --git a/Orange/widgets/data/utils/tablesummary.py b/Orange/widgets/data/utils/tablesummary.py new file mode 100644 index 00000000000..00f9f4f9463 --- /dev/null +++ b/Orange/widgets/data/utils/tablesummary.py @@ -0,0 +1,125 @@ +from concurrent.futures import Future, ThreadPoolExecutor +from typing import NamedTuple, Optional, List + +import numpy as np + +from Orange.data import Domain, Table, Storage +from Orange.data.sql.table import SqlTable +from Orange.statistics import basic_stats +from Orange.widgets.utils import datacaching +from Orange.widgets.utils.localization import pl + + +# Table Summary +class _ArrayStat(NamedTuple): + # Basic statistics for X/Y/metas arrays + nans: int + non_nans: int + stats: np.ndarray + + +class DenseArray(_ArrayStat): + pass + + +class SparseArray(_ArrayStat): + pass + + +class SparseBoolArray(_ArrayStat): + pass + + +#: Orange.data.Table summary +class Summary(NamedTuple): + len: int + domain: Domain + X: Optional[_ArrayStat] + Y: Optional[_ArrayStat] + M: Optional[_ArrayStat] + + +def table_summary(table: Table) -> Summary: + if isinstance(table, SqlTable): + n_instances = len(table) + return Summary(n_instances, table.domain, None, None, None) + else: + domain = table.domain + n_instances = len(table) + bstats = datacaching.getCached( + table, basic_stats.DomainBasicStats, (table, True) + ) + + dist = bstats.stats + # pylint: disable=unbalanced-tuple-unpacking + X_dist, Y_dist, M_dist = np.split( + dist, np.cumsum([len(domain.attributes), + len(domain.class_vars)])) + + def parts(density, col_dist): + nans = sum(dist.nans for dist in col_dist) + non_nans = sum(dist.non_nans for dist in col_dist) + if density == Storage.DENSE: + return DenseArray(nans, non_nans, col_dist) + elif density == Storage.SPARSE: + return SparseArray(nans, non_nans, col_dist) + elif density == Storage.SPARSE_BOOL: + return SparseBoolArray(nans, non_nans, col_dist) + elif density == Storage.MISSING: + return None + else: + raise ValueError + X_part = parts(table.X_density(), X_dist) + Y_part = parts(table.Y_density(), Y_dist) + M_part = parts(table.metas_density(), M_dist) + return Summary(n_instances, domain, X_part, Y_part, M_part) + + +def format_summary(summary: Summary) -> List[str]: + def format_part(part: Optional[_ArrayStat]) -> str: + if isinstance(part, DenseArray): + if not part.nans: + return "" + perc = 100 * part.nans / (part.nans + part.non_nans) + return f" ({perc:.1f} % missing data)" + + if isinstance(part, SparseArray): + tag = "sparse" + elif isinstance(part, SparseBoolArray): + tag = "tags" + else: # isinstance(part, NotAvailable) + return "" + dens = 100 * part.non_nans / (part.nans + part.non_nans) + return f" ({tag}, density {dens:.2f} %)" + + text = [] + ninst = summary.len + text.append(f"{ninst} {pl(ninst, 'instance')}") + if summary.X is not None and \ + sum(p.nans for p in [summary.X, summary.Y, summary.M]) == 0: + text[-1] += " (no missing data)" + + nattrs = len(summary.domain.attributes) + text.append(f"{nattrs} {pl(nattrs, 'feature')}" + + format_part(summary.X)) + + if not summary.domain.class_vars: + text.append("No target variable.") + else: + nclasses = len(summary.domain.class_vars) + if nclasses > 1: + c_text = f"{nclasses} {pl(nclasses, 'outcome')}" + elif summary.domain.has_continuous_class: + c_text = "Numeric outcome" + else: + nvalues = len(summary.domain.class_var.values) + c_text = f"Target with {nvalues} {pl(nvalues, 'value')}" + text.append(c_text + format_part(summary.Y)) + + nmetas = len(summary.domain.metas) + if nmetas: + text.append(f"{nmetas} {pl(nmetas, 'meta attribute')}" + + format_part(summary.M)) + else: + text.append("No meta attributes.") + return text diff --git a/Orange/widgets/data/utils/tableview.py b/Orange/widgets/data/utils/tableview.py new file mode 100644 index 00000000000..4527dfa9644 --- /dev/null +++ b/Orange/widgets/data/utils/tableview.py @@ -0,0 +1,253 @@ +import sys +from itertools import chain, starmap +from typing import Sequence, Tuple, cast, Optional + +import numpy as np + +from AnyQt.QtCore import ( + Qt, QObject, QEvent, QSize, QAbstractProxyModel, QItemSelection, + QItemSelectionModel, QItemSelectionRange, QAbstractItemModel +) +from AnyQt.QtGui import QPainter +from AnyQt.QtWidgets import ( + QStyle, QWidget, QStyleOptionHeader, QAbstractButton +) + +import Orange.data +import Orange.data.sql.table + +from Orange.widgets.data.utils.models import RichTableModel +from Orange.widgets.utils.itemmodels import TableModel +from Orange.widgets.utils.itemselectionmodel import ( + BlockSelectionModel, selection_blocks, ranges +) +from Orange.widgets.utils.tableview import TableView + + +class DataTableView(TableView): + """ + A TableView with settable corner text. + """ + class __CornerPainter(QObject): + def drawCorner(self, button: QWidget): + opt = QStyleOptionHeader() + view = self.parent() + assert isinstance(view, DataTableView) + header = view.horizontalHeader() + opt.initFrom(header) + state = QStyle.State_None + if button.isEnabled(): + state |= QStyle.State_Enabled + if button.isActiveWindow(): + state |= QStyle.State_Active + if button.isDown(): + state |= QStyle.State_Sunken + opt.state = state + opt.rect = button.rect() + opt.text = button.text() + opt.position = QStyleOptionHeader.OnlyOneSection + style = header.style() + painter = QPainter(button) + style.drawControl(QStyle.CE_Header, opt, painter, header) + + def eventFilter(self, receiver: QObject, event: QEvent) -> bool: + if event.type() == QEvent.Paint: + self.drawCorner(receiver) + return True + return super().eventFilter(receiver, event) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__cornerText = "" + self.__cornerButton = btn = self.findChild(QAbstractButton) + self.__cornerButtonFilter = DataTableView.__CornerPainter(self) + btn.installEventFilter(self.__cornerButtonFilter) + if sys.platform == "darwin": + btn.setAttribute(Qt.WA_MacSmallSize) + + def setCornerText(self, text: str) -> None: + """Set the corner text.""" + self.__cornerButton.setText(text) + self.__cornerText = text + self.__cornerButton.update() + assert self.__cornerButton is self.findChild(QAbstractButton) + opt = QStyleOptionHeader() + opt.initFrom(self.__cornerButton) + opt.text = text + s = self.__cornerButton.style().sizeFromContents( + QStyle.CT_HeaderSection, opt, QSize(), self.__cornerButton + ) + if s.isValid(): + self.verticalHeader().setMinimumWidth(s.width()) + + def cornerText(self): + """Return the corner text.""" + return self.__cornerText + + +def source_model(model: QAbstractItemModel) -> Optional[QAbstractItemModel]: + while isinstance(model, QAbstractProxyModel): + model = model.sourceModel() + return model + + +def is_table_sortable(table): + if isinstance(table, Orange.data.sql.table.SqlTable): + return False + elif isinstance(table, Orange.data.Table): + return True + else: + return False + + +class RichTableView(DataTableView): + """ + The preferred table view for RichTableModel. + + Handles the display of variable's labels keys in top left corner. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + header = self.horizontalHeader() + header.setSortIndicator(-1, Qt.AscendingOrder) + + def setModel(self, model: QAbstractItemModel): + current = self.model() + if current is not None: + current.headerDataChanged.disconnect(self.__headerDataChanged) + super().setModel(model) + if model is not None: + model.headerDataChanged.connect(self.__headerDataChanged) + self.__headerDataChanged(Qt.Horizontal) + select_rows = self.selectionBehavior() == TableView.SelectRows + sel_model = BlockSelectionModel(model, selectBlocks=not select_rows) + self.setSelectionModel(sel_model) + self.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) + + sortable = self.isModelSortable(model) + if sortable != self.isSortingEnabled(): + # setSortingEnabled disconnects/reconnects Qt's internal + # connections to model.sort(), causing client + # sortIndicatorChange connections to trigger before the model + # is actually sorted. Avoid unnecessary calls. + self.setSortingEnabled(sortable) + header = self.horizontalHeader() + header.setSectionsClickable(sortable) + header.setSortIndicatorShown(sortable) + + def isModelSortable(self, model: QAbstractItemModel) -> bool: + """ + Should the `model` be sortable via the view header click. + + This predicate is called when a model is set on the view and + enables/disables the model sorting and header section sort indicators. + """ + model = source_model(model) + if isinstance(model, TableModel): + table = model.source + return is_table_sortable(table) + return False + + def __headerDataChanged( + self, + orientation: Qt.Orientation, + ) -> None: + if orientation == Qt.Horizontal: + model = self.model() + model = source_model(model) + if isinstance(model, RichTableModel) and \ + model.richHeaderFlags() & RichTableModel.Labels and \ + model.columnCount() > 0: + items = model.headerData( + 0, Qt.Horizontal, RichTableModel.LabelsItemsRole + ) + text = "\n" + text += "\n".join(key for key, _ in items) + else: + text = "" + self.setCornerText(text) + + def setBlockSelection( + self, rows: Sequence[int], columns: Sequence[int] + ) -> None: + """ + Set the block row and column selection. + + Note + ---- + The `rows` indices refer to the underlying TableModel's rows. + + Parameters + ---------- + rows: Sequence[int] + The rows to select. + columns: Sequence[int] + The columns to select. + + See Also + -------- + blockSelection() + """ + model = self.model() + if model is None: + return + sel_model = self.selectionModel() + assert isinstance(sel_model, BlockSelectionModel) + if not rows or not columns or model.rowCount() <= rows[-1] or \ + model.columnCount() <= columns[-1]: + # selection out of range for the model + rows = columns = [] + proxy_chain = [] + while isinstance(model, QAbstractProxyModel): + proxy_chain.append(model) + model = model.sourceModel() + assert isinstance(model, TableModel) + + rows = model.mapFromSourceRows(rows) + + selection = QItemSelection() + rowranges = list(ranges(rows)) + colranges = list(ranges(columns)) + + for rowstart, rowend in rowranges: + for colstart, colend in colranges: + selection.append( + QItemSelectionRange( + model.index(rowstart, colstart), + model.index(rowend - 1, colend - 1) + ) + ) + for proxy in proxy_chain[::-1]: + selection = proxy.mapSelectionFromSource(selection) + sel_model.select(selection, QItemSelectionModel.ClearAndSelect) + + def blockSelection(self) -> Tuple[Sequence[int], Sequence[int]]: + """ + Return the current selected rows and columns. + + Note + ---- + The `rows` indices refer to the underlying TableModel's rows. + """ + model = self.model() + if model is None: + return [], [] + sel_model = self.selectionModel() + selection = sel_model.selection() + + # map through the proxies into input table. + while isinstance(model, QAbstractProxyModel): + selection = model.mapSelectionToSource(selection) + model = model.sourceModel() + + assert isinstance(sel_model, BlockSelectionModel) + assert isinstance(model, TableModel) + + row_spans, col_spans = selection_blocks(selection) + rows = list(chain.from_iterable(starmap(range, row_spans))) + cols = list(chain.from_iterable(starmap(range, col_spans))) + rows = np.array(rows, dtype=np.intp) + # map the rows through the applied sorting (if any) + rows = model.mapToSourceRows(rows) + rows = cast(Sequence[int], rows.tolist()) + return rows, cols diff --git a/Orange/widgets/data/utils/tests/__init__.py b/Orange/widgets/data/utils/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/data/utils/tests/test_tableview.py b/Orange/widgets/data/utils/tests/test_tableview.py new file mode 100644 index 00000000000..8323aa06c6e --- /dev/null +++ b/Orange/widgets/data/utils/tests/test_tableview.py @@ -0,0 +1,69 @@ +from AnyQt.QtWidgets import QAbstractButton + +from orangewidget.tests.base import GuiTest + +import Orange +from Orange.widgets.data.utils.models import RichTableModel, TableSliceProxy +from Orange.widgets.data.utils.tableview import RichTableView +from Orange.widgets.utils.itemselectionmodel import BlockSelectionModel + + +class TableViewTest(GuiTest): + def setUp(self) -> None: + super().setUp() + self.data = Orange.data.Table("iris")[::10] + self.data.domain.attributes[0].attributes["A"] = "a" + self.data.domain.class_var.attributes["A"] = "b" + + def tearDown(self) -> None: + del self.data + super().tearDown() + + def test_tableview(self): + view = RichTableView() + model = RichTableModel(self.data) + view.setModel(model) + self.assertIsInstance(view.selectionModel(), BlockSelectionModel) + model.setRichHeaderFlags(RichTableModel.Name | RichTableModel.Labels | + RichTableModel.Icon) + view.grab() + self.assertIn("A", view.cornerText()) + model.setRichHeaderFlags(RichTableModel.Name) + self.assertEqual(view.cornerText(), "") + + def test_tableview_empty_model(self): + data = Orange.data.Table.from_list( + Orange.data.Domain([], None), + [], + ) + view = RichTableView() + model = RichTableModel(data) + view.setModel(model) + self.assertIsInstance(view.selectionModel(), BlockSelectionModel) + model.setRichHeaderFlags(RichTableModel.Name | RichTableModel.Labels | + RichTableModel.Icon) + view.grab() + + def test_selection(self): + view = RichTableView() + model = RichTableModel(self.data) + view.setModel(model) + view.setBlockSelection([1, 2], [2, 3]) + sel = [(idx.row(), idx.column()) for idx in view.selectedIndexes()] + self.assertEqual(sorted(sel), [(1, 2), (1, 3), (2, 2), (2, 3)]) + self.assertEqual(view.blockSelection(), ([1, 2], [2, 3])) + + model_ = TableSliceProxy(rowSlice=slice(1, None, 1)) + model_.setSourceModel(model) + view.setModel(model_) + view.setBlockSelection([1, 2], [2, 3]) + sel = [(idx.row(), idx.column()) for idx in view.selectedIndexes()] + self.assertEqual(sorted(sel), [(0, 2), (0, 3), (1, 2), (1, 3)]) + self.assertEqual(view.blockSelection(), ([1, 2], [2, 3])) + + def test_basket_column(self): + model = RichTableModel(self.data.to_sparse()) + view = RichTableView() + view.setModel(model) + model.setRichHeaderFlags(RichTableModel.Name | RichTableModel.Labels) + view.grab() diff --git a/Orange/widgets/evaluate/__init__.py b/Orange/widgets/evaluate/__init__.py index b19110dba1e..8c54aad3ea1 100644 --- a/Orange/widgets/evaluate/__init__.py +++ b/Orange/widgets/evaluate/__init__.py @@ -1,10 +1,17 @@ """ -Widgets from Evaluate category +======== +Evaluate +======== + +Evaluating models. """ + NAME = "Evaluate" -DESCRIPTION = "Evaluate classification/regression performance." +ID = "orange.widgets.evaluate" + +DESCRIPTION = "Evaluate model performance" BACKGROUND = "#C3F3F3" diff --git a/Orange/widgets/evaluate/icons/CalibrationPlot.svg b/Orange/widgets/evaluate/icons/CalibrationPlot-symbolic.svg similarity index 100% rename from Orange/widgets/evaluate/icons/CalibrationPlot.svg rename to Orange/widgets/evaluate/icons/CalibrationPlot-symbolic.svg diff --git a/Orange/widgets/evaluate/icons/ConfusionMatrix.svg b/Orange/widgets/evaluate/icons/ConfusionMatrix-symbolic.svg similarity index 100% rename from Orange/widgets/evaluate/icons/ConfusionMatrix.svg rename to Orange/widgets/evaluate/icons/ConfusionMatrix-symbolic.svg diff --git a/Orange/widgets/evaluate/icons/FeatureAsPredictor-symbolic.svg b/Orange/widgets/evaluate/icons/FeatureAsPredictor-symbolic.svg new file mode 100644 index 00000000000..9a275161085 --- /dev/null +++ b/Orange/widgets/evaluate/icons/FeatureAsPredictor-symbolic.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/evaluate/icons/LiftCurve.svg b/Orange/widgets/evaluate/icons/LiftCurve-symbolic.svg similarity index 100% rename from Orange/widgets/evaluate/icons/LiftCurve.svg rename to Orange/widgets/evaluate/icons/LiftCurve-symbolic.svg diff --git a/Orange/widgets/evaluate/icons/ParameterFitter-symbolic.svg b/Orange/widgets/evaluate/icons/ParameterFitter-symbolic.svg new file mode 100644 index 00000000000..9ee52e92dae --- /dev/null +++ b/Orange/widgets/evaluate/icons/ParameterFitter-symbolic.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/evaluate/icons/PermutationPlot-symbolic.svg b/Orange/widgets/evaluate/icons/PermutationPlot-symbolic.svg new file mode 100644 index 00000000000..29b2df50d5c --- /dev/null +++ b/Orange/widgets/evaluate/icons/PermutationPlot-symbolic.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/evaluate/icons/Predictions.svg b/Orange/widgets/evaluate/icons/Predictions-symbolic.svg similarity index 100% rename from Orange/widgets/evaluate/icons/Predictions.svg rename to Orange/widgets/evaluate/icons/Predictions-symbolic.svg diff --git a/Orange/widgets/evaluate/icons/ROCAnalysis.svg b/Orange/widgets/evaluate/icons/ROCAnalysis-symbolic.svg similarity index 100% rename from Orange/widgets/evaluate/icons/ROCAnalysis.svg rename to Orange/widgets/evaluate/icons/ROCAnalysis-symbolic.svg diff --git a/Orange/widgets/evaluate/icons/TestLearners1.svg b/Orange/widgets/evaluate/icons/TestLearners1-symbolic.svg similarity index 100% rename from Orange/widgets/evaluate/icons/TestLearners1.svg rename to Orange/widgets/evaluate/icons/TestLearners1-symbolic.svg diff --git a/Orange/widgets/evaluate/icons/TestLearners2.svg b/Orange/widgets/evaluate/icons/TestLearners2-symbolic.svg similarity index 100% rename from Orange/widgets/evaluate/icons/TestLearners2.svg rename to Orange/widgets/evaluate/icons/TestLearners2-symbolic.svg diff --git a/Orange/widgets/evaluate/owcalibrationplot.py b/Orange/widgets/evaluate/owcalibrationplot.py index 71dc321642f..d06cde242cf 100644 --- a/Orange/widgets/evaluate/owcalibrationplot.py +++ b/Orange/widgets/evaluate/owcalibrationplot.py @@ -2,8 +2,8 @@ import numpy as np -from AnyQt.QtCore import Qt, QSize -from AnyQt.QtWidgets import QListWidget, QSizePolicy +from AnyQt.QtCore import Qt +from AnyQt.QtWidgets import QListWidget import pyqtgraph as pg @@ -16,12 +16,13 @@ from Orange.widgets import widget, gui, settings from Orange.widgets.evaluate.contexthandlers import \ EvaluationResultsContextHandler -from Orange.widgets.evaluate.utils import results_for_preview +from Orange.widgets.evaluate.utils import results_for_preview, \ + check_can_calibrate from Orange.widgets.utils import colorpalettes from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.visualize.utils.customizableplot import \ CommonParameterSetter -from Orange.widgets.visualize.utils.plotutils import AxisItem +from Orange.widgets.visualize.utils.plotutils import GraphicsView, PlotItem from Orange.widgets.widget import Input, Output, Msg from Orange.widgets import report @@ -94,9 +95,9 @@ def axis_items(self): class OWCalibrationPlot(widget.OWWidget): name = "Calibration Plot" description = "Calibration plot based on evaluation of classifiers." - icon = "icons/CalibrationPlot.svg" + icon = "icons/CalibrationPlot-symbolic.svg" priority = 1030 - keywords = [] + keywords = "calibration plot" class Inputs: evaluation_results = Input("Evaluation Results", Results) @@ -137,7 +138,7 @@ class Information(widget.OWWidget.Information): visual_settings = settings.Setting({}, schema_only=True) auto_commit = settings.Setting(True) - graph_name = "plot" + graph_name = "plot" # pg.GraphicsItem (pg.PlotItem) def __init__(self): super().__init__() @@ -165,9 +166,8 @@ def __init__(self): self.classifiers_list_box = gui.listBox( self.controlArea, self, "selected_classifiers", "classifier_names", box="Classifier", selectionMode=QListWidget.ExtendedSelection, - sizePolicy=(QSizePolicy.Preferred, QSizePolicy.Preferred), - sizeHint=QSize(150, 40), callback=self._on_selection_changed) + self.classifiers_list_box.setMaximumHeight(100) box = gui.vBox(self.controlArea, "Metrics") combo = gui.comboBox( @@ -184,24 +184,22 @@ def __init__(self): gui.radioButtons( box, self, value="output_calibration", btnLabels=("Sigmoid calibration", "Isotonic calibration"), - label="Output model calibration", callback=self.apply) + label="Output model calibration", callback=self.commit.deferred) self.info_box = gui.widgetBox(self.controlArea, "Info") self.info_label = gui.widgetLabel(self.info_box) - gui.auto_apply(self.buttonsArea, self, "auto_commit", commit=self.apply) + gui.rubber(self.controlArea) - self.plotview = pg.GraphicsView(background="w") - axes = {"bottom": AxisItem(orientation="bottom"), - "left": AxisItem(orientation="left")} - self.plot = pg.PlotItem(enableMenu=False, axisItems=axes) + gui.auto_apply(self.buttonsArea, self, "auto_commit") + + self.plotview = GraphicsView() + self.plot = PlotItem(enableMenu=False) self.plot.parameter_setter = ParameterSetter(self.plot) self.plot.setMouseEnabled(False, False) self.plot.hideButtons() - for axis_name in ("bottom", "left"): axis = self.plot.getAxis(axis_name) - axis.setPen(pg.mkPen(color=0.0)) # Remove the condition (that is, allow setting this for bottom # axis) when pyqtgraph is fixed # Issue: https://github.com/pyqtgraph/pyqtgraph/issues/930 @@ -240,7 +238,7 @@ def set_results(self, results): self.openContext(class_var, self.classifier_names) self._replot() - self.apply() + self.commit.now() def clear(self): self.plot.clear() @@ -255,13 +253,13 @@ def target_index_changed(self): self.threshold = 1 - self.threshold self._set_explanation() self._replot() - self.apply() + self.commit.deferred() def score_changed(self): self._set_explanation() self._replot() if self._last_score_value != self.score: - self.apply() + self.commit.deferred() self._last_score_value = self.score def _set_explanation(self): @@ -428,7 +426,7 @@ def _on_display_rug_changed(self): def _on_selection_changed(self): self._replot() - self.apply() + self.commit.deferred() def threshold_change(self): self.threshold = round(self.line.pos().x(), 2) @@ -481,30 +479,19 @@ def _update_info(self): self.info_label.setText(self.get_info_text(short=True)) def threshold_change_done(self): - self.apply() + self.commit.deferred() - def apply(self): + @gui.deferred + def commit(self): self.Information.no_output.clear() wrapped = None results = self.results if results is not None: - problems = [ - msg for condition, msg in ( - (len(results.folds) > 1, - "each training data sample produces a different model"), - (results.models is None, - "test results do not contain stored models - try testing " - "on separate data or on training data"), - (len(self.selected_classifiers) != 1, - "select a single model - the widget can output only one"), - (self.score != 0 and len(results.domain.class_var.values) != 2, - "cannot calibrate non-binary classes")) - if condition] - if len(problems) == 1: - self.Information.no_output(problems[0]) - elif problems: - self.Information.no_output( - "".join(f"\n - {problem}" for problem in problems)) + problems = check_can_calibrate( + self.results, self.selected_classifiers, + require_binary=self.score != 0) + if problems: + self.Information.no_output(problems) else: clsf_idx = self.selected_classifiers[0] model = results.models[0, clsf_idx] @@ -558,7 +545,7 @@ def smoother(xs): W = a * np.exp(-gamma * ((xs - x) ** 2)) return np.average(y, weights=W) - return np.vectorize(smoother, otypes=[np.float]) + return np.vectorize(smoother, otypes=[float]) if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/evaluate/owconfusionmatrix.py b/Orange/widgets/evaluate/owconfusionmatrix.py index 8b539cb30dd..959cc78265d 100644 --- a/Orange/widgets/evaluate/owconfusionmatrix.py +++ b/Orange/widgets/evaluate/owconfusionmatrix.py @@ -86,9 +86,9 @@ class OWConfusionMatrix(widget.OWWidget): name = "Confusion Matrix" description = "Display a confusion matrix constructed from " \ "the results of classifier evaluations." - icon = "icons/ConfusionMatrix.svg" + icon = "icons/ConfusionMatrix-symbolic.svg" priority = 1001 - keywords = [] + keywords = "confusion matrix" class Inputs: evaluation_results = Input("Evaluation Results", Orange.evaluation.Results) @@ -99,7 +99,13 @@ class Outputs: quantities = ["Number of instances", "Proportion of predicted", - "Proportion of actual"] + "Proportion of actual", + "Sum of probabilities"] + qu_tooltips = ["Number of correctly and incorrectly classified instances", + "Proportion of predicted", + "Proportion of actual", + "Number of instances, distributed across columns " + "according to predicted probabilities"] settings_version = 1 settingsHandler = ClassValuesContextHandler() @@ -136,7 +142,7 @@ def __init__(self): ) self.outputbox = gui.vBox(self.buttonsArea) - box = gui.hBox(self.outputbox) + box = gui.vBox(self.outputbox, box="Output") gui.checkBox(box, self, "append_predictions", "Predictions", callback=self._invalidate) gui.checkBox(box, self, "append_probabilities", @@ -149,8 +155,8 @@ def __init__(self): sbox = gui.hBox(box) gui.rubber(sbox) - gui.comboBox(sbox, self, "selected_quantity", - items=self.quantities, label="Show: ", + gui.comboBox(sbox, self, "selected_quantity", label="Show: ", + items=self.quantities, tooltips=self.qu_tooltips, orientation=Qt.Horizontal, callback=self._update) self.tablemodel = QStandardItemModel(self) @@ -299,7 +305,7 @@ def set_results(self, results): self.selected_learner[:] = prev_sel_learner self._update() self._set_selection() - self.unconditional_commit() + self.commit.now() def clear(self): """Reset the widget, clear controls""" @@ -384,17 +390,18 @@ def _prepare_data(self): self.results.probabilities is not None: probs = self.results.probabilities[self.selected_learner[0]] extra.append(np.array(probs, dtype=object)) - pvars = [Orange.data.ContinuousVariable("p({})".format(value)) - for value in class_var.values] - metas = metas + tuple(pvars) + names = [f"p({value})" for value in class_var.values] + names = get_unique_names(self.data.domain, names) + metas += tuple(map(Orange.data.ContinuousVariable, names)) domain = Orange.data.Domain(self.data.domain.attributes, self.data.domain.class_vars, metas) data = self.data.transform(domain) if extra: - data.metas[:, len(self.data.domain.metas):] = \ - np.hstack(tuple(extra)) + with data.unlocked(data.metas): + data.metas[:, len(self.data.domain.metas):] = \ + np.hstack(tuple(extra)) data.name = learner_name if selected: @@ -406,6 +413,7 @@ def _prepare_data(self): return data, annotated_data + @gui.deferred def commit(self): """Output data instances corresponding to selected cells""" if self.results is not None and self.data is not None \ @@ -421,7 +429,7 @@ def commit(self): def _invalidate(self): indices = self.tableview.selectedIndexes() self.selection = {(ind.row() - 2, ind.column() - 2) for ind in indices} - self.commit() + self.commit.deferred() def _set_selection(self): selection = QItemSelection() @@ -435,7 +443,7 @@ def _set_selection(self): def _learner_changed(self): self._update() self._set_selection() - self.commit() + self.commit.deferred() def _update(self): def _isinvalid(x): @@ -443,26 +451,43 @@ def _isinvalid(x): # Update the displayed confusion matrix if self.results is not None and self.selected_learner: - cmatrix = confusion_matrix(self.results, self.selected_learner[0]) - colsum = cmatrix.sum(axis=0) - rowsum = cmatrix.sum(axis=1) + learner_index = self.selected_learner[0] + if self.selected_quantity != 3: + cmatrix = confusion_matrix(self.results, learner_index) + colsum = cmatrix.sum(axis=0) + rowsum = cmatrix.sum(axis=1) + + else: + probabilities = self.results.probabilities[learner_index] + n = probabilities.shape[1] + cmatrix = np.zeros((n, n), dtype=float) + for index in np.unique(self.results.actual).astype(int): + mask = self.results.actual == index + cmatrix[index] = np.sum(probabilities[mask], axis=0) + colsum = cmatrix.sum(axis=0) + rowsum = cmatrix.sum(axis=1) + n = len(cmatrix) diag = np.diag_indices(n) colors = cmatrix.astype(np.double) colors[diag] = 0 if self.selected_quantity == 0: - normalized = cmatrix.astype(np.int) + normalized = cmatrix.astype(int) formatstr = "{}" div = np.array([colors.max()]) - else: - if self.selected_quantity == 1: - normalized = 100 * cmatrix / colsum - div = colors.max(axis=0) - else: - normalized = 100 * cmatrix / rowsum[:, np.newaxis] - div = colors.max(axis=1)[:, np.newaxis] + elif self.selected_quantity == 1: + normalized = 100 * cmatrix / colsum + div = colors.max(axis=0) + formatstr = "{:2.1f} %" + elif self.selected_quantity == 2: + normalized = 100 * cmatrix / rowsum[:, np.newaxis] + div = colors.max(axis=1)[:, np.newaxis] formatstr = "{:2.1f} %" + elif self.selected_quantity == 3: + normalized = cmatrix + formatstr = "{:2.1f}" + div = np.array([colors.max()]) div[div == 0] = 1 colors /= div maxval = normalized[diag].max() @@ -481,6 +506,8 @@ def _isinvalid(x): [0, 240][i == j], 160, 255 if _isinvalid(col_val) else int(255 - 30 * col_val)) item.setData(QBrush(bkcolor), Qt.BackgroundRole) + # bkcolor is light-ish so use a black text + item.setData(QBrush(Qt.black), Qt.ForegroundRole) item.setData("trbl", BorderRole) item.setToolTip("actual: {}\npredicted: {}".format( self.headers[i], self.headers[j])) @@ -492,6 +519,7 @@ def _isinvalid(x): bold_font.setBold(True) def _sum_item(value, border=""): + value = int(round(value)) item = QStandardItem() item.setData(value, Qt.DisplayRole) item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) @@ -502,9 +530,9 @@ def _sum_item(value, border=""): return item for i in range(n): - self._set_item(n + 2, i + 2, _sum_item(int(colsum[i]), "t")) - self._set_item(i + 2, n + 2, _sum_item(int(rowsum[i]), "l")) - self._set_item(n + 2, n + 2, _sum_item(int(rowsum.sum()))) + self._set_item(n + 2, i + 2, _sum_item(colsum[i], "t")) + self._set_item(i + 2, n + 2, _sum_item(rowsum[i], "l")) + self._set_item(n + 2, n + 2, _sum_item(rowsum.sum())) def send_report(self): """Send report""" diff --git a/Orange/widgets/evaluate/owfeatureaspredictor.py b/Orange/widgets/evaluate/owfeatureaspredictor.py new file mode 100644 index 00000000000..2c81dfdaef5 --- /dev/null +++ b/Orange/widgets/evaluate/owfeatureaspredictor.py @@ -0,0 +1,179 @@ +from itertools import chain + +from AnyQt.QtWidgets import QComboBox, QCheckBox + +from orangewidget import gui +from orangewidget.settings import Setting +from orangewidget.widget import Msg + +from Orange.data import Variable, Table +from Orange.modelling.column import ( + ColumnModel, ColumnLearner, valid_value_sets, valid_prob_range) +from Orange.widgets.widget import OWWidget, Input, Output +from Orange.widgets.utils.itemmodels import VariableListModel +from Orange.widgets.utils.widgetpreview import WidgetPreview + + +class OWFeatureAsPredictor(OWWidget): + name = "Feature as Predictor" + description = "Use a column as probabilities or predictions" + icon = "icons/FeatureAsPredictor-symbolic.svg" + priority = 1000 + keywords = "column predictor" + + want_main_area = False + resizing_enabled = False + + class Inputs: + data = Input("Data", Table) + + class Outputs: + learner = Output("Learner", ColumnLearner) + model = Output("Model", ColumnModel) + + class Error(OWWidget.Error): + no_class = Msg("Data has no target variable.") + no_variables = Msg("No useful variables") + + column_hint: Variable = Setting(None, schema_only=True) + # Stores the last user setting. + # apply_transformation tells what will actually happens; + # checkbox may be disabled and set to reflect apply_transformation. + apply_transformation_setting = Setting(False) + auto_apply = Setting(True) + + def __init__(self): + super().__init__() + self.data = None + self.column = None + self.apply_transformation = False + self.pars_to_report = (False, False) + + box = gui.vBox(self.controlArea, True) + + self.column_combo = combo = QComboBox() + combo.setModel(VariableListModel()) + box.layout().addWidget(combo) + @combo.activated.connect + def on_column_changed(index): + self.column = combo.model()[index] + self.column_hint = self.column.name + self._update_controls() + self.commit.deferred() + + self.cb_transformation = cb = QCheckBox("", self) + box.layout().addWidget(cb) + @cb.clicked.connect + def on_apply_transformation_changed(checked): + self.apply_transformation_setting \ + = self.apply_transformation = checked + self.commit.deferred() + + gui.auto_apply(self.controlArea, self, "auto_apply") + self._update_controls() + + def _update_controls(self): + cb = self.cb_transformation + data = self.data + + if data is None or self.column is None: + cb.setChecked(self.apply_transformation_setting) + cb.setDisabled(False) + return + + if self.column.is_discrete: + self.apply_transformation = False + cb.setChecked(False) + cb.setDisabled(True) + elif (data.domain.class_var.is_discrete + and not valid_prob_range(data.get_column(self.column))): + self.apply_transformation = True + cb.setChecked(True) + cb.setDisabled(True) + else: + self.apply_transformation = self.apply_transformation_setting + cb.setChecked(self.apply_transformation_setting) + cb.setDisabled(False) + + shape = "logistic" if data.domain.class_var.is_discrete else "linear" + cb.setText(f"Transform through {shape} function") + cb.setToolTip(f"Use {shape} regression to fit the model's coefficients") + + @Inputs.data + def set_data(self, data): + self._set_data(data) + self._update_controls() + self.commit.now() + + def _set_data(self, data): + column_model: VariableListModel = self.column_combo.model() + + self.Error.clear() + column_model.clear() + self.column = None + self.data = None + + if data is None: + return + + class_var = data.domain.class_var + if class_var is None: + self.Error.no_class() + return + + allow_continuous = (class_var.is_continuous + or len(class_var.values) == 2) + column_model[:] = ( + var + for var in chain(data.domain.attributes, data.domain.metas) + if (var.is_continuous and allow_continuous + or (var.is_discrete and class_var.is_discrete + and valid_value_sets(class_var, var)) + ) + ) + if not column_model: + self.Error.no_variables() + return + + self.data = data + if self.column_hint \ + and self.column_hint in self.data.domain \ + and (var := self.data.domain[self.column_hint]) in column_model: + self.column = var + self.column_combo.setCurrentIndex(column_model.indexOf(self.column)) + else: + self.column = column_model[0] + self.column_combo.setCurrentIndex(0) + self.column_hint = self.column.name + + @gui.deferred + def commit(self): + self.pars_to_report = (False, False) + if self.column is None: + self.Outputs.learner.send(None) + self.Outputs.model.send(None) + return + + learner = ColumnLearner( + self.data.domain.class_var, self.column, self.apply_transformation) + model = learner(self.data) + self.Outputs.learner.send(learner) + self.Outputs.model.send(model) + if self.apply_transformation: + self.pars_to_report = (model.intercept, model.coefficient) + + def send_report(self): + if self.column is None: + return + self.report_items(( + ("Predict values from", self.column.name), + ("Applied transformation", + self.apply_transformation and self.data is not None and + ("logistic" if self.data.domain.class_var.is_discrete else "linear")), + ("Intercept", self.pars_to_report[0]), + ("Coefficient", self.pars_to_report[1]) + )) + + +if __name__ == "__main__": # pragma: no cover + WidgetPreview(OWFeatureAsPredictor).run(Table("heart_disease")) diff --git a/Orange/widgets/evaluate/owliftcurve.py b/Orange/widgets/evaluate/owliftcurve.py index d83103e3a98..663df49b818 100644 --- a/Orange/widgets/evaluate/owliftcurve.py +++ b/Orange/widgets/evaluate/owliftcurve.py @@ -1,25 +1,33 @@ from enum import IntEnum -from typing import NamedTuple, Dict, Tuple +from typing import NamedTuple, Dict, Tuple, List import numpy as np +from sklearn.metrics import precision_recall_curve +from sklearn.preprocessing import label_binarize from AnyQt.QtWidgets import QListView, QFrame -from AnyQt.QtGui import QColor, QPen, QPalette, QFont +from AnyQt.QtGui import QColor, QPen, QFont from AnyQt.QtCore import Qt import pyqtgraph as pg +from orangewidget.utils.visual_settings_dlg import VisualSettingsDialog from orangewidget.widget import Msg import Orange +from Orange.base import Model +from Orange.classification import ThresholdClassifier from Orange.widgets import widget, gui, settings from Orange.widgets.evaluate.contexthandlers import \ EvaluationResultsContextHandler -from Orange.widgets.evaluate.utils import check_results_adequacy +from Orange.widgets.evaluate.utils import check_results_adequacy, \ + check_can_calibrate from Orange.widgets.utils import colorpalettes -from Orange.widgets.evaluate.owrocanalysis import convex_hull from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import Input +from Orange.widgets.visualize.utils.customizableplot import Updater, \ + CommonParameterSetter +from Orange.widgets.visualize.utils.plotutils import GraphicsView, PlotItem +from Orange.widgets.widget import Input, Output from Orange.widgets import report @@ -32,49 +40,129 @@ CurveData.is_valid = property(lambda self: self.contacted.size > 0) -PointsAndHull = NamedTuple( - "PointsAndHull", - [("points", CurveData), - ("hull", CurveData)] -) - - class CurveTypes(IntEnum): - LiftCurve, CumulativeGains = range(2) + LiftCurve, CumulativeGains, PrecisionRecall = range(3) # pylint: disable=invalid-name + + +class ParameterSetter(CommonParameterSetter): + WIDE_LINE_LABEL = "Line" + DEFAULT_LINE_LABEL = "Default Line" + + WIDE_LINE_WIDTH = 3 + LINE_WIDTH = 1 + DEFAULT_LINE_WIDTH = 1 + + WIDE_LINE_STYLE = "Solid line" + LINE_STYLE = "Solid line" + DEFAULT_LINE_STYLE = "Dash line" + + def __init__(self, master): + self.master = master + self.wide_line_settings = { + Updater.WIDTH_LABEL: self.WIDE_LINE_WIDTH, + Updater.STYLE_LABEL: self.WIDE_LINE_STYLE, + } + self.default_line_settings = { + Updater.WIDTH_LABEL: self.DEFAULT_LINE_WIDTH, + Updater.STYLE_LABEL: self.DEFAULT_LINE_STYLE, + } + super().__init__() + + def update_setters(self): + self.initial_settings = { + self.LABELS_BOX: { + self.FONT_FAMILY_LABEL: self.FONT_FAMILY_SETTING, + self.TITLE_LABEL: self.FONT_SETTING, + self.AXIS_TITLE_LABEL: self.FONT_SETTING, + self.AXIS_TICKS_LABEL: self.FONT_SETTING, + }, + self.ANNOT_BOX: { + self.TITLE_LABEL: {self.TITLE_LABEL: ("", "")}, + }, + self.PLOT_BOX: { + self.WIDE_LINE_LABEL: { + Updater.WIDTH_LABEL: (range(1, 15), self.WIDE_LINE_WIDTH), + Updater.STYLE_LABEL: (list(Updater.LINE_STYLES), + self.WIDE_LINE_STYLE), + }, + self.DEFAULT_LINE_LABEL: { + Updater.WIDTH_LABEL: (range(1, 15), + self.DEFAULT_LINE_WIDTH), + Updater.STYLE_LABEL: (list(Updater.LINE_STYLES), + self.DEFAULT_LINE_STYLE), + }, + } + } + + def update_wide_curves(**_settings): + self.wide_line_settings.update(**_settings) + Updater.update_lines(self.master.curve_items, + **self.wide_line_settings) + + def update_default_line(**_settings): + self.default_line_settings.update(**_settings) + Updater.update_lines(self.default_line_items, + **self.default_line_settings) + + self._setters[self.PLOT_BOX] = { + self.WIDE_LINE_LABEL: update_wide_curves, + self.DEFAULT_LINE_LABEL: update_default_line, + } + + @property + def title_item(self): + return self.master.titleLabel + + @property + def axis_items(self): + return [value["item"] for value in self.master.axes.values()] + + @property + def default_line_items(self): + return [self.master.default_line_item] \ + if self.master.default_line_item else [] class OWLiftCurve(widget.OWWidget): - name = "Lift Curve" - description = "Construct and display a lift curve " \ + name = "Performance Curve" + description = "Construct and display a performance curve " \ "from the evaluation of classifiers." - icon = "icons/LiftCurve.svg" + icon = "icons/LiftCurve-symbolic.svg" priority = 1020 - keywords = ["lift", "cumulative gain"] + keywords = "performance curve, lift, cumulative gain, precision, recall, curve" class Inputs: evaluation_results = Input( "Evaluation Results", Orange.evaluation.Results) + class Outputs: + calibrated_model = Output("Calibrated Model", Model) + class Warning(widget.OWWidget.Warning): undefined_curves = Msg( "Some curves are undefined; check models and data") class Error(widget.OWWidget.Error): - undefined_curves = Msg( - "No defined curves; check models and data") + undefined_curves = Msg("No defined curves; check models and data") - buttons_area_orientation = None + class Information(widget.OWWidget.Information): + no_output = Msg("Can't output a model: {}") settingsHandler = EvaluationResultsContextHandler() target_index = settings.ContextSetting(0) selected_classifiers = settings.ContextSetting([]) - display_convex_hull = settings.Setting(True) curve_type = settings.Setting(CurveTypes.LiftCurve) + show_threshold = settings.Setting(True) + show_points = settings.Setting(True) + rate = settings.Setting(0.5) + auto_commit = settings.Setting(True) + visual_settings = settings.Setting({}, schema_only=True) - graph_name = "plot" + graph_name = "plot" # pg.GraphicsItem (pg.PlotItem) - YLabels = ("Lift", "TP Rate") + XLabels = ("P Rate", "P Rate", "Recall") + YLabels = ("Lift", "TP Rate", "Precision") def __init__(self): super().__init__() @@ -82,7 +170,9 @@ def __init__(self): self.results = None self.classifier_names = [] self.colors = [] - self._points_hull: Dict[Tuple[int, int], PointsAndHull] = {} + self._points: Dict[Tuple[int, int, int], CurveData] = {} + self.line = None + self.tooltip = None box = gui.vBox(self.controlArea, box="Curve") self.target_cb = gui.comboBox( @@ -92,7 +182,8 @@ def __init__(self): contentsLength=8, searchable=True ) gui.radioButtons( - box, self, "curve_type", ("Lift Curve", "Cumulative Gains"), + box, self, "curve_type", + ("Lift Curve", "Cumulative Gains", "Precision Recall"), callback=self._on_curve_type_changed ) @@ -104,35 +195,42 @@ def __init__(self): ) self.classifiers_list_box.setMaximumHeight(100) - gui.checkBox(self.controlArea, self, "display_convex_hull", - "Show convex hull", box="Settings", callback=self._replot) + box = gui.vBox(self.controlArea, box="Settings") + gui.checkBox(box, self, "show_threshold", "Show thresholds", + callback=self._on_show_threshold_changed) + gui.checkBox(box, self, "show_points", "Show points", + callback=self._on_show_points_changed) gui.rubber(self.controlArea) - self.plotview = pg.GraphicsView(background="w") - self.plotview.setFrameStyle(QFrame.StyledPanel) + box = gui.vBox(self.controlArea, box="Area under the curve") + self._area_info = gui.label(box, self, "/", textFormat=Qt.RichText) + + gui.auto_apply(self.buttonsArea, self, "auto_commit") - self.plot = pg.PlotItem(enableMenu=False) + self.plotview = GraphicsView() + self.plotview.setFrameStyle(QFrame.StyledPanel) + self.plot = PlotItem(enableMenu=False) + self.plot.parameter_setter = ParameterSetter(self.plot) + self.plot.curve_items = [] + self.plot.default_line_item = None self.plot.setMouseEnabled(False, False) self.plot.hideButtons() - pen = QPen(self.palette().color(QPalette.Text)) - tickfont = QFont(self.font()) tickfont.setPixelSize(max(int(tickfont.pixelSize() * 2 // 3), 11)) - - for pos, label in (("bottom", "P Rate"), ("left", "")): + for pos in ("bottom", "left"): axis = self.plot.getAxis(pos) axis.setTickFont(tickfont) - axis.setPen(pen) - axis.setLabel(label) - self._set_left_label() + self._set_axes_labels() self.plot.showGrid(True, True, alpha=0.1) self.plotview.setCentralItem(self.plot) self.mainArea.layout().addWidget(self.plotview) + VisualSettingsDialog(self, self.plot.parameter_setter.initial_settings) + @Inputs.evaluation_results def set_results(self, results): self.closeContext() @@ -143,16 +241,20 @@ def set_results(self, results): self.openContext(self.results.domain.class_var, self.classifier_names) self._setup_plot() + self.commit.now() def clear(self): self.plot.clear() + self.plot.curve_items = [] + self.plot.default_line_item = None self.Warning.clear() self.Error.clear() self.results = None self.target_cb.clear() self.classifier_names = [] self.colors = [] - self._points_hull = {} + self._points = {} + self._update_info([]) def _initialize(self, results): n_models = len(results.predicted) @@ -166,67 +268,216 @@ def _initialize(self, results): item = self.classifiers_list_box.item(i) item.setIcon(colorpalettes.ColorIcon(color)) - class_values = results.data.domain.class_var.values + class_values = results.domain.class_var.values self.target_cb.addItems(class_values) if class_values: self.target_index = 0 def _replot(self): self.plot.clear() + self.plot.curve_items = [] + self.plot.default_line_item = None if self.results is not None: self._setup_plot() - _on_target_changed = _replot - _on_classifiers_changed = _replot + def _on_target_changed(self): + self._replot() + self.commit.deferred() + + def _on_classifiers_changed(self): + self._on_show_threshold_changed() + self._replot() + self.commit.deferred() def _on_curve_type_changed(self): - self._set_left_label() + self._set_axes_labels() self._replot() + self.commit.deferred() + + def _on_threshold_change(self): + self.rate = round(self.line.pos().x(), 5) + self.line.setPos(self.rate) + self._set_tooltip() + + def _on_show_threshold_changed(self): + selected = len(self.selected_classifiers) > 0 + self.tooltip.setVisible(self.show_threshold and selected) - def _set_left_label(self): - self.plot.getAxis("left").setLabel(self.YLabels[self.curve_type]) + def _on_show_points_changed(self): + for item in self.plot.curve_items: + item.scatter.setVisible(self.show_points) + + def _set_axes_labels(self): + self.plot.getAxis("bottom").setLabel( + self.XLabels[int(self.curve_type)]) + self.plot.getAxis("left").setLabel(self.YLabels[int(self.curve_type)]) def _setup_plot(self): self._plot_default_line() is_valid = [ - self._plot_curve(self.target_index, clf_idx) + self._plot_curve(self.target_index, clf_idx, self.curve_type) for clf_idx in self.selected_classifiers ] + self._update_info(is_valid) self.plot.autoRange() + if self.curve_type != CurveTypes.LiftCurve: + self.plot.getViewBox().setYRange(0, 1) self._set_undefined_curves_err_warn(is_valid) - def _plot_curve(self, target, clf_idx): - key = (target, clf_idx) - if key not in self._points_hull: - self._points_hull[key] = \ - points_from_results(self.results, target, clf_idx) - points, hull = self._points_hull[key] + self.line = pg.InfiniteLine( + pos=self.rate, movable=True, + pen=pg.mkPen(color="k", style=Qt.DashLine, width=2), + hoverPen=pg.mkPen(color="k", style=Qt.DashLine, width=3), + bounds=(0, 1), + ) + self.line.setCursor(Qt.SizeHorCursor) + self.line.sigPositionChanged.connect(self._on_threshold_change) + self.line.sigPositionChangeFinished.connect( + self._on_threshold_change_done) + self.plot.addItem(self.line) + + self.tooltip = pg.TextItem(border=QColor(*(100, 100, 100, 200)), + fill=(250, 250, 250, 200)) + self.tooltip.setZValue(1e9) + self.plot.addItem(self.tooltip) + self._set_tooltip() + + self._on_show_points_changed() + self._on_show_threshold_changed() + + def _on_threshold_change_done(self): + self.commit.deferred() + + def _plot_curve(self, target, clf_idx, curve_type): + curve_type = curve_type if curve_type == CurveTypes.PrecisionRecall \ + else CurveTypes.LiftCurve + key = (target, clf_idx, curve_type) + if key not in self._points: + self._points[key] = points_from_results( + self.results, target, clf_idx, self.curve_type) + points = self._points[key] if not points.is_valid: return False + param_setter = self.plot.parameter_setter color = self.colors[clf_idx] - pen = QPen(color, 1) - pen.setCosmetic(True) - wide_pen = QPen(color, 3) + width = param_setter.wide_line_settings[Updater.WIDTH_LABEL] + style = param_setter.wide_line_settings[Updater.STYLE_LABEL] + wide_pen = QPen(color, width, Updater.LINE_STYLES[style]) wide_pen.setCosmetic(True) - def _plot(points, pen): + def tip(x, y, data): + xlabel = self.XLabels[int(self.curve_type)] + ylabel = self.YLabels[int(self.curve_type)] + return f"{xlabel}: {round(x, 3)}\n" \ + f"{ylabel}: {round(y, 3)}\n" \ + f"Threshold: {round(data, 3)}" + + def _plot(points, pen, kwargs): contacted, respondents, _ = points if self.curve_type == CurveTypes.LiftCurve: respondents = respondents / contacted - self.plot.plot(contacted, respondents, pen=pen, antialias=True) - - _plot(points, wide_pen if not self.display_convex_hull else pen) - if self.display_convex_hull: - _plot(hull, wide_pen) + curve = pg.PlotDataItem(contacted, respondents, pen=pen, + antialias=True, **kwargs) + curve.scatter.opts["hoverable"] = True + curve.scatter.opts["tip"] = tip + self.plot.addItem(curve) + bottom = pg.PlotDataItem(contacted, np.zeros(len(contacted))) + area_color = QColor(color) + area_color.setAlphaF(0.1) + area_item = pg.FillBetweenItem(curve, bottom, + brush=pg.mkBrush(area_color)) + self.plot.addItem(area_item) + return curve + + light_color = QColor(color) + light_color.setAlphaF(0.25) + line_kwargs = {"symbol": "o", "symbolSize": 8, + "symbolPen": light_color, "symbolBrush": light_color, + "data": points.thresholds, "stepMode": "right"} + + self.plot.curve_items.append(_plot(points, wide_pen, line_kwargs)) return True + def _update_info(self, is_valid: List[bool]): + self._area_info.setText("/") + if any(is_valid): + text = "" + for clf_idx, valid in zip(self.selected_classifiers, is_valid): + if valid: + if self.curve_type == CurveTypes.PrecisionRecall: + curve_type = self.curve_type + else: + curve_type = CurveTypes.LiftCurve + key = self.target_index, clf_idx, curve_type + contacted, respondents, _ = self._points[key] + if self.curve_type == CurveTypes.LiftCurve: + respondents = respondents / contacted + area = compute_area(contacted, respondents) + area = f"{round(area, 3)}" + else: + area = "/" + text += \ + f"" \ + f"" \ + f"" \ + f"" \ + f"" + text += "
    {self.classifier_names[clf_idx]}: {area}
    " + self._area_info.setText(text) + + def _set_tooltip(self): + html = "" + if len(self.plot.curve_items) > 0: + html = '
    Probability threshold(s):' + for item in self.plot.curve_items: + threshold = self._get_threshold(item.xData, item.opts["data"]) + html += f'
    ' \ + f'' \ + f'{round(threshold, 3)}' \ + f'
    ' + html += '
    ' + + self.tooltip.setHtml(html) + + view_box = self.plot.getViewBox() + y_min, y_max = view_box.viewRange()[1] + self.tooltip.setPos(self.rate, y_min + (y_max - y_min) * 0.8) + half_width = self.tooltip.boundingRect().width() * \ + view_box.viewPixelSize()[0] / 2 + anchor = [0.5, 0] + if half_width > self.rate - 0: + anchor[0] = 0 + elif half_width > 1 - self.rate: + anchor[0] = 1 + self.tooltip.setAnchor(anchor) + + def _get_threshold(self, contacted, thresholds): + indices = np.array(thresholds).argsort()[::-1] + diff = contacted[indices] + value = diff[diff - self.rate >= 0][0] + ind = np.where(np.round(contacted, 6) == np.round(value, 6))[0][-1] + return thresholds[ind] + def _plot_default_line(self): - pen = QPen(QColor(20, 20, 20), 1, Qt.DashLine) + param_setter = self.plot.parameter_setter + width = param_setter.default_line_settings[Updater.WIDTH_LABEL] + style = param_setter.default_line_settings[Updater.STYLE_LABEL] + pen = QPen(QColor(20, 20, 20), width, Updater.LINE_STYLES[style]) pen.setCosmetic(True) - y0 = 1 if self.curve_type == CurveTypes.LiftCurve else 0 - self.plot.plot([0, 1], [y0, 1], pen=pen, antialias=True) + if self.curve_type == CurveTypes.LiftCurve: + y0, y1 = 1, 1 + elif self.curve_type == CurveTypes.CumulativeGains: + y0, y1 = 0, 1 + else: + y_true = self.results.actual + y0 = y1 = sum(y_true == self.target_index) / len(y_true) + curve = pg.PlotCurveItem([0, 1], [y0, y1], pen=pen, antialias=True) + self.plot.addItem(curve) + self.plot.default_line_item = curve def _set_undefined_curves_err_warn(self, is_valid): self.Error.undefined_curves.clear() @@ -237,6 +488,26 @@ def _set_undefined_curves_err_warn(self, is_valid): else: self.Error.undefined_curves() + @gui.deferred + def commit(self): + self.Information.no_output.clear() + wrapped = None + results = self.results + if results is not None: + problems = check_can_calibrate( + self.results, self.selected_classifiers) + if problems: + self.Information.no_output(problems) + else: + clsf_idx = self.selected_classifiers[0] + model = results.models[0, clsf_idx] + item = self.plot.curve_items[0] + threshold = self._get_threshold(item.xData, item.opts["data"]) + threshold = [1 - threshold, threshold][self.target_index] + wrapped = ThresholdClassifier(model, threshold) + + self.Outputs.calibrated_model.send(wrapped) + def send_report(self): if self.results is None: return @@ -246,12 +517,38 @@ def send_report(self): self.report_plot() self.report_caption(caption) + def set_visual_settings(self, key, value): + self.plot.parameter_setter.set_parameter(key, value) + self.visual_settings[key] = value -def points_from_results(results, target, clf_index): - x, y, thresholds = cumulative_gains_from_results(results, target, clf_index) - points = CurveData(x, y, thresholds) - hull = CurveData(*convex_hull([(x, y, thresholds)])) - return PointsAndHull(points, hull) + +def points_from_results(results, target, clf_index, curve_type): + func = precision_recall_from_results \ + if curve_type == CurveTypes.PrecisionRecall \ + else cumulative_gains_from_results + x, y, thresholds = func(results, target, clf_index) + return CurveData(x, y, thresholds) + + +def precision_recall_from_results(results, target, clf_idx): + y_true = results.actual + classes = np.unique(results.actual) + if len(classes) > 2: + y_true = label_binarize(y_true, classes=sorted(classes)) + y_true = y_true[:, target] + scores = results.probabilities[clf_idx][:, target] + precision, recall, thresholds = precision_recall_curve(y_true, scores) + + # scikit's precision_recall_curve adds a (0, 1) point, + # so we add a corresponding threshold = 1. + # In case the probability threshold was 1 we remove the (0, 1) point. + if thresholds[-1] < 1: + thresholds = np.append(thresholds, 1) + else: + recall = recall[:-1] + precision = precision[:-1] + + return recall, precision, thresholds def cumulative_gains_from_results(results, target, clf_idx): @@ -283,6 +580,13 @@ def cumulative_gains(y_true, y_score, target=1): return contacted, respondents, y_score[threshold_idxs] +def compute_area(x: np.ndarray, y: np.ndarray) -> float: + ids = np.argsort(x) + x = x[ids] + y = y[ids] + return np.dot(x[1:] - x[:-1], y[:-1]) + + if __name__ == "__main__": # pragma: no cover from Orange.widgets.evaluate.utils import results_for_preview WidgetPreview(OWLiftCurve).run(results_for_preview()) diff --git a/Orange/widgets/evaluate/owparameterfitter.py b/Orange/widgets/evaluate/owparameterfitter.py new file mode 100644 index 00000000000..eb59494121e --- /dev/null +++ b/Orange/widgets/evaluate/owparameterfitter.py @@ -0,0 +1,665 @@ +from typing import Optional, Callable, Collection, Sequence + +import numpy as np +from AnyQt.QtCore import QPointF, Qt, QSize +from AnyQt.QtGui import QStandardItemModel, QStandardItem, \ + QPainter, QFontMetrics +from AnyQt.QtWidgets import QGraphicsSceneHelpEvent, QToolTip, \ + QGridLayout, QSizePolicy, QWidget + +import pyqtgraph as pg + +from orangewidget.utils.itemmodels import signal_blocking +from orangewidget.utils.visual_settings_dlg import VisualSettingsDialog, \ + KeyType, ValueType + +from Orange.base import Learner +from Orange.data import Table +from Orange.evaluation import CrossValidation, TestOnTrainingData, Results +from Orange.evaluation.scoring import Score, AUC, R2 +from Orange.modelling import Fitter +from Orange.util import dummy_callback, wrap_callback +from Orange.widgets import gui +from Orange.widgets.settings import Setting +from Orange.widgets.utils import userinput +from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState +from Orange.widgets.utils.multi_target import check_multiple_targets_input +from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.owscatterplotgraph import LegendItem +from Orange.widgets.visualize.utils.customizableplot import \ + CommonParameterSetter, Updater +from Orange.widgets.visualize.utils.plotutils import PlotWidget, \ + HelpEventDelegate +from Orange.widgets.widget import OWWidget, Input, Msg + +N_FOLD = 7 +MIN_MAX_SPIN = 100000 +ScoreType = tuple[int, tuple[float, float]] +# scores, score name, label +FitterResults = tuple[list[ScoreType], str, str] + + +def _validate( + data: Table, + learner: Learner, + scorer: type[Score], + progress_callback: Callable +) -> tuple[float, float]: + res: Results = TestOnTrainingData()(data, [learner], + suppresses_exceptions=False, + callback=wrap_callback( + progress_callback, 0, 1/(1+N_FOLD)) + ) + res_cv: Results = CrossValidation(k=N_FOLD)(data, [learner], + suppresses_exceptions=False, + callback=wrap_callback( + progress_callback, 1/(1+N_FOLD), 1.) + ) + # pylint: disable=unsubscriptable-object + return scorer(res)[0], scorer(res_cv)[0] + + +def _search( + data: Table, + learner: Learner, + fitted_parameter_props: Learner.FittedParameter, + initial_parameters: dict[str, int], + steps: Collection[int], + progress_callback: Callable = dummy_callback +) -> FitterResults: + progress_callback(0, "Calculating...") + scores = [] + scorer = AUC if data.domain.has_discrete_class else R2 + name = fitted_parameter_props.name + for i, value in enumerate(steps): + params = initial_parameters.copy() + params[name] = value + result = _validate(data, type(learner)(**params), scorer, + wrap_callback(progress_callback, i / len(steps), (i+1) / len(steps))) + scores.append((value, result)) + return scores, scorer.name, fitted_parameter_props.label + + +def run( + data: Table, + learner: Learner, + fitted_parameter_props: Learner.FittedParameter, + initial_parameters: dict[str, int], + steps: Collection[int], + state: TaskState +) -> FitterResults: + def callback(i: float, status: str = ""): + state.set_progress_value(i * 100) + if status: + state.set_status(status) + if state.is_interruption_requested(): + # pylint: disable=broad-exception-raised + raise Exception + + return _search(data, learner, fitted_parameter_props, initial_parameters, + steps, callback) + + +class ParameterSetter(CommonParameterSetter): + GRID_LABEL, SHOW_GRID_LABEL = "Gridlines", "Show" + DEFAULT_ALPHA_GRID, DEFAULT_SHOW_GRID = 80, True + + def __init__(self, master): + self.grid_settings: Optional[dict] = None + self.master: FitterPlot = master + super().__init__() + + def update_setters(self): + self.grid_settings = { + Updater.ALPHA_LABEL: self.DEFAULT_ALPHA_GRID, + self.SHOW_GRID_LABEL: self.DEFAULT_SHOW_GRID, + } + + self.initial_settings = { + self.LABELS_BOX: { + self.FONT_FAMILY_LABEL: self.FONT_FAMILY_SETTING, + self.AXIS_TITLE_LABEL: self.FONT_SETTING, + self.AXIS_TICKS_LABEL: self.FONT_SETTING, + self.LEGEND_LABEL: self.FONT_SETTING, + }, + self.PLOT_BOX: { + self.GRID_LABEL: { + self.SHOW_GRID_LABEL: (None, True), + Updater.ALPHA_LABEL: (range(0, 255, 5), + self.DEFAULT_ALPHA_GRID), + }, + }, + } + + def update_grid(**settings): + self.grid_settings.update(**settings) + self.master.showGrid( + x=False, y=self.grid_settings[self.SHOW_GRID_LABEL], + alpha=self.grid_settings[Updater.ALPHA_LABEL] / 255) + + self._setters[self.PLOT_BOX] = {self.GRID_LABEL: update_grid} + + @property + def axis_items(self): + return [value["item"] for value in + self.master.getPlotItem().axes.values()] + + @property + def legend_items(self): + return self.master.legend.items + + +class FitterPlot(PlotWidget): + BAR_WIDTH = 0.4 + + def __init__(self): + super().__init__(enableMenu=False) + self.__bar_item_tr: Optional[pg.BarGraphItem] = None + self.__bar_item_cv: Optional[pg.BarGraphItem] = None + self.__data: Optional[list[ScoreType]] = None + self.legend = self._create_legend() + self.parameter_setter = ParameterSetter(self) + self.setMouseEnabled(False, False) + self.hideButtons() + + self.showGrid(x=False, y=self.parameter_setter.DEFAULT_SHOW_GRID, + alpha=self.parameter_setter.DEFAULT_ALPHA_GRID / 255) + + self.tooltip_delegate = HelpEventDelegate(self.help_event) + self.scene().installEventFilter(self.tooltip_delegate) + + def _create_legend(self) -> LegendItem: + legend = LegendItem() + legend.setParentItem(self.getViewBox()) + legend.anchor((1, 1), (1, 1), offset=(-5, -5)) + legend.hide() + return legend + + def clear_all(self): + self.clear() + self.__bar_item_tr = None + self.__bar_item_cv = None + self.__data = None + self.setLabel(axis="bottom", text=None) + self.setLabel(axis="left", text=None) + self.getAxis("bottom").setTicks(None) + + def set_data( + self, + scores: list[ScoreType], + score_name: str, + parameter_name: str + ): + self.__data = scores + self.clear() + self.setLabel(axis="bottom", text=parameter_name) + self.setLabel(axis="left", text=score_name) + + ticks = [[(i, str(val)) for i, (val, _) + in enumerate(scores)]] + self.getAxis("bottom").setTicks(ticks) + + brush_tr = "#6fa255" + brush_cv = "#3a78b6" + pen = pg.mkPen("#333") + kwargs = {"pen": pen, "width": self.BAR_WIDTH} + bar_item_tr = pg.BarGraphItem(x=np.arange(len(scores)) - 0.2, + height=[(s[0]) for _, s in scores], + brush=brush_tr, **kwargs) + bar_item_cv = pg.BarGraphItem(x=np.arange(len(scores)) + 0.2, + height=[(s[1]) for _, s in scores], + brush=brush_cv, **kwargs) + self.addItem(bar_item_tr) + self.addItem(bar_item_cv) + self.__bar_item_tr = bar_item_tr + self.__bar_item_cv = bar_item_cv + + self.legend.clear() + kwargs = {"pen": pen, "symbol": "s"} + scatter_item_tr = pg.ScatterPlotItem(brush=brush_tr, **kwargs) + scatter_item_cv = pg.ScatterPlotItem(brush=brush_cv, **kwargs) + self.legend.addItem(scatter_item_tr, "Train") + self.legend.addItem(scatter_item_cv, "CV") + Updater.update_legend_font(self.legend.items, + **self.parameter_setter.legend_settings) + self.legend.show() + + def help_event(self, ev: QGraphicsSceneHelpEvent) -> bool: + if self.__bar_item_tr is None: + return False + + pos = self.__bar_item_tr.mapFromScene(ev.scenePos()) + index = self.__get_index_at(pos) + text = "" + if index is not None: + _, scores = self.__data[index] + text = "
    " \ + "" \ + "" \ + f"" \ + "" \ + "" \ + f"" \ + "" \ + "
    Train:{round(scores[0], 3)}
    CV:{round(scores[1], 3)}
    " + if text: + QToolTip.showText(ev.screenPos(), text, widget=self) + return True + else: + return False + + def __get_index_at(self, point: QPointF) -> Optional[int]: + x = point.x() + index = round(x) + # pylint: disable=unsubscriptable-object + heights_tr: list = self.__bar_item_tr.opts["height"] + heights_cv: list = self.__bar_item_cv.opts["height"] + if 0 <= index < len(heights_tr) and abs(index - x) <= self.BAR_WIDTH: + if index > x and 0 <= point.y() <= heights_tr[index]: + return index + if x > index and 0 <= point.y() <= heights_cv[index]: + return index + return None + + +class RangePreview(QWidget): + def __init__(self): + super().__init__() + font = self.font() + font.setPointSize(font.pointSize() - 3) + self.setFont(font) + + self.__steps: Optional[Sequence[int]] = None + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + + def minimumSizeHint(self): + return QSize(1, 20) + + def set_steps(self, steps: Optional[Sequence[int]]): + self.__steps = steps + self.update() + + def steps(self): + return self.__steps + + def paintEvent(self, _): + if not self.__steps: + return + painter = QPainter(self) + metrics = QFontMetrics(self.font()) + style = self.style() + rect = self.rect() + + # Indent by the width of the radio button indicator + rect.adjust(style.pixelMetric(style.PM_IndicatorWidth) + + style.pixelMetric(style.PM_CheckBoxLabelSpacing), 0, 0, 0) + + last_text = f"{self.__steps[-1]}" + if len(self.__steps) > 1: + last_text = ", " + last_text + last_width = metrics.horizontalAdvance(last_text) + + elided_text = metrics.elidedText( + "Steps: " + ", ".join(map(str, self.__steps[:-1])), + Qt.ElideRight, rect.width() - last_width) + elided_width = metrics.horizontalAdvance(elided_text) + + # Right-align by indenting by the underflow width + rect.adjust(rect.width() - elided_width - last_width, 0, 0, 0) + + painter.drawText(rect, Qt.AlignLeft, elided_text) + rect.adjust(elided_width, 0, 0, 0) + painter.drawText(rect, Qt.AlignLeft, last_text) + + +class OWParameterFitter(OWWidget, ConcurrentWidgetMixin): + name = "Parameter Fitter" + description = "Fit learner for various values of fitting parameter." + icon = "icons/ParameterFitter-symbolic.svg" + priority = 1110 + keywords = "parameter, fitter, tuning" + + visual_settings = Setting({}, schema_only=True) + graph_name = "graph.plotItem" + + class Inputs: + data = Input("Data", Table) + learner = Input("Learner", Learner) + + DEFAULT_PARAMETER_INDEX = 0 + DEFAULT_MINIMUM = 1 + DEFAULT_MAXIMUM = 9 + parameter_index = Setting(DEFAULT_PARAMETER_INDEX, schema_only=True) + FROM_RANGE, MANUAL = range(2) + type: int = Setting(FROM_RANGE) + minimum: int = Setting(DEFAULT_MINIMUM, schema_only=True) + maximum: int = Setting(DEFAULT_MAXIMUM, schema_only=True) + manual_steps: str = Setting("", schema_only=True) + auto_commit = Setting(True) + + class Error(OWWidget.Error): + unknown_err = Msg("{}") + not_enough_data = Msg(f"At least {N_FOLD} instances are needed.") + incompatible_learner = Msg("{}") + manual_steps_error = Msg("Invalid values for '{}': {}") + min_max_error = Msg("Minimum must be less than maximum.") + missing_target = Msg("Data has no target.") + + class Warning(OWWidget.Warning): + no_parameters = Msg("{} has no parameters to fit.") + + def __init__(self): + OWWidget.__init__(self) + ConcurrentWidgetMixin.__init__(self) + self._data: Optional[Table] = None + self._learner: Optional[Learner] = None + self.__parameters_model = QStandardItemModel() + self.__initialize_settings = False + + self.setup_gui() + VisualSettingsDialog( + self, self.graph.parameter_setter.initial_settings + ) + + def setup_gui(self): + self._add_plot() + self._add_controls() + + def _add_plot(self): + # This is a part of __init__ + # pylint: disable=attribute-defined-outside-init + box = gui.vBox(self.mainArea) + self.graph = FitterPlot() + box.layout().addWidget(self.graph) + + def _add_controls(self): + # This is a part of __init__ + # pylint: disable=attribute-defined-outside-init + layout = QGridLayout() + gui.widgetBox(self.controlArea, "Settings", orientation=layout) + self.__combo = gui.comboBox(None, self, "parameter_index", + model=self.__parameters_model, + callback=self.__on_parameter_changed) + layout.addWidget(self.__combo, 0, 0, 1, 2) + + buttons = gui.radioButtons(None, self, "type", + callback=self.__on_type_changed) + button = gui.appendRadioButton(buttons, "Range:") + layout.addWidget(button, 1, 0) + + # pylint: disable=use-dict-literal + kw = dict(minv=-MIN_MAX_SPIN, maxv=MIN_MAX_SPIN, + alignment=Qt.AlignRight, + callback=self.__on_min_max_changed) + box = gui.hBox(None) + self.__spin_min = gui.spin(box, self, "minimum", label="From:", **kw) + layout.addWidget(box, 1, 1) + + box = gui.hBox(None) + self.__spin_max = gui.spin(box, self, "maximum", label="To:", **kw) + layout.addWidget(box, 2, 1) + + self.range_preview = RangePreview() + layout.addWidget(self.range_preview, 3, 0, 1, 2) + + gui.appendRadioButton(buttons, "Manual:") + layout.addWidget(buttons, 4, 0) + self.edit = gui.lineEdit(None, self, "manual_steps", + placeholderText="e.g. 10, 20, ..., 50", + callback=self.__on_manual_changed) + layout.addWidget(self.edit, 4, 1) + + # gui.lineEdit's connect does not call the callback on return pressed + # if the line hasn't changed. + @self.edit.returnPressed.connect + def _(): + if self.type != self.MANUAL: + self.type = self.MANUAL + self.__on_type_changed() + + gui.rubber(self.controlArea) + + gui.auto_apply(self.buttonsArea, self, "auto_commit") + + self._update_preview() + + def __on_type_changed(self): + self._settings_changed() + + def __on_parameter_changed(self): + self.__initialize_settings = True + self._set_range_controls(self.fitted_parameters[self.parameter_index]) + self._settings_changed() + + def __on_min_max_changed(self): + self.type = self.FROM_RANGE + self._settings_changed() + + def __on_manual_changed(self): + self.type = self.MANUAL + self._settings_changed() + + def _settings_changed(self): + self._update_preview() + self.commit.deferred() + + @property + def fitted_parameters(self) -> list: + if not self._learner: + return [] + return self._learner.fitted_parameters + + @property + def initial_parameters(self) -> dict: + if not self._learner: + return {} + if isinstance(self._learner, Fitter): + return self._learner.get_params(self._data or "classification") + return self._learner.params + + @property + def steps(self) -> tuple[int, ...]: + self.Error.min_max_error.clear() + self.Error.manual_steps_error.clear() + + if self.type == self.FROM_RANGE: + return self._steps_from_range() + else: + return self._steps_from_manual() + + def _steps_from_range(self) -> tuple[int, ...]: + if self.maximum < self.minimum: + self.Error.min_max_error() + return () + + if self.minimum == self.maximum: + return (self.minimum, ) + + diff = self.maximum - self.minimum + # This should give between 10 and 15 steps + exp = max(0, int(np.ceil(np.log10(diff / 1.5))) - 1) + step = int(10 ** exp) + return (self.minimum, + *range((self.minimum // step + 1) * step, self.maximum, step), + self.maximum) + + def _steps_from_manual(self) -> tuple[int, ...]: + param = self.fitted_parameters[self.parameter_index] + try: + steps = userinput.numbers_from_list( + self.manual_steps, int, param.min, param.max) + except ValueError as ex: + self.Error.manual_steps_error(param.label, ex) + return () + if steps and "..." not in self.manual_steps: + self.manual_steps = ", ".join(map(str, steps)) + return steps + + @Inputs.data + @check_multiple_targets_input + def set_data(self, data: Optional[Table]): + self.Error.not_enough_data.clear() + self.Error.missing_target.clear() + self._data = data + if self._data and len(self._data) < N_FOLD: + self.Error.not_enough_data() + self._data = None + if self._data and len(self._data.domain.class_vars) < 1: + self.Error.missing_target() + self._data = None + + @Inputs.learner + def set_learner(self, learner: Optional[Learner]): + self.Warning.clear() + self.Error.manual_steps_error.clear() + self.Error.min_max_error.clear() + self.__parameters_model.clear() + + if not learner: + self.__initialize_settings = False + # reset spin controls + ars = (None, None, int, None, None) + self._set_range_controls(Learner.FittedParameter(*ars)) + + elif self._learner: + self.__initialize_settings = \ + learner.fitted_parameters != self.fitted_parameters + + else: + # changed by user or opened workflow + self.__initialize_settings = \ + self.parameter_index == self.DEFAULT_PARAMETER_INDEX and \ + self.minimum == self.DEFAULT_MINIMUM and \ + self.maximum == self.DEFAULT_MAXIMUM + + self._learner = learner + if self._learner is None: + return + + for param in self.fitted_parameters: + item = QStandardItem(param.label) + self.__parameters_model.appendRow(item) + + if not self.fitted_parameters: + self.Warning.no_parameters(self._learner.name) + else: + if self.__initialize_settings: + self.parameter_index = 0 + else: + self.__combo.setCurrentIndex(self.parameter_index) + self._set_range_controls( + self.fitted_parameters[self.parameter_index]) + + self._update_preview() + + def handleNewSignals(self): + self.Error.unknown_err.clear() + self.Error.incompatible_learner.clear() + self.clear() + + if not self._data or not self._learner: + return + + reason = self._learner.incompatibility_reason(self._data.domain) + if reason: + self.Error.incompatible_learner(reason) + return + + self.commit.now() + + def _set_range_controls(self, param: Learner.FittedParameter): + assert param.type == int, \ + "The widget currently supports only int parameters" + + # Block signals to avoid changing `self.type` + with signal_blocking(self.__spin_min), signal_blocking(self.__spin_max): + if param.min is not None: + self.__spin_min.setMinimum(param.min) + self.__spin_max.setMinimum(param.min) + self.minimum = param.min if self.__initialize_settings else \ + max(self.minimum, param.min) + else: + self.__spin_min.setMinimum(-MIN_MAX_SPIN) + self.__spin_max.setMinimum(-MIN_MAX_SPIN) + if self.__initialize_settings: + self.minimum = self.initial_parameters[param.name] + if param.max is not None: + self.__spin_min.setMaximum(param.max) + self.__spin_max.setMaximum(param.max) + if self.__initialize_settings: + self.maximum = param.max + self.maximum = param.max if self.__initialize_settings else \ + min(self.maximum, param.max) + else: + self.__spin_min.setMaximum(MIN_MAX_SPIN) + self.__spin_max.setMaximum(MIN_MAX_SPIN) + if self.__initialize_settings: + self.maximum = self.initial_parameters[param.name] + self.__initialize_settings = False + + tip = "Enter a list of values" + if param.min is not None: + if param.max is not None: + self.edit.setToolTip(f"{tip} between {param.min} and {param.max}.") + else: + self.edit.setToolTip(f"{tip} greater or equal to {param.min}.") + elif param.max is not None: + self.edit.setToolTip(f"{tip} smaller or equal to {param.max}.") + else: + self.edit.setToolTip("") + + def _update_preview(self): + if self.type == self.FROM_RANGE: + self.range_preview.set_steps(self.steps) + else: + self.range_preview.set_steps(None) + + def clear(self): + self.cancel() + self.graph.clear_all() + + @gui.deferred + def commit(self): + self.graph.clear_all() + if self._data is None or self._learner is None or \ + not self.fitted_parameters or not self.steps: + return + self.start(run, self._data, self._learner, + self.fitted_parameters[self.parameter_index], + self.initial_parameters, self.steps) + + def on_done(self, result: FitterResults): + self.graph.set_data(*result) + + def on_exception(self, ex: Exception): + self.Error.unknown_err(ex) + + def on_partial_result(self, _): + pass + + def onDeleteWidget(self): + self.shutdown() + super().onDeleteWidget() + + def send_report(self): + if self._data is None or self._learner is None \ + or not self.fitted_parameters: + return + parameter = self.fitted_parameters[self.parameter_index].label + self.report_items("Settings", + [("Parameter", parameter), + ("Range", ", ".join(map(str, self.steps)))]) + self.report_name("Plot") + self.report_plot() + + def set_visual_settings(self, key: KeyType, value: ValueType): + self.graph.parameter_setter.set_parameter(key, value) + # pylint: disable=unsupported-assignment-operation + self.visual_settings[key] = value + + +if __name__ == "__main__": + from Orange.regression import PLSRegressionLearner + + WidgetPreview(OWParameterFitter).run( + set_data=Table("housing"), set_learner=PLSRegressionLearner()) diff --git a/Orange/widgets/evaluate/owpermutationplot.py b/Orange/widgets/evaluate/owpermutationplot.py new file mode 100644 index 00000000000..7000dc10006 --- /dev/null +++ b/Orange/widgets/evaluate/owpermutationplot.py @@ -0,0 +1,387 @@ +from typing import Optional, Tuple, Callable, List, Dict, Union + +import numpy as np +from scipy.stats import spearmanr, linregress +from AnyQt.QtCore import Qt +from AnyQt.QtWidgets import QLabel +import pyqtgraph as pg + +from orangewidget.utils.visual_settings_dlg import VisualSettingsDialog, \ + KeyType, ValueType +from Orange.base import Learner +from Orange.data import Table +from Orange.data.table import DomainTransformationError +from Orange.evaluation import CrossValidation, R2, AUC, TestOnTrainingData, \ + Results +from Orange.evaluation.scoring import Score +from Orange.util import dummy_callback +from Orange.widgets import gui +from Orange.widgets.settings import Setting +from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState +from Orange.widgets.utils.multi_target import check_multiple_targets_input +from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.owscatterplotgraph import LegendItem +from Orange.widgets.visualize.utils.customizableplot import \ + CommonParameterSetter, Updater +from Orange.widgets.visualize.utils.plotutils import PlotWidget +from Orange.widgets.widget import OWWidget, Input, Msg + +N_FOLD = 7 +# corr, scores_tr, intercept_tr, slope_tr, +# scores_cv, intercept_cv, slope_cv, score_name +PermutationResults = \ + Tuple[np.ndarray, List, float, float, List, float, float, str] + + +def _f_lin( + intercept: float, + slope: float, + x: Union[float, np.ndarray] +) -> Union[float, np.ndarray]: + return intercept + slope * x + + +def _correlation(y: np.ndarray, y_pred: np.ndarray) -> float: + return spearmanr(y, y_pred)[0] * 100 + + +def _validate( + data: Table, + learner: Learner, + scorer: Score +) -> Tuple[float, float]: + res: Results = TestOnTrainingData()(data, [learner], + suppresses_exceptions=False) + res_cv: Results = CrossValidation(k=N_FOLD)(data, [learner], + suppresses_exceptions=False) + # pylint: disable=unsubscriptable-object + return scorer(res)[0], scorer(res_cv)[0] + + +def permutation( + data: Table, + learner: Learner, + n_perm: int = 100, + progress_callback: Callable = dummy_callback +) -> PermutationResults: + scorer = AUC if data.domain.has_discrete_class else R2 + + score_tr, score_cv = _validate(data, learner, scorer) + scores_tr = [score_tr] + scores_cv = [score_cv] + correlations = [100.0] + progress_callback(0, "Calculating...") + np.random.seed(0) + + data_perm = data.copy() + for i in range(n_perm): + progress_callback(i / n_perm) + np.random.shuffle(data_perm.Y) + score_tr, score_cv = _validate(data_perm, learner, scorer) + correlations.append(_correlation(data.Y, data_perm.Y)) + scores_tr.append(score_tr) + scores_cv.append(score_cv) + + correlations = np.abs(correlations) + res_tr = linregress([correlations[0], np.mean(correlations[1:])], + [scores_tr[0], np.mean(scores_tr[1:])]) + res_cv = linregress([correlations[0], np.mean(correlations[1:])], + [scores_cv[0], np.mean(scores_cv[1:])]) + + return (correlations, scores_tr, res_tr.intercept, res_tr.slope, + scores_cv, res_cv.intercept, res_cv.slope, scorer.name) + + +def run( + data: Table, + learner: Learner, + n_perm: int, + state: TaskState +) -> PermutationResults: + def callback(i: float, status: str = ""): + state.set_progress_value(i * 100) + if status: + state.set_status(status) + if state.is_interruption_requested(): + # pylint: disable=broad-exception-raised + raise Exception + + return permutation(data, learner, n_perm, callback) + + +class ParameterSetter(CommonParameterSetter): + GRID_LABEL, SHOW_GRID_LABEL = "Gridlines", "Show" + DEFAULT_ALPHA_GRID, DEFAULT_SHOW_GRID = 80, True + + def __init__(self, master): + self.grid_settings: Dict = None + self.master: PermutationPlot = master + super().__init__() + + def update_setters(self): + self.grid_settings = { + Updater.ALPHA_LABEL: self.DEFAULT_ALPHA_GRID, + self.SHOW_GRID_LABEL: self.DEFAULT_SHOW_GRID, + } + + self.initial_settings = { + self.LABELS_BOX: { + self.FONT_FAMILY_LABEL: self.FONT_FAMILY_SETTING, + self.TITLE_LABEL: self.FONT_SETTING, + self.AXIS_TITLE_LABEL: self.FONT_SETTING, + self.AXIS_TICKS_LABEL: self.FONT_SETTING, + self.LEGEND_LABEL: self.FONT_SETTING, + }, + self.PLOT_BOX: { + self.GRID_LABEL: { + self.SHOW_GRID_LABEL: (None, True), + Updater.ALPHA_LABEL: (range(0, 255, 5), + self.DEFAULT_ALPHA_GRID), + }, + }, + } + + def update_grid(**settings): + self.grid_settings.update(**settings) + self.master.showGrid( + x=self.grid_settings[self.SHOW_GRID_LABEL], + y=self.grid_settings[self.SHOW_GRID_LABEL], + alpha=self.grid_settings[Updater.ALPHA_LABEL] / 255) + + self._setters[self.PLOT_BOX] = {self.GRID_LABEL: update_grid} + + @property + def title_item(self): + return self.master.getPlotItem().titleLabel + + @property + def axis_items(self): + return [value["item"] for value in + self.master.getPlotItem().axes.values()] + + @property + def legend_items(self): + return self.master.legend.items + + +class PermutationPlot(PlotWidget): + def __init__(self): + super().__init__(enableMenu=False) + self.legend = self._create_legend() + self.parameter_setter = ParameterSetter(self) + self.setMouseEnabled(False, False) + self.hideButtons() + + self.showGrid(True, True) + text = "Correlation between original Y and permuted Y (%)" + self.setLabel(axis="bottom", text=text) + + def _create_legend(self) -> LegendItem: + legend = LegendItem() + legend.setParentItem(self.getViewBox()) + legend.anchor((1, 1), (1, 1), offset=(-5, -5)) + legend.hide() + return legend + + def set_data( + self, + corr: np.ndarray, + scores_tr: List, + intercept_tr: float, + slope_tr: float, + scores_cv: List, + intercept_cv: float, + slope_cv: float, + score_name: str + ): + self.clear() + self.setLabel(axis="left", text=score_name) + + y = 0.5 if score_name == "AUC" else 0 + line = pg.InfiniteLine(pos=(0, y), angle=0, pen=pg.mkPen("#000")) + + x = np.array([0, 100]) + pen = pg.mkPen("#000", width=2, style=Qt.DashLine) + y_tr = _f_lin(intercept_tr, slope_tr, x) + y_cv = _f_lin(intercept_cv, slope_cv, x) + line_tr = pg.PlotCurveItem(x, y_tr, pen=pen) + line_cv = pg.PlotCurveItem(x, y_cv, pen=pen) + + point_pen = pg.mkPen("#333") + kwargs_tr = {"pen": point_pen, "symbol": "o", "brush": "#6fa255"} + kwargs_cv = {"pen": point_pen, "symbol": "s", "brush": "#3a78b6"} + + kwargs = {"size": 12, "hoverable": True, + "tip": 'x: {x:.3g}\ny: {y:.3g}'.format} + kwargs.update(kwargs_tr) + points_tr = pg.ScatterPlotItem(corr, scores_tr, **kwargs) + kwargs.update(kwargs_cv) + points_cv = pg.ScatterPlotItem(corr, scores_cv, **kwargs) + + self.addItem(points_tr) + self.addItem(points_cv) + self.addItem(line) + self.addItem(line_tr) + self.addItem(line_cv) + + self.legend.clear() + self.legend.addItem(pg.ScatterPlotItem(**kwargs_tr), "Train") + self.legend.addItem(pg.ScatterPlotItem(**kwargs_cv), "CV") + self.legend.show() + + +class OWPermutationPlot(OWWidget, ConcurrentWidgetMixin): + name = "Permutation Plot" + description = "Permutation analysis plotting" + icon = "icons/PermutationPlot-symbolic.svg" + priority = 1100 + keywords = "_keywords" + + n_permutations = Setting(20) + visual_settings = Setting({}, schema_only=True) + graph_name = "graph.plotItem" + + class Inputs: + data = Input("Data", Table) + learner = Input("Learner", Learner) + + class Error(OWWidget.Error): + domain_transform_err = Msg("{}") + unknown_err = Msg("{}") + not_enough_data = Msg(f"At least {N_FOLD} instances are needed.") + incompatible_learner = Msg("{}") + + def __init__(self): + OWWidget.__init__(self) + ConcurrentWidgetMixin.__init__(self) + self._data: Optional[Table] = None + self._learner: Optional[Learner] = None + self._info: QLabel = None + self.graph: PermutationPlot = None + self.setup_gui() + VisualSettingsDialog( + self, self.graph.parameter_setter.initial_settings + ) + + def setup_gui(self): + self._add_plot() + self._add_controls() + + def _add_plot(self): + box = gui.vBox(self.mainArea) + self.graph = PermutationPlot() + box.layout().addWidget(self.graph) + + def _add_controls(self): + box = gui.vBox(self.controlArea, "Settings") + gui.spin(box, self, "n_permutations", label="Permutations:", + minv=1, maxv=1000, callback=self._run) + gui.rubber(self.controlArea) + + box = gui.vBox(self.controlArea, "Info") + self._info = gui.label(box, self, "", textFormat=Qt.RichText, + minimumWidth=180) + self.__set_info(None) + + def __set_info(self, result: PermutationResults): + html = "No data available." + if result is not None: + intercept_tr, slope_tr, _, intercept_cv, slope_cv = result[2: -1] + y_tr = _f_lin(intercept_tr, slope_tr, 100) + y_cv = _f_lin(intercept_cv, slope_cv, 100) + html = f""" + + + + + + + + + + + + + + + + +
    Corr = 0Corr = 100
    Train{intercept_tr:.4f}{y_tr:.4f}
    CV{intercept_cv:.4f}{y_cv:.4f}
    + """ + self._info.setText(html) + + @Inputs.data + @check_multiple_targets_input + def set_data(self, data: Table): + self.Error.not_enough_data.clear() + self._data = data + if self._data and len(self._data) < N_FOLD: + self.Error.not_enough_data() + self._data = None + + @Inputs.learner + def set_learner(self, learner: Learner): + self._learner = learner + + def handleNewSignals(self): + self.Error.incompatible_learner.clear() + self.Error.unknown_err.clear() + self.Error.domain_transform_err.clear() + self.clear() + if self._data is None or self._learner is None: + return + + reason = self._learner.incompatibility_reason(self._data.domain) + if reason: + self.Error.incompatible_learner(reason) + return + + self._run() + + def clear(self): + self.cancel() + self.graph.clear() + self.graph.setTitle() + self.__set_info(None) + + def _run(self): + if self._data is None or self._learner is None: + return + self.start(run, self._data, self._learner, self.n_permutations) + + def on_done(self, result: PermutationResults): + self.graph.set_data(*result) + self.__set_info(result) + + def on_exception(self, ex: Exception): + if isinstance(ex, DomainTransformationError): + self.Error.domain_transform_err(ex) + else: + self.Error.unknown_err(ex) + + def on_partial_result(self, _): + pass + + def onDeleteWidget(self): + self.shutdown() + super().onDeleteWidget() + + def send_report(self): + if self._data is None or self._learner is None: + return + self.report_items("Settings", [("Permutations", self.n_permutations)]) + self.report_raw("Info", self._info.text()) + self.report_name("Plot") + self.report_plot() + + def set_visual_settings(self, key: KeyType, value: ValueType): + self.graph.parameter_setter.set_parameter(key, value) + # pylint: disable=unsupported-assignment-operation + self.visual_settings[key] = value + + +if __name__ == "__main__": + from Orange.classification import LogisticRegressionLearner + + WidgetPreview(OWPermutationPlot).run( + set_data=Table("iris"), set_learner=LogisticRegressionLearner()) diff --git a/Orange/widgets/evaluate/owpredictions.py b/Orange/widgets/evaluate/owpredictions.py index 4be4959c081..5773b8005c4 100644 --- a/Orange/widgets/evaluate/owpredictions.py +++ b/Orange/widgets/evaluate/owpredictions.py @@ -1,70 +1,92 @@ -from collections import namedtuple +import math +import warnings from contextlib import contextmanager from functools import partial from operator import itemgetter -from itertools import chain -from typing import Set, List, Sequence, Union +from itertools import chain, product +from typing import Set, Sequence, Union, Optional, List, NamedTuple import numpy from AnyQt.QtWidgets import ( - QTableView, QListWidget, QSplitter, QToolTip, QStyle, QApplication, - QSizePolicy -) -from AnyQt.QtGui import QPainter, QStandardItem, QPen, QColor + QTableView, QSplitter, QToolTip, QStyle, QApplication, QSizePolicy, + QPushButton, QStyledItemDelegate, QStyleOptionViewItem) +from AnyQt.QtGui import QPainter, QStandardItem, QPen, QColor, QBrush from AnyQt.QtCore import ( - Qt, QSize, QRect, QRectF, QPoint, QLocale, - QModelIndex, QAbstractTableModel, QSortFilterProxyModel, pyqtSignal, QTimer, + Qt, QSize, QRect, QRectF, QPoint, QPointF, QLocale, + QModelIndex, pyqtSignal, QTimer, QItemSelectionModel, QItemSelection) -from orangewidget.report import plural +from orangewidget.utils.itemmodels import AbstractSortTableModel +from orangewidget.utils.signals import LazyValue import Orange from Orange.evaluation import Results from Orange.base import Model -from Orange.data import ContinuousVariable, DiscreteVariable, Value, Domain +from Orange.data import ContinuousVariable, DiscreteVariable, Domain from Orange.data.table import DomainTransformationError from Orange.data.util import get_unique_names from Orange.widgets import gui, settings from Orange.widgets.evaluate.utils import ( ScoreTable, usable_scorers, learner_name, scorer_caller) from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import OWWidget, Msg, Input, Output +from Orange.widgets.widget import OWWidget, Msg, Input, Output, MultiInput from Orange.widgets.utils.itemmodels import TableModel +from Orange.widgets.utils.annotated_data import lazy_annotated_table, \ + domain_with_annotation_column, create_annotated_table +from Orange.widgets.utils.multi_target import multiple_targets_msg from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.state_summary import format_summary_details from Orange.widgets.utils.colorpalettes import LimitedDiscretePalette -from Orange.widgets.utils.itemdelegates import DataDelegate, TableDataDelegate +from Orange.widgets.utils.itemdelegates import TableDataDelegate +from Orange.widgets.utils.localization import pl # Input slot for the Predictors channel -PredictorSlot = namedtuple( - "PredictorSlot", - ["predictor", # The `Model` instance - "name", # Predictor name - "results"] # Computed prediction results or None. -) +PredictorSlot = NamedTuple( + "PredictorSlot", [ + ("predictor", Model), # The `Model` instance + ("name", str), # Predictor name + ("results", Optional[Results]), # Computed prediction results or None. +]) + + +NO_ERR, DIFF_ERROR, ABSDIFF_ERROR, REL_ERROR, ABSREL_ERROR = range(5) +ERROR_OPTS = ["(None)", "Difference", "Absolute difference", + "Relative", "Absolute relative"] +ERROR_TOOLTIPS = [ + "Don't show columns with errors", + "Show difference between predicted and actual value", + "Show absolute difference between predicted and actual value", + "Show relative difference between predicted and actual value", + "Show absolute value of relative difference between predicted and actual value"] class OWPredictions(OWWidget): name = "Predictions" - icon = "icons/Predictions.svg" + icon = "icons/Predictions-symbolic.svg" priority = 200 description = "Display predictions of models for an input dataset." - keywords = [] + keywords = "predictions" - buttons_area_orientation = None + settings_version = 3 + + want_control_area = False class Inputs: data = Input("Data", Orange.data.Table) - predictors = Input("Predictors", Model, multiple=True) + predictors = MultiInput("Predictors", Model, filter_none=True) class Outputs: - predictions = Output("Predictions", Orange.data.Table) + selected_predictions = Output("Selected Predictions", Orange.data.Table, + default=True, replaces=["Predictions"]) + annotated = Output("Predictions", Orange.data.Table) evaluation_results = Output("Evaluation Results", Results) class Warning(OWWidget.Warning): empty_data = Msg("Empty dataset") wrong_targets = Msg( "Some model(s) predict a different target (see more ...)\n{}") + missing_targets = Msg("Instances with missing targets " + "are ignored while scoring.") class Error(OWWidget.Error): predictor_failed = Msg("Some predictor(s) failed (see more ...)\n{}") @@ -74,32 +96,88 @@ class Error(OWWidget.Error): score_table = settings.SettingProvider(ScoreTable) #: List of selected class value indices in the `class_values` list - selected_classes = settings.ContextSetting([]) + PROB_OPTS = ["(None)", + "Classes in data", "Classes known to the model", "Classes in data and model"] + PROB_TOOLTIPS = ["Don't show probabilities", + "Show probabilities for classes in the data", + "Show probabilities for classes known to the model,\n" + "including those that don't appear in this data", + "Show probabilities for classes in data that are also\n" + "known to the model" + ] + TARGET_AVERAGE = "(Average over classes)" + + NO_PROBS, DATA_PROBS, MODEL_PROBS, BOTH_PROBS = range(4) + shown_probs = settings.ContextSetting(NO_PROBS) selection = settings.Setting([], schema_only=True) + show_scores = settings.Setting(True) + target_class = settings.ContextSetting("") + show_probability_errors = settings.ContextSetting(True) + show_reg_errors = settings.ContextSetting(DIFF_ERROR) def __init__(self): super().__init__() self.data = None # type: Optional[Orange.data.Table] - self.predictors = {} # type: Dict[object, PredictorSlot] + self.predictors = [] # type: List[PredictorSlot] self.class_values = [] # type: List[str] self._delegates = [] + self.scorer_errors = [] self.left_width = 10 self.selection_store = None self.__pending_selection = self.selection - controlBox = gui.vBox(self.controlArea, "Show probabilities for") + self._prob_controls = [] - gui.listBox(controlBox, self, "selected_classes", "class_values", - callback=self._update_prediction_delegate, - selectionMode=QListWidget.ExtendedSelection, - sizePolicy=(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding), - sizeHint=QSize(1, 350), - minimumHeight=100) - self.reset_button = gui.button( - controlBox, self, "Restore Original Order", - callback=self._reset_order, - tooltip="Show rows in the original order") + predopts = gui.hBox( + None, sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)) + self._prob_controls = [ + gui.widgetLabel(predopts, "Show probabilities for"), + gui.comboBox( + predopts, self, "shown_probs", contentsLength=30, + callback=self._update_prediction_delegate), + ] + + self._cls_error_controls = [ + gui.checkBox( + predopts, self, "show_probability_errors", + "Show classification errors", + tooltip="Show 1 - probability assigned to the correct class", + callback=self._update_errors_visibility + ) + ] + + err_label = gui.widgetLabel(predopts, "Shown regression error: ") + err_combo = gui.comboBox( + predopts, self, "show_reg_errors", items=ERROR_OPTS, + callback=self._reg_error_changed, + toolTip="See tooltips for individual options") + self._reg_error_controls = [err_label, err_combo] + for i, tip in enumerate(ERROR_TOOLTIPS): + err_combo.setItemData(i, tip, Qt.ToolTipRole) + + gui.rubber(predopts) + self.reset_button = button = QPushButton("Restore Original Order") + button.clicked.connect(self._reset_order) + button.setToolTip("Show rows in the original order") + button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + predopts.layout().addWidget(self.reset_button) + + self.score_opt_box = scoreopts = gui.hBox( + None, sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)) + gui.checkBox( + scoreopts, self, "show_scores", "Show perfomance scores", + callback=self._update_score_table_visibility + ) + gui.separator(scoreopts, 32) + self._target_controls = [ + gui.widgetLabel(scoreopts, "Target class:"), + gui.comboBox( + scoreopts, self, "target_class", items=[], contentsLength=30, + sendSelectedValue=True, callback=self._on_target_changed, + emptyString=self.TARGET_AVERAGE) + ] + gui.rubber(scoreopts) table_opts = dict(horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOn, horizontalScrollMode=QTableView.ScrollPerPixel, @@ -134,21 +212,25 @@ def __init__(self): self.splitter.addWidget(self.dataview) self.score_table = ScoreTable(self) - self.vsplitter = gui.vBox(self.mainArea) - self.vsplitter.layout().addWidget(self.splitter) - self.vsplitter.layout().addWidget(self.score_table.view) - - def get_selection_store(self, proxy): - # Both proxies map the same, so it doesn't matter which one is used + self.mainArea.layout().setSpacing(0) + self.mainArea.layout().setContentsMargins(4, 0, 4, 4) + self.mainArea.layout().addWidget(predopts) + self.mainArea.layout().addWidget(self.splitter) + self.mainArea.layout().addWidget(scoreopts) + self.mainArea.layout().addWidget(self.score_table.view) + + def get_selection_store(self, model): + # Both models map the same, so it doesn't matter which one is used # to initialize SharedSelectionStore if self.selection_store is None: - self.selection_store = SharedSelectionStore(proxy) + self.selection_store = SharedSelectionStore(model) return self.selection_store @Inputs.data @check_sql_input def set_data(self, data): self.Warning.empty_data(shown=data is not None and not data) + self.closeContext() self.data = data self.selection_store = None if not data: @@ -157,12 +239,10 @@ def set_data(self, data): else: # force full reset of the view's HeaderView state self.dataview.setModel(None) - model = TableModel(data, parent=None) - modelproxy = SortProxyModel() - modelproxy.setSourceModel(model) - self.dataview.setModel(modelproxy) + model = DataModel(data, parent=None) + self.dataview.setModel(model) sel_model = SharedSelectionModel( - self.get_selection_store(modelproxy), modelproxy, self.dataview) + self.get_selection_store(model), model, self.dataview) self.dataview.setSelectionModel(sel_model) if self.__pending_selection is not None: self.selection = self.__pending_selection @@ -177,6 +257,8 @@ def set_data(self, data): self._update_data_sort_order, self.dataview, self.predictionsview)) + self._set_target_combos() + self.openContext(self.class_var.values if self.is_discrete_class else ()) self._invalidate_predictions() def _store_selection(self): @@ -184,40 +266,108 @@ def _store_selection(self): @property def class_var(self): - return self.data and self.data.domain.class_var + return self.data is not None and self.data.domain.class_var + + @property + def is_discrete_class(self): + return bool(self.class_var) and self.class_var.is_discrete + + @property + def shown_errors(self): + return self.class_var and ( + self.show_probability_errors if self.is_discrete_class + else self.show_reg_errors != NO_ERR) - # pylint: disable=redefined-builtin @Inputs.predictors - def set_predictor(self, predictor=None, id=None): - if id in self.predictors: - if predictor is not None: - self.predictors[id] = self.predictors[id]._replace( - predictor=predictor, name=predictor.name, results=None) - else: - del self.predictors[id] - elif predictor is not None: - self.predictors[id] = PredictorSlot(predictor, predictor.name, None) + def set_predictor(self, index, predictor: Model): + item = self.predictors[index] + self.predictors[index] = item._replace( + predictor=predictor, name=predictor.name, results=None + ) + + @Inputs.predictors.insert + def insert_predictor(self, index, predictor: Model): + item = PredictorSlot(predictor, predictor.name, None) + self.predictors.insert(index, item) + + @Inputs.predictors.remove + def remove_predictor(self, index): + self.predictors.pop(index) + + def _set_target_combos(self): + prob_combo = self.controls.shown_probs + target_combo = self.controls.target_class + prob_combo.clear() + target_combo.clear() + + self._update_control_visibility() + + # Set these to prevent warnings when setting self.shown_probs + target_combo.addItem(self.TARGET_AVERAGE) + prob_combo.addItems(self.PROB_OPTS) + + if self.is_discrete_class: + target_combo.addItems(self.class_var.values) + prob_combo.addItems(self.class_var.values) + for i, tip in enumerate(self.PROB_TOOLTIPS): + prob_combo.setItemData(i, tip, Qt.ToolTipRole) + self.shown_probs = self.DATA_PROBS + self.target_class = "" + else: + self.shown_probs = self.NO_PROBS + model = prob_combo.model() + for v in (self.DATA_PROBS, self.BOTH_PROBS): + item = model.item(v) + item.setFlags(item.flags() & ~Qt.ItemIsEnabled) + + def _update_control_visibility(self): + visible_prob = self.is_discrete_class \ + or any(slot.predictor.domain.has_discrete_class + for slot in self.predictors) + for widget in self._prob_controls: + widget.setVisible(visible_prob) + + for widget in self._cls_error_controls: + widget.setVisible(self.is_discrete_class) + for widget in self._reg_error_controls: + widget.setVisible(bool(self.class_var) and not self.is_discrete_class) + + for widget in self._target_controls: + widget.setVisible(self.is_discrete_class and self.show_scores) + + self.score_opt_box.setVisible(bool(self.class_var)) + + def _reg_error_changed(self): + model = self.predictionsview.model() + if model is not None: + model.setRegErrorType(self.show_reg_errors) + self._update_prediction_delegate() + + def _update_errors_visibility(self): + shown = self.shown_errors + view = self.predictionsview + for col, slot in enumerate(self.predictors): + view.setColumnHidden( + 2 * col + 1, + not shown or + self.is_discrete_class is not slot.predictor.domain.has_discrete_class) + self._commit_predictions() def _set_class_values(self): - class_values = [] - for slot in self.predictors.values(): + self.class_values = [] + if self.is_discrete_class: + self.class_values += self.data.domain.class_var.values + for slot in self.predictors: class_var = slot.predictor.domain.class_var if class_var and class_var.is_discrete: for value in class_var.values: - if value not in class_values: - class_values.append(value) - - if self.class_var and self.class_var.is_discrete: - values = self.class_var.values - self.class_values = sorted( - class_values, key=lambda val: val not in values) - self.selected_classes = [ - i for i, name in enumerate(class_values) if name in values] - else: - self.class_values = class_values # This assignment updates listview - self.selected_classes = [] + if value not in self.class_values: + self.class_values.append(value) def handleNewSignals(self): + # Disconnect the model: the model and the delegate will be inconsistent + # between _set_class_values and update_predictions_model. + self.predictionsview.setModel(None) self._set_class_values() self._call_predictors() self._update_scores() @@ -226,6 +376,9 @@ def handleNewSignals(self): self._set_errors() self.commit() + def _on_target_changed(self): + self._update_scores() + def _call_predictors(self): if not self.data: return @@ -236,19 +389,20 @@ def _call_predictors(self): else: classless_data = self.data - for inputid, slot in self.predictors.items(): + for index, slot in enumerate(self.predictors): if isinstance(slot.results, Results): continue predictor = slot.predictor try: - if predictor.domain.class_var.is_discrete: + class_var = predictor.domain.class_var + if class_var and predictor.domain.class_var.is_discrete: pred, prob = predictor(classless_data, Model.ValueProbs) else: pred = predictor(classless_data, Model.Value) prob = numpy.zeros((len(pred), 0)) except (ValueError, DomainTransformationError) as err: - self.predictors[inputid] = \ + self.predictors[index] = \ slot._replace(results=f"{predictor.name}: {err}") continue @@ -261,7 +415,7 @@ def _call_predictors(self): results.unmapped_probabilities = prob results.unmapped_predicted = pred results.probabilities = results.predicted = None - self.predictors[inputid] = slot._replace(results=results) + self.predictors[index] = slot._replace(results=results) target = predictor.domain.class_var if target != self.class_var: @@ -271,73 +425,119 @@ def _call_predictors(self): backmappers, n_values = predictor.get_backmappers(self.data) prob = predictor.backmap_probs(prob, n_values, backmappers) pred = predictor.backmap_value(pred, prob, n_values, backmappers) + if len(pred.shape) > 1 and pred.shape[1] > 1: + self.predictors[index] = \ + slot._replace(results=multiple_targets_msg) + continue results.predicted = pred.reshape((1, len(self.data))) results.probabilities = prob.reshape((1,) + prob.shape) def _update_scores(self): model = self.score_table.model + if self.is_discrete_class and self.target_class: + target = self.class_var.values.index(self.target_class) + else: + target = None model.clear() - scorers = usable_scorers(self.class_var) if self.class_var else [] + scorers = usable_scorers(self.data.domain) if self.data else [] self.score_table.update_header(scorers) - errors = [] - for inputid, pred in self.predictors.items(): - results = self.predictors[inputid].results + self.scorer_errors = errors = [] + for pred in self.predictors: + results = pred.results if not isinstance(results, Results) or results.predicted is None: continue row = [QStandardItem(learner_name(pred.predictor)), QStandardItem("N/A"), QStandardItem("N/A")] - for scorer in scorers: - item = QStandardItem() - try: - score = scorer_caller(scorer, results)()[0] - item.setText(f"{score:.3f}") - except Exception as exc: # pylint: disable=broad-except - item.setToolTip(str(exc)) - if scorer.name in self.score_table.shown_scores: - errors.append(str(exc)) - row.append(item) - self.score_table.model.appendRow(row) - + actual = results.actual + predicted = results.predicted + probabilities = results.probabilities + try: + if self.class_var: + mask = numpy.isnan(results.actual) + else: + mask = numpy.any(numpy.isnan(results.actual), axis=1) + no_targets = mask.sum() == len(results.actual) + results.actual = results.actual[~mask] + results.predicted = results.predicted[:, ~mask] + results.probabilities = results.probabilities[:, ~mask] + + for scorer in scorers: + item = QStandardItem() + if no_targets: + item.setText("NA") + else: + try: + score = scorer_caller(scorer, results, + target=target)()[0] + item.setText(f"{score:.3f}") + except Exception as exc: # pylint: disable=broad-except + item.setToolTip(str(exc)) + # false pos.; pylint: disable=unsupported-membership-test + if scorer.name in self.score_table.shown_scores: + errors.append(str(exc)) + row.append(item) + self.score_table.model.appendRow(row) + + finally: + results.actual = actual + results.predicted = predicted + results.probabilities = probabilities + + self._update_score_table_visibility() + + def _update_score_table_visibility(self): + self._update_control_visibility() view = self.score_table.view - if model.rowCount(): + nmodels = self.score_table.model.rowCount() + if nmodels and self.show_scores: view.setVisible(True) view.ensurePolished() + view.resizeColumnsToContents() + view.resizeRowsToContents() view.setFixedHeight( 5 + view.horizontalHeader().height() + - view.verticalHeader().sectionSize(0) * model.rowCount()) + view.verticalHeader().sectionSize(0) * nmodels) + + errors = "\n".join(self.scorer_errors) + self.Error.scorer_failed(errors, shown=bool(errors)) else: view.setVisible(False) - - self.Error.scorer_failed("\n".join(errors), shown=bool(errors)) + self.Error.scorer_failed.clear() + self._set_errors() def _set_errors(self): # Not all predictors are run every time, so errors can't be collected # in _call_predictors errors = "\n".join( f"- {p.predictor.name}: {p.results}" - for p in self.predictors.values() + for p in self.predictors if isinstance(p.results, str) and p.results) self.Error.predictor_failed(errors, shown=bool(errors)) if self.class_var: inv_targets = "\n".join( f"- {pred.name} predicts '{pred.domain.class_var.name}'" - for pred in (p.predictor for p in self.predictors.values() + for pred in (p.predictor for p in self.predictors if isinstance(p.results, Results) and p.results.probabilities is None)) self.Warning.wrong_targets(inv_targets, shown=bool(inv_targets)) + + show_warning = numpy.isnan(self.data.Y).any() and self.predictors \ + and self.show_scores + self.Warning.missing_targets(shown=show_warning) else: self.Warning.wrong_targets.clear() + self.Warning.missing_targets.clear() def _get_details(self): details = "Data:
    " details += format_summary_details(self.data, format=Qt.RichText) details += "
    " - pred_names = [v.name for v in self.predictors.values()] + pred_names = [v.name for v in self.predictors] n_predictors = len(self.predictors) if n_predictors: n_valid = len(self._non_errored_predictors()) - details += plural("Model: {number} model{s}", n_predictors) + details += f"Model: {n_predictors} {pl(n_predictors, 'model')}" if n_valid != n_predictors: details += f" ({n_predictors - n_valid} failed)" details += "
      " @@ -349,11 +549,11 @@ def _get_details(self): return details def _invalidate_predictions(self): - for inputid, pred in list(self.predictors.items()): - self.predictors[inputid] = pred._replace(results=None) + for i, pred in enumerate(self.predictors): + self.predictors[i] = pred._replace(results=None) def _non_errored_predictors(self): - return [p for p in self.predictors.values() + return [p for p in self.predictors if isinstance(p.results, Results)] def _reordered_probabilities(self, prediction): @@ -366,23 +566,31 @@ def _reordered_probabilities(self, prediction): return new_probs def _update_predictions_model(self): - results = [] headers = [] + all_values = [] + all_probs = [] for p in self._non_errored_predictors(): values = p.results.unmapped_predicted target = p.predictor.domain.class_var - if target.is_discrete: + if target and target.is_discrete: # order probabilities in order from Show prob. for prob = self._reordered_probabilities(p) - values = [Value(target, v) for v in values] + values = numpy.array(target.values)[values.astype(int)] else: prob = numpy.zeros((len(values), 0)) - results.append((values, prob)) + all_values.append(values) + all_probs.append(prob) headers.append(p.predictor.name) - if results: - results = list(zip(*(zip(*res) for res in results))) - model = PredictionsModel(results, headers) + if all_values: + model = PredictionsModel( + all_values, all_probs, + self.data.Y if self.class_var else None, + headers, reg_error_type=self.show_reg_errors) + model.list_sorted.connect( + partial( + self._update_data_sort_order, self.predictionsview, + self.dataview)) else: model = None @@ -390,51 +598,29 @@ def _update_predictions_model(self): self.selection_store.unregister( self.predictionsview.selectionModel()) - predmodel = PredictionsSortProxyModel() - predmodel.setSourceModel(model) - predmodel.setDynamicSortFilter(True) - self.predictionsview.setModel(predmodel) - - self.predictionsview.setSelectionModel( - SharedSelectionModel(self.get_selection_store(predmodel), - predmodel, self.predictionsview)) + self.predictionsview.setModel(model) + if model is not None: + self.predictionsview.setSelectionModel( + SharedSelectionModel(self.get_selection_store(model), + model, self.predictionsview)) hheader = self.predictionsview.horizontalHeader() hheader.setSortIndicatorShown(False) - # SortFilterProxyModel is slow due to large abstraction overhead - # (every comparison triggers multiple `model.index(...)`, - # model.rowCount(...), `model.parent`, ... calls) - hheader.setSectionsClickable(predmodel.rowCount() < 20000) - - self.predictionsview.model().list_sorted.connect( - partial( - self._update_data_sort_order, self.predictionsview, - self.dataview)) - - self.predictionsview.resizeColumnsToContents() + hheader.setSectionsClickable(True) def _update_data_sort_order(self, sort_source_view, sort_dest_view): - sort_dest = sort_dest_view.model() sort_source = sort_source_view.model() - sortindicatorshown = False + sort_dest = sort_dest_view.model() + + sort_source_view.horizontalHeader().setSortIndicatorShown( + sort_source.sortColumn() != -1) + sort_dest_view.horizontalHeader().setSortIndicatorShown(False) + if sort_dest is not None: - assert isinstance(sort_dest, QSortFilterProxyModel) - n = sort_dest.rowCount() if sort_source is not None and sort_source.sortColumn() >= 0: - sortind = numpy.argsort( - [sort_source.mapToSource(sort_source.index(i, 0)).row() - for i in range(n)]) - sortind = numpy.array(sortind, numpy.int) - sortindicatorshown = True + sort_dest.setSortIndices(sort_source.mapToSourceRows(...)) else: - sortind = None - - sort_dest.setSortIndices(sortind) - - sort_dest_view.horizontalHeader().setSortIndicatorShown( - False) - sort_source_view.horizontalHeader().setSortIndicatorShown( - sortindicatorshown) + sort_dest.setSortIndices(None) self.commit() def _reset_order(self): @@ -461,7 +647,9 @@ def _all_color_values(self): p.predictor.domain.class_var.colors, p.predictor.domain.class_var.values ), key=itemgetter(1)))) - for p in predictors if p.predictor.domain.class_var.is_discrete + for p in predictors + if p.predictor.domain.class_var and + p.predictor.domain.class_var.is_discrete ] return color_values if color_values else [([], [])] @@ -504,29 +692,97 @@ def _get_colors(self): def _update_prediction_delegate(self): self._delegates.clear() colors = self._get_colors() - for col, slot in enumerate(self.predictors.values()): + shown_class = "" # just to silence warnings about undefined var + if self.shown_probs == self.NO_PROBS: + tooltip_probs = () + elif self.shown_probs == self.DATA_PROBS: + tooltip_probs = self.class_var.values + elif self.shown_probs >= len(self.PROB_OPTS): + shown_class = self.class_var.values[self.shown_probs + - len(self.PROB_OPTS)] + tooltip_probs = (shown_class, ) + sort_col_indices = [] + if self.data \ + and self.data.domain.class_var and not self.is_discrete_class: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", ".*All-NaN.*", RuntimeWarning) + minv = numpy.nanmin(self.data.Y) + maxv = numpy.nanmax(self.data.Y) + else: + minv = maxv = numpy.nan + model = self.predictionsview.model() + for col, slot in enumerate(self._non_errored_predictors()): target = slot.predictor.domain.class_var - shown_probs = ( - () if target.is_continuous else - [val if self.class_values[val] in target.values else None - for val in self.selected_classes] - ) - delegate = PredictionsItemDelegate( - None if target.is_continuous else self.class_values, - colors, - shown_probs, - target.format_str if target.is_continuous else None, - parent=self.predictionsview - ) + if target is not None and target.is_discrete: + shown_probs = self._shown_prob_indices(target, in_target=True) + if self.shown_probs in (self.MODEL_PROBS, self.BOTH_PROBS): + tooltip_probs = [self.class_values[i] + for i in shown_probs if i is not None] + delegate = ClassificationItemDelegate( + self.class_values, colors, shown_probs, tooltip_probs) + if self.is_discrete_class: + error_delegate = ClassificationErrorDelegate() + else: + error_delegate = NoopItemDelegate() + sort_col_indices.append([col for col in shown_probs + if col is not None]) + + else: + predictions = slot.results.unmapped_predicted + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", ".*All-NaN.*", + RuntimeWarning) + minpv = numpy.nanmin([minv, numpy.nanmin(predictions)]) + maxpv = numpy.nanmax([maxv, numpy.nanmax(predictions)]) + format_str = target.format_str if target is not None else None + delegate = RegressionItemDelegate(format_str, minpv, maxpv) + + if self.show_reg_errors == NO_ERR \ + or self.class_var is None or self.is_discrete_class: + error_delegate = NoopItemDelegate() + else: + errors = model.errorColumn(col) + centered = self.show_reg_errors in (REL_ERROR, DIFF_ERROR) + span = numpy.nanmax(numpy.abs(errors)) + error_delegate = RegressionErrorDelegate( + format_str, centered, span) + sort_col_indices.append(None) + # QAbstractItemView does not take ownership of delegates, so we must + delegate.setParent(self.predictionsview) self._delegates.append(delegate) - self.predictionsview.setItemDelegateForColumn(col, delegate) - self.predictionsview.setColumnHidden(col, False) + error_delegate.setParent(self.predictionsview) + self._delegates.append(error_delegate) + self.predictionsview.setItemDelegateForColumn(2 * col, delegate) + self.predictionsview.setColumnHidden(2 * col, False) + self.predictionsview.setItemDelegateForColumn(2 * col + 1, error_delegate) + + self._update_errors_visibility() self.predictionsview.resizeColumnsToContents() self._recompute_splitter_sizes() if self.predictionsview.model() is not None: - self.predictionsview.model().setProbInd(self.selected_classes) + self.predictionsview.model().setProbInd(sort_col_indices) + + def _shown_prob_indices(self, target: DiscreteVariable, in_target): + if self.shown_probs == self.NO_PROBS: + values = [] + elif self.shown_probs == self.DATA_PROBS: + values = self.class_var.values + elif self.shown_probs == self.MODEL_PROBS: + values = target.values + elif self.shown_probs == self.BOTH_PROBS: + # Don't use set intersection because it's unordered! + values = (value for value in self.class_var.values + if value in target.values) + else: + shown_cls_idx = self.shown_probs - len(self.PROB_OPTS) + values = [self.class_var.values[shown_cls_idx]] + + return [self.class_values.index(value) + if not in_target or value in target.values + else None + for value in values] def _recompute_splitter_sizes(self): if not self.data: @@ -550,19 +806,20 @@ def commit(self): def _commit_evaluation_results(self): slots = [p for p in self._non_errored_predictors() if p.results.predicted is not None] - if not slots: + if not slots or not self.class_var: self.Outputs.evaluation_results.send(None) return - nanmask = numpy.isnan(self.data.get_column_view(self.class_var)[0]) + nanmask = numpy.isnan(self.data.get_column(self.class_var)) data = self.data[~nanmask] results = Results(data, store_data=True) - results.folds = None + results.folds = [...] + results.models = numpy.array([[p.predictor for p in self.predictors]]) results.row_indices = numpy.arange(len(data)) results.actual = data.Y.ravel() results.predicted = numpy.vstack( tuple(p.results.predicted[0][~nanmask] for p in slots)) - if self.class_var and self.class_var.is_discrete: + if self.is_discrete_class: results.probabilities = numpy.array( [p.results.probabilities[0][~nanmask] for p in slots]) results.learner_names = [p.name for p in slots] @@ -570,16 +827,18 @@ def _commit_evaluation_results(self): def _commit_predictions(self): if not self.data: - self.Outputs.predictions.send(None) + self.Outputs.selected_predictions.send(None) + self.Outputs.annotated.send(None) return newmetas = [] newcolumns = [] - for slot in self._non_errored_predictors(): - if slot.predictor.domain.class_var.is_discrete: - self._add_classification_out_columns(slot, newmetas, newcolumns) + for i, slot in enumerate(self._non_errored_predictors()): + target = slot.predictor.domain.class_var + if target and target.is_discrete: + self._add_classification_out_columns(slot, newmetas, newcolumns, i) else: - self._add_regression_out_columns(slot, newmetas, newcolumns) + self._add_regression_out_columns(slot, newmetas, newcolumns, i) attrs = list(self.data.domain.attributes) metas = list(self.data.domain.metas) @@ -593,46 +852,73 @@ def _commit_predictions(self): names.append(uniq) metas += uniq_newmetas - domain = Orange.data.Domain(attrs, self.class_var, metas=metas) + domain = Orange.data.Domain(attrs, self.data.domain.class_vars, metas=metas) predictions = self.data.transform(domain) if newcolumns: newcolumns = numpy.hstack( - [numpy.atleast_2d(cols) for cols in newcolumns]) - predictions.metas[:, -newcolumns.shape[1]:] = newcolumns + [col.reshape((-1, 1)) for col in newcolumns]) + with predictions.unlocked(predictions.metas): + predictions.metas[:, -newcolumns.shape[1]:] = newcolumns - index = self.dataview.model().index - map_to = self.dataview.model().mapToSource + datamodel = self.dataview.model() + predmodel = self.predictionsview.model() + assert datamodel is not None # because we have data assert self.selection_store is not None - rows = None - if self.selection_store.rows: - rows = [ind.row() - for ind in self.dataview.selectionModel().selectedRows(0)] - rows.sort() - elif self.dataview.model().isSorted() \ - or self.predictionsview.model().isSorted(): - rows = list(range(len(self.data))) - if rows: - source_rows = [map_to(index(row, 0)).row() for row in rows] - predictions = predictions[source_rows] - self.Outputs.predictions.send(predictions) - - @staticmethod - def _add_classification_out_columns(slot, newmetas, newcolumns): - # Mapped or unmapped predictions?! - # Or provide a checkbox so the user decides? + rows = numpy.array(list(self.selection_store.rows), dtype=int) + if rows.size: + domain, _ = domain_with_annotation_column(predictions) + annotated_data = LazyValue[Orange.data.Table]( + lambda: create_annotated_table( + predictions, rows)[datamodel.mapToSourceRows(...)], + length=len(predictions), domain=domain) + + # Reorder rows as they are ordered in view + shown_rows = datamodel.mapFromSourceRows(rows) + rows = rows[numpy.argsort(shown_rows)] + selected = predictions[rows] + else: + if datamodel.sortColumn() >= 0 \ + or predmodel is not None and predmodel.sortColumn() > 0: + predictions = predictions[datamodel.mapToSourceRows(...)] + selected = predictions + annotated_data = lazy_annotated_table(predictions, rows) + self.Outputs.selected_predictions.send(selected) + self.Outputs.annotated.send(annotated_data) + + def _add_classification_out_columns(self, slot, newmetas, newcolumns, index): pred = slot.predictor name = pred.name values = pred.domain.class_var.values + probs = slot.results.unmapped_probabilities + + # Column with class prediction newmetas.append(DiscreteVariable(name=name, values=values)) - newcolumns.append(slot.results.unmapped_predicted.reshape(-1, 1)) - newmetas += [ContinuousVariable(name=f"{name} ({value})") - for value in values] - newcolumns.append(slot.results.unmapped_probabilities) + newcolumns.append(slot.results.unmapped_predicted) + + # Columns with probability predictions (same as shown in the view) + for cls_idx in self._shown_prob_indices(pred.domain.class_var, + in_target=False): + value = self.class_values[cls_idx] + newmetas.append(ContinuousVariable(f"{name} ({value})")) + if value in values: + newcolumns.append(probs[:, values.index(value)]) + else: + newcolumns.append(numpy.zeros(probs.shape[0])) - @staticmethod - def _add_regression_out_columns(slot, newmetas, newcolumns): + # Column with error + self._add_error_out_columns(slot, newmetas, newcolumns, index) + + def _add_regression_out_columns(self, slot, newmetas, newcolumns, index): newmetas.append(ContinuousVariable(name=slot.predictor.name)) - newcolumns.append(slot.results.unmapped_predicted.reshape((-1, 1))) + newcolumns.append(slot.results.unmapped_predicted) + self._add_error_out_columns(slot, newmetas, newcolumns, index) + + def _add_error_out_columns(self, slot, newmetas, newcolumns, index): + if self.shown_errors: + name = f"{slot.predictor.name} (error)" + newmetas.append(ContinuousVariable(name=name)) + err = self.predictionsview.model().errorColumn(index) + newcolumns.append(err) def send_report(self): def merge_data_with_predictions(): @@ -662,20 +948,33 @@ def merge_data_with_predictions(): predictions_model.data(predictions_model.index(i, j)), QLocale()) for j, delegate in enumerate(delegates)] + \ - [data_model.data(data_model.index(i, j)) + [data_model.data(data_model.index(i, j), + role=Qt.DisplayRole) for j in iter_data_cols] if self.data: text = self._get_details().replace('\n', '
      ') - if self.selected_classes: - text += '
      Showing probabilities for: ' - text += ', '. join([self.class_values[i] - for i in self.selected_classes]) + if self.is_discrete_class and self.shown_probs != self.NO_PROBS: + text += '
      Showing probabilities for ' + if self.shown_probs == self.MODEL_PROBS: + text += "all classes known to the model." + elif self.shown_probs == self.DATA_PROBS: + text += "all classes that appear in the data." + elif self.shown_probs == self.BOTH_PROBS: + text += "all classes that appear in the data " \ + "and are known to the model." + else: + class_idx = self.shown_probs - len(self.PROB_OPTS) + text += f"'{self.class_var.values[class_idx]}.'" self.report_paragraph('Info', text) self.report_table("Data & Predictions", merge_data_with_predictions(), header_rows=1, header_columns=1) - self.report_table("Scores", self.score_table.view) + self.report_name("Scores") + if self.is_discrete_class: + self.report_items([("Target class", + self.target_class or self.TARGET_AVERAGE)]) + self.report_table(self.score_table.view) def resizeEvent(self, event): super().resizeEvent(event) @@ -685,80 +984,49 @@ def showEvent(self, event): super().showEvent(event) QTimer.singleShot(0, self._update_splitter) - -class DataItemDelegate(TableDataDelegate): + @classmethod + def migrate_settings(cls, settings, version): + if version < 2: + if "score_table" in settings: + ScoreTable.migrate_to_show_scores_hints(settings["score_table"]) + + @classmethod + def migrate_context(cls, context, version): + if version < 3: + target_class = context.values.get("target_class") + # The second condition is a workaround if the workflow is opened + # in a wrong language. Assuming that contexts (and other code) works + # target_class will always appear among values, and if it doesn't, + # it must be because of averaging. + # The first condition is also covered by the second, but let it + # be there for clarity. + if target_class == cls.TARGET_AVERAGE \ + or target_class not in context.classes: + context.values["target_class"] = "" + +class ItemDelegate(TableDataDelegate): def initStyleOption(self, option, index): super().initStyleOption(option, index) if self.parent().selectionModel().isSelected(index): - option.state |= QStyle.State_Selected \ - | QStyle.State_HasFocus \ - | QStyle.State_Active + option.state |= QStyle.State_Selected + if self.parent().window().isActiveWindow(): + option.state |= QStyle.State_Active | QStyle.State_HasFocus -class PredictionsItemDelegate(DataDelegate): +class DataItemDelegate(ItemDelegate): + pass + + +class PredictionsBarItemDelegate(ItemDelegate): """ - A Item Delegate for custom formatting of predictions/probabilities + A base Item Delegate for formatting and drawing predictions/probabilities """ #: Roles supplied by the `PredictionsModel` DefaultRoles = (Qt.DisplayRole, ) - def __init__( - self, class_values, colors, shown_probabilities=(), - target_format=None, parent=None, - ): + def __init__(self, parent=None): super().__init__(parent) - self.class_values = class_values # will be None for continuous - self.colors = [QColor(*c) for c in colors] - self.target_format = target_format # target format for cont. vars - self.shown_probabilities = self.fmt = self.tooltip = None # set below - self.setFormat(shown_probabilities) - - def setFormat(self, shown_probabilities=()): - self.shown_probabilities = shown_probabilities - if self.class_values is None: - # is continuous class - self.fmt = f"{{value:{self.target_format[1:]}}}" - else: - self.fmt = " \N{RIGHTWARDS ARROW} ".join( - [" : ".join(f"{{dist[{i}]:.2f}}" if i is not None else "-" - for i in shown_probabilities)] - * bool(shown_probabilities) - + ["{value!s}"]) - self.tooltip = "" - if shown_probabilities: - val = ', '.join( - self.class_values[i] if i is not None else "-" - for i in shown_probabilities if i is not None - ) - self.tooltip = f"p({val})" - - def displayText(self, value, _locale): - try: - value, dist = value - except ValueError: - return "" - else: - return self.fmt.format(value=value, dist=dist) - - def helpEvent(self, event, view, option, index): - if self.tooltip is not None: - # ... but can be an empty string, so the current tooltip is removed - QToolTip.showText(event.globalPos(), self.tooltip, view) - return True - else: - return super().helpEvent(event, view, option, index) - - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - if self.parent().selectionModel().isSelected(index): - option.state |= QStyle.State_Selected \ - | QStyle.State_HasFocus \ - | QStyle.State_Active - - if self.class_values is None: - option.displayAlignment = \ - (option.displayAlignment & Qt.AlignVertical_Mask) | \ - Qt.AlignRight + self.fmt = "" def sizeHint(self, option, index): # reimplemented @@ -773,20 +1041,7 @@ def sizeHint(self, option, index): height = sh.height() + metrics.leading() + 2 * margin return QSize(sh.width(), height) - def distribution(self, index): - value = self.cachedData(index, Qt.DisplayRole) - if isinstance(value, tuple) and len(value) == 2: - _, dist = value - return dist - else: - return None - def paint(self, painter, option, index): - dist = self.distribution(index) - if dist is None or self.colors is None: - super().paint(painter, option, index) - return - if option.widget is not None: style = option.widget.style() else: @@ -801,11 +1056,12 @@ def paint(self, painter, option, index): QStyle.PM_FocusFrameHMargin, option, option.widget) + 1 bottommargin = min(margin, 1) rect = option.rect.adjusted(margin, margin, -margin, -bottommargin) - option.text = "" + textrect = style.subElementRect( QStyle.SE_ItemViewItemText, option, option.widget) + # Are the margins included in the subElementRect?? -> No! - textrect = textrect.adjusted(margin, margin, -margin, -bottommargin) + textrect = textrect.adjusted(0, 0, 0, -bottommargin) spacing = max(metrics.leading(), 1) distheight = rect.height() - metrics.height() - spacing @@ -822,163 +1078,388 @@ def paint(self, painter, option, index): textrect = textrect.adjusted(0, 0, 0, -distheight - spacing) distrect = QRect( - textrect.bottomLeft() + QPoint(0, spacing), - QSize(rect.width(), distheight)) + textrect.bottomLeft() + QPoint(margin, spacing), + QSize(textrect.width() - 2 * margin, distheight)) painter.setPen(QPen(Qt.lightGray, 0.3)) - self.drawDistBar(painter, distrect, dist) + self.drawBar(painter, option, index, distrect) painter.restore() if text: option.text = text self.drawViewItemText(style, painter, option, textrect) - def drawDistBar(self, painter, rect, distribution): + def drawBar(self, painter, option, index, rect): + pass # pragma: no cover + + +class PredictionsItemDelegate(PredictionsBarItemDelegate): + def displayText(self, value, _): + if value is None: + return "" + value, dist = value + return self.fmt.format(value=value, dist=dist) + + +class ClassificationItemDelegate(PredictionsItemDelegate): + def __init__( + self, class_values, colors, shown_probabilities=(), + tooltip_probabilities=(), parent=None): + super().__init__(parent) + self.class_values = class_values + self.colors = [QColor(*c) for c in colors] + + self.shown_probabilities = shown_probabilities + + if shown_probabilities: + probs = " : ".join(f"{{dist[{i}]:.2f}}" if i is not None else "-" + for i in shown_probabilities) + self.fmt = f"{probs} → {{value!s}}" + else: + self.fmt = "{value!s}" + + if tooltip_probabilities: + self.tooltip = f"p({', '.join(tooltip_probabilities)})" + else: + self.tooltip = "" + + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: + sh = super().sizeHint(option, index) + opt = QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + widget = option.widget + style = widget.style() if widget is not None else QApplication.style() + # standin for {.2f} format to compute max possible text width + pp = [float(f"{x}.{x}{x}") for x in range(10)] + maxwidth = 0 + nclass = max((len(self.class_values), *filter(None, self.shown_probabilities or ()))) + for pp, cls in product(pp, self.class_values): + dist = [pp] * nclass + opt.text = self.fmt.format(dist=dist, value=cls) + csh = style.sizeFromContents(QStyle.CT_ItemViewItem, opt, QSize(), widget) + maxwidth = max(maxwidth, csh.width()) + sh.setWidth(max(maxwidth, sh.width())) + return sh + + # pylint: disable=unused-argument + def helpEvent(self, event, view, option, index): + QToolTip.showText(event.globalPos(), self.tooltip, view) + return True + + # pylint: disable=unused-argument + def drawBar(self, painter, option, index, rect): + value = self.cachedData(index, Qt.DisplayRole) + if not isinstance(value, tuple) or len(value) != 2: + return + _, distribution = value + painter.save() painter.translate(rect.topLeft()) + actual = index.data(Qt.UserRole) for i in self.shown_probabilities: if i is None: continue dvalue = distribution[i] if not dvalue > 0: # This also skips nans continue - painter.setBrush(self.colors[i]) width = rect.width() * dvalue - painter.drawRoundedRect(QRectF(0, 0, width, rect.height()), 1, 2) + height = rect.height() + painter.setBrush(self.colors[i]) + if i == actual: + painter.drawRoundedRect(QRectF(0, 0, width, height), 1, 2) + else: + painter.drawRoundedRect( + QRectF(0, height / 4, width, height / 2), 1, 2) painter.translate(width, 0.0) painter.restore() -class SortProxyModel(QSortFilterProxyModel): - """ - QSortFilter model used in both TableView and PredictionsView - """ - list_sorted = pyqtSignal() +class ErrorDelegate(PredictionsBarItemDelegate): + __size_hint = None - def __init__(self, parent=None): - super().__init__(parent) - self.__sortInd = None + @classmethod + def sizeHint(cls, option, index): + if cls.__size_hint is None: + if option.widget is not None: + style = option.widget.style() + else: + style = QApplication.style() + margin = style.pixelMetric( + QStyle.PM_FocusFrameHMargin, option, option.widget) + 1 + cls.__size_hint = QSize( + 2 * margin + option.fontMetrics.horizontalAdvance("X" * 6), + 1) + return cls.__size_hint - def setSortIndices(self, indices): - if indices is not None: - indices = numpy.array(indices, dtype=numpy.int) - if indices.shape != (self.rowCount(),): - raise ValueError("indices.shape != (self.rowCount(),)") - indices.flags.writeable = False - self.__sortInd = indices +class NoopItemDelegate(QStyledItemDelegate): + def paint(self, *_): + pass - if self.__sortInd is not None: - self.custom_sort(0) # need valid order to call lessThan + def sizeHint(self, *_): + return QSize(0, 0) - def lessThan(self, left, right): - if self.__sortInd is None: - return super().lessThan(left, right) + def displayText(self, *_): + return "" - assert not (left.parent().isValid() or right.parent().isValid()), \ - "Not a table model" - rleft, rright = left.row(), right.row() - try: - ileft, iright = self.__sortInd[rleft], self.__sortInd[rright] - except IndexError: - return False - else: - return ileft < iright +class ClassificationErrorDelegate(ErrorDelegate): + def displayText(self, value, _): + return "?" if numpy.isnan(value) else f"{value:.3f}" - def isSorted(self): - return self.__sortInd is not None + def drawBar(self, painter, option, index, rect): + value = self.cachedData(index, Qt.DisplayRole) + if value is None or numpy.isnan(value): + return - def sort(self, n, order=Qt.AscendingOrder): - """ - This sort is called only on click, in other cases we manually call - custom_sort - """ - # reset sort - otherwise when same parameters set by lessThan function - # clicking on header would not trigger resort - self.__sortInd = None - self.custom_sort(n, order=order) - self.list_sorted.emit() + painter.save() + painter.translate(rect.topLeft()) + length = rect.width() * value + height = rect.height() + painter.setBrush(QColor(255, 0, 0)) + painter.drawRect(QRectF(0, 0, length, height)) + painter.restore() - def custom_sort(self, n, order=Qt.AscendingOrder): - """ - When sorting is dmanded because of sort change in the other view - this sorting is called. It will not reset __sortInd to None. - """ - if self.sortColumn() == n and self.sortOrder() == order: - self.invalidate() + +class RegressionItemDelegate(PredictionsItemDelegate): + def __init__(self, + target_format: Optional[str]=None, + minv: Optional[float]=None, maxv: Optional[float]=None, + parent=None): + super().__init__(parent) + self.fmt = f"{{value:{(target_format or '%.2f')[1:]}}}" + assert (minv is None) is (maxv is None) + assert (not isinstance(minv, float) or numpy.isnan(float(minv))) \ + is (not isinstance(maxv, float) or numpy.isnan(float(maxv))) + if minv is None or numpy.isnan(minv): + self.offset = 0 + self.span = 1 else: - super().sort(n, order) # need some valid sort column + self.offset = minv + self.span = maxv - minv or 1 + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + option.displayAlignment = \ + (option.displayAlignment & Qt.AlignVertical_Mask) | \ + Qt.AlignRight -class PredictionsSortProxyModel(SortProxyModel): - def __init__(self, parent=None): - super().__init__(parent) - self.__probInd = None + def drawBar(self, painter, option, index, rect): + value = self.cachedData(index, Qt.DisplayRole) + if not isinstance(value, tuple) or len(value) != 2: + return + value, _ = value - def setProbInd(self, indices): - self.__probInd = indices - self.invalidate() - self.list_sorted.emit() + width = rect.width() + height = rect.height() + xactual = (index.data(Qt.UserRole) - self.offset) / self.span * width + xvalue = (value - self.offset) / self.span * width - def lessThan(self, left, right): - if self.isSorted(): - return super().lessThan(left, right) + painter.save() + painter.translate(rect.topLeft()) + if numpy.isfinite(xvalue): + painter.setBrush(QBrush(Qt.magenta)) + painter.drawRect(QRectF(0, 0, xvalue, height)) + if numpy.isfinite(xactual): + painter.setPen(QPen(QBrush(Qt.black), 1)) + painter.setBrush(Qt.white) + painter.drawEllipse(QPointF(xactual, height / 2), 1.5, 1.5) + painter.restore() + + +class RegressionErrorDelegate(ErrorDelegate): + def __init__(self, fmt, centered, span, parent=None): + super().__init__(parent) + self.format = fmt + self.centered = centered + self.span = span # can be 0 if no errors, or None if they're hidden - role = self.sortRole() - left_data = self.sourceModel().data(left, role) - right_data = self.sourceModel().data(right, role) + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + option.displayAlignment = \ + (option.displayAlignment & Qt.AlignVertical_Mask) | \ + (Qt.AlignCenter if self.centered else Qt.AlignRight) - return self._key(left_data) < self._key(right_data) + def displayText(self, value, _): + if not self.format: + return "" + if numpy.isnan(value): + return "?" + if numpy.isneginf(value): + return "-∞" + if numpy.isinf(value): + return "∞" + return self.format % value + + def drawBar(self, painter, option, index, rect): + if not self.span: # can be 0 if no errors, or None if they're hidden + return + error = self.cachedData(index, Qt.DisplayRole) + if error is None or numpy.isnan(error): + return + scaled = error / self.span - def _key(self, prediction): - value, probs = prediction - if probs is not None: - if self.__probInd is not None: - probs = probs[self.__probInd] - probs = tuple(probs) + painter.save() + painter.translate(rect.topLeft()) + width = rect.width() + height = rect.height() + if self.centered: + painter.setBrush(QColor(0, 0, 255) if error < 0 else QColor(255, 0, 0)) + painter.drawRect(QRectF(width / 2, 0, width / 2 * scaled, height)) + else: + painter.setBrush(QColor(255, 0, 0)) + painter.drawRect(QRectF(0, 0, width * scaled, height)) + painter.restore() - return probs, value +class PredictionsModel(AbstractSortTableModel): + list_sorted = pyqtSignal() -class PredictionsModel(QAbstractTableModel): - def __init__(self, table=None, headers=None, parent=None): + def __init__(self, values=None, probs=None, actual=None, + headers=None, reg_error_type=NO_ERR, parent=None): super().__init__(parent) - self._table = [[]] if table is None else table - if headers is None: - headers = [None] * len(self._table) + self._values = values + self._probs = probs + self._actual = actual + self.__probInd = None + self._reg_err_type = reg_error_type + if values is not None: + assert len(values) == len(probs) != 0 + assert len(values[0]) == len(probs[0]) + assert actual is None or len(probs[0]) == len(actual) + sizes = {len(x) for c in (values, probs) for x in c} + assert len(sizes) == 1 + self.__columnCount = 2 * len(values) + self.__rowCount = sizes.pop() + if headers is None: + headers = [None] * self.__columnCount + else: + assert probs is None + assert headers is None + self.__columnCount = self.__rowCount = 0 self._header = headers - self.__columnCount = max([len(row) for row in self._table] or [0]) def rowCount(self, parent=QModelIndex()): - return 0 if parent.isValid() else len(self._table) + return 0 if parent.isValid() else self.__rowCount def columnCount(self, parent=QModelIndex()): return 0 if parent.isValid() else self.__columnCount - def _value(self, index): - return self._table[index.row()][index.column()] + def setRegErrorType(self, err_type): + self._reg_err_type = err_type def data(self, index, role=Qt.DisplayRole): + row = self.mapToSourceRows(index.row()) if role in (Qt.DisplayRole, Qt.EditRole): - return self._value(index) + column = index.column() + error_column = column % 2 == 1 + column //= 2 + if error_column: + if self._actual is None: + return None + actual = self._actual[row] + if numpy.isnan(actual): + return None + elif self._probs[column].size: + return 1 - self._probs[column][row, int(actual)] + else: + diff = self._values[column][row] - actual + if self._reg_err_type == DIFF_ERROR: + return diff + elif self._reg_err_type == ABSDIFF_ERROR: + return abs(diff) + elif actual == diff == 0: + return 0 + elif self._reg_err_type == REL_ERROR: + return diff / abs(actual) if actual != 0 \ + else math.copysign(numpy.inf, diff) + elif self._reg_err_type == ABSREL_ERROR: + return abs(diff / actual) if actual != 0 else numpy.inf + else: + return None + else: + return self._values[column][row], self._probs[column][row] + if role == Qt.UserRole: + return self._actual[row] if self._actual is not None else numpy.nan return None def headerData(self, section, orientation, role=Qt.DisplayRole): - if orientation == Qt.Vertical and role == Qt.DisplayRole: - return str(section + 1) - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - return (self._header[section] if section < len(self._header) - else str(section)) + if role == Qt.DisplayRole: + if orientation == Qt.Vertical: + return str(section + 1) + elif self._header is not None and section < 2 * len(self._header): + if section % 2 == 1: + return "error" + else: + return self._header[section // 2] return None + def errorColumn(self, column): + probs = self._probs[column] + if probs is not None and probs.size: + actuals = self._actual.copy() + nans = numpy.isnan(actuals) + actuals[nans] = 0 + errors = 1 - numpy.choose(actuals.astype(int), self._probs[column].T) + errors[nans] = numpy.nan + return errors + else: + actual = self._actual + diff = self._values[column] - actual + if self._reg_err_type == DIFF_ERROR: + return diff + elif self._reg_err_type == ABSDIFF_ERROR: + return numpy.abs(diff) + # we want inf's here + with numpy.errstate(divide="ignore", invalid="ignore"): + rel = diff / numpy.abs(actual) + rel[diff == 0] = 0 # 0 / 0 will become nan in previous line + if self._reg_err_type == REL_ERROR: + return rel + elif self._reg_err_type == ABSREL_ERROR: + return numpy.abs(rel) + else: + return numpy.zeros(len(actual)) + + def setProbInd(self, indicess): + self.__probInd = indicess + self.sort(self.sortColumn(), self.sortOrder()) + + def sortColumnData(self, column): + if column % 2 == 1: + return self.errorColumn(column // 2) + column //= 2 + values = self._values[column] + probs = self._probs[column] + # Let us assume that probs can be None, numpy array or list of arrays + # self.__probInd[column] can be None (numeric) or empty (no probs + # shown for particular model) + if probs is not None and len(probs) and len(probs[0]) \ + and self.__probInd is not None \ + and self.__probInd[column]: + return probs[:, self.__probInd[column]] + else: + return values + + def sort(self, column, order=Qt.AscendingOrder): + super().sort(column, order) + self.list_sorted.emit() + +# PredictionsModel and DataModel have the same signal and sort method, but +# extracting them into a mixin (because they're derived from different classes) +# would be more complicated and longer than some code repetition. +class DataModel(TableModel): + list_sorted = pyqtSignal() + + def sort(self, column, order=Qt.AscendingOrder): + super().sort(column, order) + self.list_sorted.emit() + class SharedSelectionStore: """ An object shared between multiple selection models - The object assumes that the underlying models are proxies. - - Method `select` and emit refer to indices in proxy. - - Internally, the object stores indices into source model (as int). Method - `select_rows` also uses internal, source-model indices. + The object assumes that the underlying models are AbstractSortTableModel. + Internally, the object stores indices of unmapped, source rows (as int). The class implements method `select` with the same signature as QItemSelectionModel.select. Selection models that share this object @@ -986,10 +1467,10 @@ class SharedSelectionStore: call `emit_selection_rows_changed` of all selection models, so they can emit the signal selectionChanged. """ - def __init__(self, proxy): - # indices of selected rows in the original model, not in the proxy + def __init__(self, model): + # unmapped indices of selected rows self._rows: Set[int] = set() - self.proxy: SortProxyModel = proxy + self.model: AbstractSortTableModel = model self._selection_models: List[SharedSelectionModel] = [] @property @@ -1022,22 +1503,19 @@ def select(self, selection: Union[QModelIndex, QItemSelection], flags: int): Args: selection (QModelIndex or QItemSelection): - rows to select; indices refer to the proxy model, not the source + rows to select; indices are mapped to rows in the view flags (QItemSelectionModel.SelectionFlags): flags that tell whether to Clear, Select, Deselect or Toggle """ + rows = set() if isinstance(selection, QModelIndex): if selection.model() is not None: - rows = {selection.model().mapToSource(selection).row()} - else: - rows = set() + rows = {selection.model().mapToSourceRows(selection.row())} else: indices = selection.indexes() if indices: - selection = indices[0].model().mapSelectionToSource(selection) - rows = {index.row() for index in selection.indexes()} - else: - rows = set() + map_to = indices[0].model().mapToSourceRows + rows = set(map_to([index.row() for index in indices])) self.select_rows(rows, flags) def select_rows(self, rows: Set[int], flags): @@ -1046,7 +1524,7 @@ def select_rows(self, rows: Set[int], flags): Args: selection (set of int): - rows to select; indices refer to the source model. + rows to select; indices refer to unmapped rows in model, not view flags (QItemSelectionModel.SelectionFlags): flags that tell whether to Clear, Select, Deselect or Toggle """ @@ -1076,18 +1554,16 @@ def _emit_changed(self): changing a selection. """ def map_from_source(rows): - from_src = self.proxy.mapFromSource - index = self.proxy.sourceModel().index - return {from_src(index(row, 0)).row() for row in rows} + return self.model.mapFromSourceRows(list(rows)) old_rows = self._rows.copy() try: yield finally: - if self.proxy.sourceModel() is not None: + if self.model.rowCount() != 0: deselected = map_from_source(old_rows - self._rows) selected = map_from_source(self._rows - old_rows) - if selected or deselected: + if len(selected) != 0 or len(deselected) != 0: for model in self._selection_models: model.emit_selection_rows_changed(selected, deselected) @@ -1096,28 +1572,26 @@ class SharedSelectionModel(QItemSelectionModel): """ A selection model that shares the selection with its peers. - It assumes that the underlying model is a proxy. + It assumes that the underlying model is a AbstractTableModel. """ - def __init__(self, shared_store, proxy, parent): - super().__init__(proxy, parent) + def __init__(self, shared_store, model, parent): + super().__init__(model, parent) self.store: SharedSelectionStore = shared_store self.store.register(self) def select(self, selection, flags): self.store.select(selection, flags) - def selection_from_rows(self, rows: Sequence[int], - model=None) -> QItemSelection: + def selection_from_rows(self, rows: Sequence[int]) -> QItemSelection: """ Return selection across all columns for given row indices (as ints) Args: - rows (sequence of int): row indices (in proxy model) + rows (sequence of int): row indices, as shown in the view, not model Returns: QItemSelection """ - if model is None: - model = self.model() + model = self.model() index = model.index last_col = model.columnCount() - 1 sel = QItemSelection() @@ -1129,7 +1603,7 @@ def emit_selection_rows_changed( self, selected: Sequence[int], deselected: Sequence[int]): """ Given a sequence of indices of selected and deselected rows, - emit a selectionChanged signal. Indices refer to proxy model. + emit a selectionChanged signal. Args: selected (Sequence[int]): indices of selected rows @@ -1140,9 +1614,8 @@ def emit_selection_rows_changed( self.selection_from_rows(deselected)) def selection(self): - src_sel = self.selection_from_rows(self.store.rows, - model=self.model().sourceModel()) - return self.model().mapSelectionFromSource(src_sel) + rows = self.model().mapFromSourceRows(list(self.store.rows)) + return self.selection_from_rows(rows) def hasSelection(self) -> bool: return bool(self.store.rows) @@ -1151,12 +1624,13 @@ def isColumnSelected(self, *_) -> bool: return len(self.store.rows) == self.model().rowCount() def isRowSelected(self, row, _parent=None) -> bool: - return self.isSelected(self.model().index(row, 0)) + mapped_row = self.model().mapToSourceRows(row) + return mapped_row in self.store.rows rowIntersectsSelection = isRowSelected def isSelected(self, index) -> bool: - return self.model().mapToSource(index).row() in self.store.rows + return self.model().mapToSourceRows(index.row()) in self.store.rows def selectedColumns(self, row: int): if self.isColumnSelected(): @@ -1166,14 +1640,17 @@ def selectedColumns(self, row: int): else: return [] + def _selected_rows_arr(self): + return numpy.fromiter(self.store.rows, int, len(self.store.rows)) + def selectedRows(self, col: int): - index = self.model().sourceModel().index - map_from = self.model().mapFromSource - return [map_from(index(row, col)) for row in self.store.rows] + index = self.model().index + rows = self.model().mapFromSourceRows(self._selected_rows_arr()) + return [index(row, col) for row in rows] def selectedIndexes(self): index = self.model().index - rows = [index.row() for index in self.selectedRows(0)] + rows = self.model().mapFromSourceRows(self._selected_rows_arr()) return [index(row, col) for col in range(self.model().columnCount()) for row in rows] @@ -1238,7 +1715,7 @@ def sizeHintForColumn(self, column): def tool_tip(value): value, dist = value if dist is not None: - return "{!s} {!s}".format(value, dist) + return f"{value:!s} {dist:!s}" else: return str(value) @@ -1246,12 +1723,7 @@ def tool_tip(value): if __name__ == "__main__": # pragma: no cover filename = "iris.tab" iris = Orange.data.Table(filename) - idom = iris.domain - dom = Domain( - idom.attributes, - DiscreteVariable(idom.class_var.name, idom.class_var.values[1::-1]) - ) - iris2 = iris[:100].transform(dom) + iris2 = iris[:100] def pred_error(data, *args, **kwargs): raise ValueError @@ -1260,6 +1732,12 @@ def pred_error(data, *args, **kwargs): pred_error.name = "To err is human" if iris.domain.has_discrete_class: + idom = iris.domain + dom = Domain( + idom.attributes, + DiscreteVariable(idom.class_var.name, idom.class_var.values[1::-1]) + ) + iris2 = iris2.transform(dom) predictors_ = [ Orange.classification.SVMLearner(probability=True)(iris2), Orange.classification.LogisticRegressionLearner()(iris), @@ -1275,5 +1753,5 @@ def pred_error(data, *args, **kwargs): predictors_ = [pred_error] WidgetPreview(OWPredictions).run( - set_data=iris2, - set_predictor=[(pred, i) for i, pred in enumerate(predictors_)]) + set_data=iris, + insert_predictor=list(enumerate(predictors_))) diff --git a/Orange/widgets/evaluate/owrocanalysis.py b/Orange/widgets/evaluate/owrocanalysis.py index 4cacb6f8fa0..77e42e4d17f 100644 --- a/Orange/widgets/evaluate/owrocanalysis.py +++ b/Orange/widgets/evaluate/owrocanalysis.py @@ -13,17 +13,21 @@ import pyqtgraph as pg import Orange +from Orange.base import Model +from Orange.classification import ThresholdClassifier +from Orange.evaluation.testing import Results from Orange.widgets import widget, gui, settings from Orange.widgets.evaluate.contexthandlers import \ EvaluationResultsContextHandler -from Orange.widgets.evaluate.utils import check_results_adequacy +from Orange.widgets.evaluate.utils import check_results_adequacy, \ + check_can_calibrate from Orange.widgets.utils import colorpalettes from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import Input +from Orange.widgets.visualize.utils.plotutils import GraphicsView, PlotItem +from Orange.widgets.widget import Input, Output, Msg from Orange.widgets import report from Orange.widgets.evaluate.utils import results_for_preview -from Orange.evaluation.testing import Results #: Points on a ROC curve @@ -297,13 +301,19 @@ class OWROCAnalysis(widget.OWWidget): name = "ROC Analysis" description = "Display the Receiver Operating Characteristics curve " \ "based on the evaluation of classifiers." - icon = "icons/ROCAnalysis.svg" + icon = "icons/ROCAnalysis-symbolic.svg" priority = 1010 - keywords = [] + keywords = "roc analysis, analyse" class Inputs: evaluation_results = Input("Evaluation Results", Orange.evaluation.Results) + class Outputs: + calibrated_model = Output("Calibrated Model", Model) + + class Information(widget.OWWidget.Information): + no_output = Msg("Can't output a model: {}") + buttons_area_orientation = None settingsHandler = EvaluationResultsContextHandler() target_index = settings.ContextSetting(0) @@ -323,7 +333,7 @@ class Inputs: display_convex_hull = settings.Setting(False) display_convex_curve = settings.Setting(False) - graph_name = "plot" + graph_name = "plot" # pg.GraphicsItem (pg.PlotItem) def __init__(self): super().__init__() @@ -388,38 +398,35 @@ def __init__(self): grid.addWidget(sp, 1, 1) self.target_prior_sp = gui.spin(box, self, "target_prior", 1, 99, alignment=Qt.AlignRight, + spinType=float, callback=self._on_target_prior_changed) self.target_prior_sp.setSuffix(" %") self.target_prior_sp.addAction(QAction("Auto", sp)) grid.addWidget(QLabel("Prior probability:")) grid.addWidget(self.target_prior_sp, 2, 1) - self.plotview = pg.GraphicsView(background="w") + self.plotview = GraphicsView(background=None) self.plotview.setFrameStyle(QFrame.StyledPanel) self.plotview.scene().sigMouseMoved.connect(self._on_mouse_moved) - self.plot = pg.PlotItem(enableMenu=False) + self.plot = PlotItem(enableMenu=False) self.plot.setMouseEnabled(False, False) self.plot.hideButtons() - pen = QPen(self.palette().color(QPalette.Text)) - tickfont = QFont(self.font()) tickfont.setPixelSize(max(int(tickfont.pixelSize() * 2 // 3), 11)) axis = self.plot.getAxis("bottom") axis.setTickFont(tickfont) - axis.setPen(pen) axis.setLabel("FP Rate (1-Specificity)") axis.setGrid(16) axis = self.plot.getAxis("left") axis.setTickFont(tickfont) - axis.setPen(pen) axis.setLabel("TP Rate (Sensitivity)") axis.setGrid(16) - self.plot.showGrid(True, True, alpha=0.1) + self.plot.showGrid(True, True, alpha=0.2) self.plot.setRange(xRange=(0.0, 1.0), yRange=(0.0, 1.0), padding=0.05) self.plotview.setCentralItem(self.plot) @@ -435,6 +442,7 @@ def set_results(self, results): self._initialize(self.results) self.openContext(self.results.domain.class_var, self.classifier_names) + self._set_target_prior() self._setup_plot() else: self.warning() @@ -468,7 +476,7 @@ def _initialize(self, results): listitem = self.classifiers_list_box.item(i) listitem.setIcon(colorpalettes.ColorIcon(self.colors[i])) - class_var = results.data.domain.class_var + class_var = results.domain.class_var self.target_cb.addItems(class_var.values) self.target_index = 0 self._set_target_prior() @@ -552,6 +560,7 @@ def merge_averaging(): ind = np.argmin(np.abs(points.thresholds - 0.5)) item = pg.TextItem( text="{:.3f}".format(points.thresholds[ind]), + color=foreground ) item.setPos(points.fpr[ind], points.tpr[ind]) self.plot.addItem(item) @@ -559,7 +568,7 @@ def merge_averaging(): hull_curves = [curve.merged.hull for curve in selected] if hull_curves: self._rocch = convex_hull(hull_curves) - iso_pen = QPen(QColor(Qt.black), 1) + iso_pen = QPen(foreground, 1.0) iso_pen.setCosmetic(True) self._perf_line = InfiniteLine(pen=iso_pen, antialias=True) self.plot.addItem(self._perf_line) @@ -595,7 +604,7 @@ def no_averaging(): OWROCAnalysis.Threshold: threshold_averaging, OWROCAnalysis.NoAveraging: no_averaging } - + foreground = self.plotview.scene().palette().color(QPalette.Text) target = self.target_index selected = self.selected_classifiers @@ -605,21 +614,23 @@ def no_averaging(): if self.display_convex_hull and hull_curves: hull = convex_hull(hull_curves) - hull_pen = QPen(QColor(200, 200, 200, 100), 2) + hull_color = QColor(foreground) + hull_color.setAlpha(100) + hull_pen = QPen(hull_color, 2) hull_pen.setCosmetic(True) + hull_color.setAlpha(50) item = self.plot.plot( hull.fpr, hull.tpr, pen=hull_pen, - brush=QBrush(QColor(200, 200, 200, 50)), + brush=QBrush(hull_color), fillLevel=0) item.setZValue(-10000) - - pen = QPen(QColor(100, 100, 100, 100), 1, Qt.DashLine) + line_color = self.palette().color(QPalette.Disabled, QPalette.Text) + pen = QPen(QColor(*line_color.getRgb()[:3], 200), 1.0, Qt.DashLine) pen.setCosmetic(True) self.plot.plot([0, 1], [0, 1], pen=pen, antialias=True) - if self.roc_averaging == OWROCAnalysis.Merge: - self._update_perf_line() + self._update_perf_line() self._update_axes_ticks() @@ -638,14 +649,18 @@ def enumticks(a): return None return [[(x, f"{x:.2f}") for x in a[::-1]]] - data = self.curve_data(self.target_index, self.selected_classifiers[0]) - points = data.merged.points + axis_bottom = self.plot.getAxis("bottom") + axis_left = self.plot.getAxis("left") - axis = self.plot.getAxis("bottom") - axis.setTicks(enumticks(points.fpr)) - - axis = self.plot.getAxis("left") - axis.setTicks(enumticks(points.tpr)) + if not self.selected_classifiers or len(self.selected_classifiers) > 1 \ + or self.roc_averaging != OWROCAnalysis.Merge: + axis_bottom.setTicks(None) + axis_left.setTicks(None) + else: + data = self.curve_data(self.target_index, self.selected_classifiers[0]) + points = data.merged.points + axis_bottom.setTicks(enumticks(points.fpr)) + axis_left.setTicks(enumticks(points.tpr)) def _on_mouse_moved(self, pos): target = self.target_index @@ -668,7 +683,7 @@ def _on_mouse_moved(self, pos): sp = curve.curve_item.childItems()[0] # type: pg.ScatterPlotItem act_pos = sp.mapFromScene(pos) - pts = sp.pointsAt(act_pos) + pts = list(sp.pointsAt(act_pos)) if pts: mouse_pt = pts[0].pos() @@ -680,8 +695,8 @@ def _on_mouse_moved(self, pos): mask = np.equal(cache_clf, clf_idx) curr_thresh = np.compress(mask, cache_thresh).tolist() curr_clf = np.compress(mask, cache_clf).tolist() - else: - QToolTip.showText(QCursor.pos(), "") + else: # pragma: no cover + QToolTip.showText(QCursor.pos(), "", self.plotview) self._tooltip_cache = None if curr_thresh: @@ -706,7 +721,7 @@ def _on_mouse_moved(self, pos): clf_names = self.classifier_names msg = "Thresholds:\n" + "\n".join(["({:s}) {:.3f}".format(clf_names[i], thresh) for i, thresh in zip(valid_clf, valid_thresh)]) - QToolTip.showText(QCursor.pos(), msg) + QToolTip.showText(QCursor.pos(), msg, self.plotview) self._tooltip_cache = (pt, valid_thresh, valid_clf, ave_mode) def _on_target_changed(self): @@ -724,8 +739,7 @@ def _on_target_prior_changed(self): self._on_display_perf_line_changed() def _on_display_perf_line_changed(self): - if self.roc_averaging == OWROCAnalysis.Merge: - self._update_perf_line() + self._update_perf_line() if self.perf_line is not None: self.perf_line.setVisible(self.display_perf_line) @@ -739,9 +753,12 @@ def _replot(self): self._setup_plot() def _update_perf_line(self): - if self._perf_line is None: + + if self._perf_line is None or self.roc_averaging != OWROCAnalysis.Merge: + self._update_output(None) return + ind = None self._perf_line.setVisible(self.display_perf_line) if self.display_perf_line: m = roc_iso_performance_slope( @@ -756,6 +773,26 @@ def _update_perf_line(self): else: self._perf_line.setVisible(False) + self._update_output(None if ind is None else hull.thresholds[ind[0]]) + + def _update_output(self, threshold): + self.Information.no_output.clear() + + if threshold is None: + self.Outputs.calibrated_model.send(None) + return + + problems = check_can_calibrate(self.results, self.selected_classifiers) + if problems: + self.Information.no_output(problems) + self.Outputs.calibrated_model.send(None) + return + + model = ThresholdClassifier( + self.results.models[0][self.selected_classifiers[0]], + threshold) + self.Outputs.calibrated_model.send(model) + def onDeleteWidget(self): self.clear() @@ -861,7 +898,7 @@ def roc_curve_threshold_average(curves, thresh_samples): tpr_samples = np.array(tpr_samples) return ((fpr_samples.mean(axis=0), fpr_samples.std(axis=0)), - (tpr_samples.mean(axis=0), fpr_samples.std(axis=0))) + (tpr_samples.mean(axis=0), tpr_samples.std(axis=0))) def roc_curve_thresh_avg_interp(curves, thresh_samples): @@ -877,7 +914,7 @@ def roc_curve_thresh_avg_interp(curves, thresh_samples): tpr_samples = np.array(tpr_samples) return ((fpr_samples.mean(axis=0), fpr_samples.std(axis=0)), - (tpr_samples.mean(axis=0), fpr_samples.std(axis=0))) + (tpr_samples.mean(axis=0), tpr_samples.std(axis=0))) RocPoint = namedtuple("RocPoint", ["fpr", "tpr", "threshold"]) diff --git a/Orange/widgets/evaluate/owtestandscore.py b/Orange/widgets/evaluate/owtestandscore.py index bec4903f6df..0f11d5f6a9d 100644 --- a/Orange/widgets/evaluate/owtestandscore.py +++ b/Orange/widgets/evaluate/owtestandscore.py @@ -9,9 +9,11 @@ from functools import partial, reduce from concurrent.futures import Future -from collections import OrderedDict, namedtuple +from collections import OrderedDict from itertools import count -from typing import Any, Optional, List, Dict, Callable +from typing import ( + Any, Optional, List, Dict, Callable, Sequence, NamedTuple, Tuple +) import numpy as np import baycomp @@ -20,7 +22,8 @@ from AnyQt.QtCore import Qt, QSize, QThread from AnyQt.QtCore import pyqtSlot as Slot from AnyQt.QtGui import QStandardItem, QDoubleValidator -from AnyQt.QtWidgets import QHeaderView, QTableWidget, QLabel +from AnyQt.QtWidgets import \ + QHeaderView, QTableWidget, QLabel, QComboBox, QSizePolicy from Orange.base import Learner import Orange.classification @@ -39,16 +42,16 @@ from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.utils.concurrent import ThreadExecutor, TaskState -from Orange.widgets.widget import OWWidget, Msg, Input, Output +from Orange.widgets.widget import OWWidget, Msg, Input, MultiInput, Output log = logging.getLogger(__name__) -InputLearner = namedtuple( - "InputLearner", - ["learner", # :: Orange.base.Learner - "results", # :: Option[Try[Orange.evaluation.Results]] - "stats"] # :: Option[Sequence[Try[float]]] -) + +class InputLearner(NamedTuple): + learner: Orange.base.Learner + results: Optional['Try[Orange.evaluation.Results]'] + stats: Optional[Sequence['Try[float]']] + key: Any class Try(abc.ABC): @@ -130,27 +133,25 @@ class State(enum.Enum): class OWTestAndScore(OWWidget): name = "Test and Score" description = "Cross-validation accuracy estimation." - icon = "icons/TestLearners1.svg" + icon = "icons/TestLearners1-symbolic.svg" priority = 100 - keywords = ['Cross Validation', 'CV'] + keywords = "test and score, cross validation, cv" replaces = ["Orange.widgets.evaluate.owtestlearners.OWTestLearners"] class Inputs: train_data = Input("Data", Table, default=True) test_data = Input("Test Data", Table) - learner = Input("Learner", Learner, multiple=True) + learner = MultiInput( + "Learner", Learner, filter_none=True + ) preprocessor = Input("Preprocessor", Preprocess) class Outputs: predictions = Output("Predictions", Table) evaluations_results = Output("Evaluation Results", Results) - settings_version = 3 + settings_version = 4 buttons_area_orientation = None - UserAdviceMessages = [ - widget.Message( - "Click on the table header to select shown columns", - "click_header")] settingsHandler = settings.PerfectDomainContextHandler() score_table = settings.SettingProvider(ScoreTable) @@ -185,7 +186,7 @@ class Outputs: rope = settings.Setting(0.1) comparison_criterion = settings.Setting(0, schema_only=True) - TARGET_AVERAGE = "(Average over classes)" + TARGET_AVERAGE = "(None, show average over classes)" class_selection = settings.ContextSetting(TARGET_AVERAGE) class Error(OWWidget.Error): @@ -216,6 +217,8 @@ class Information(OWWidget.Information): test_data_transformed = Msg( "Test data has been transformed to match the train data.") cant_stratify_numeric = Msg("Stratification is ignored for regression") + cant_stratify_multitarget = Msg("Stratification is ignored when there are" + " multiple target variables.") def __init__(self): super().__init__() @@ -227,8 +230,10 @@ def __init__(self): self.test_data_missing_vals = False self.scorers = [] self.__pending_comparison_criterion = self.comparison_criterion - - #: An Ordered dictionary with current inputs and their testing results. + self.__id_gen = count() + self._learner_inputs = [] # type: List[Tuple[Any, Learner]] + #: An Ordered dictionary with current inputs and their testing results + #: (keyed by ids generated by __id_gen). self.learners = OrderedDict() # type: Dict[Any, Input] self.__state = State.Waiting @@ -238,7 +243,7 @@ def __init__(self): self.__task = None # type: Optional[TaskState] self.__executor = ThreadExecutor() - sbox = gui.vBox(self.controlArea, "Sampling") + sbox = gui.vBox(self.controlArea, box=True) rbox = gui.radioButtons( sbox, self, "resampling", callback=self._param_changed) @@ -281,37 +286,41 @@ def __init__(self): gui.appendRadioButton(rbox, "Test on train data") gui.appendRadioButton(rbox, "Test on test data") - self.cbox = gui.vBox(self.controlArea, "Target Class") + gui.rubber(self.controlArea) + + self.score_table = ScoreTable(self) + self.score_table.shownScoresChanged.connect(self.update_stats_model) + view = self.score_table.view + view.setSizeAdjustPolicy(view.AdjustToContents) + + self.results_box = gui.vBox(self.mainArea, box=True) + self.cbox = gui.hBox(self.results_box) self.class_selection_combo = gui.comboBox( self.cbox, self, "class_selection", items=[], - sendSelectedValue=True, contentsLength=8, searchable=True, + label="Evaluation results for target", orientation=Qt.Horizontal, + sendSelectedValue=True, searchable=True, contentsLength=25, callback=self._on_target_class_changed ) - - self.modcompbox = box = gui.vBox(self.controlArea, "Model Comparison") - gui.comboBox( - box, self, "comparison_criterion", - callback=self.update_comparison_table) - - hbox = gui.hBox(box) - gui.checkBox(hbox, self, "use_rope", - "Negligible difference: ", + self.cbox.layout().addStretch(100) + self.class_selection_combo.setMaximumContentsLength(30) + self.results_box.layout().addWidget(self.score_table.view) + + gui.separator(self.mainArea, 16) + self.compbox = box = gui.vBox(self.mainArea, box=True) + cbox = gui.comboBox( + box, self, "comparison_criterion", label="Compare models by:", + sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, + sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed), + orientation=Qt.Horizontal, callback=self.update_comparison_table).box + + gui.separator(cbox, 8) + gui.checkBox(cbox, self, "use_rope", "Negligible diff.: ", callback=self._on_use_rope_changed) - gui.lineEdit(hbox, self, "rope", validator=QDoubleValidator(), - controlWidth=70, callback=self.update_comparison_table, + gui.lineEdit(cbox, self, "rope", validator=QDoubleValidator(), + controlWidth=50, callback=self.update_comparison_table, alignment=Qt.AlignRight) self.controls.rope.setEnabled(self.use_rope) - gui.rubber(self.controlArea) - self.score_table = ScoreTable(self) - self.score_table.shownScoresChanged.connect(self.update_stats_model) - view = self.score_table.view - view.setSizeAdjustPolicy(view.AdjustToContents) - - box = gui.vBox(self.mainArea, "Evaluation Results") - box.layout().addWidget(self.score_table.view) - - self.compbox = box = gui.vBox(self.mainArea, box="Model comparison") table = self.comparison_table = QTableWidget( wordWrap=False, editTriggers=QTableWidget.NoEditTriggers, selectionMode=QTableWidget.NoSelection) @@ -356,22 +365,34 @@ def _update_controls(self): self.resampling = OWTestAndScore.KFold @Inputs.learner - def set_learner(self, learner, key): + def set_learner(self, index: int, learner: Learner): """ - Set the input `learner` for `key`. + Set the input `learner` at `index`. Parameters ---------- - learner : Optional[Orange.base.Learner] - key : Any + index: int + learner: Orange.base.Learner """ - if key in self.learners and learner is None: - # Removed - self._invalidate([key]) - del self.learners[key] - elif learner is not None: - self.learners[key] = InputLearner(learner, None, None) - self._invalidate([key]) + key, _ = self._learner_inputs[index] + slot = self.learners[key] + self.learners[key] = slot._replace(learner=learner, results=None) + self._invalidate([key]) + + @Inputs.learner.insert + def insert_learner(self, index: int, learner: Learner): + key = next(self.__id_gen) + self._learner_inputs.insert(index, (key, learner)) + self.learners[key] = InputLearner(learner, None, None, key) + self.learners = {key: self.learners[key] for key, _ in self._learner_inputs} + self._invalidate([key]) + + @Inputs.learner.remove + def remove_learner(self, index: int): + key, _ = self._learner_inputs[index] + self._invalidate([key]) + self._learner_inputs.pop(index) + self.learners.pop(key) @Inputs.train_data def set_train_data(self, data): @@ -393,7 +414,6 @@ def set_train_data(self, data): "Train data input requires a target variable.", not data.domain.class_vars ), - ("Too many target variables.", len(data.domain.class_vars) > 1), ("Target variable has no values.", np.isnan(data.Y).all()), ( "Target variable has only one value.", @@ -409,7 +429,7 @@ def set_train_data(self, data): break if isinstance(data, SqlTable): - if data.approx_len() < AUTO_DL_LIMIT: + if len(data) < AUTO_DL_LIMIT: data = Table(data) else: self.Information.data_sampled() @@ -452,14 +472,15 @@ def set_test_data(self, data): if data is not None and not data: self.Error.test_data_empty() data = None - if data and not data.domain.class_var: + + if data and not data.domain.class_vars: self.Error.class_required_test() data = None else: self.Error.class_required_test.clear() if isinstance(data, SqlTable): - if data.approx_len() < AUTO_DL_LIMIT: + if len(data) < AUTO_DL_LIMIT: data = Table(data) else: self.Information.test_data_sampled() @@ -492,10 +513,10 @@ def _which_missing_data(self): # - we don't gain much with it # - it complicates the unit tests def _update_scorers(self): - if self.data and self.data.domain.class_var: - new_scorers = usable_scorers(self.data.domain.class_var) - else: - new_scorers = [] + new_scorers = [] + if self.data: + new_scorers = usable_scorers(self.data.domain) + # Don't unnecessarily reset the combo because this would always reset # comparison_criterion; we also set it explicitly, though, for clarity if new_scorers != self.scorers: @@ -512,15 +533,6 @@ def _update_scorers(self): if self.__pending_comparison_criterion < len(self.scorers): self.comparison_criterion = self.__pending_comparison_criterion self.__pending_comparison_criterion = None - self._update_compbox_title() - - def _update_compbox_title(self): - criterion = self.comparison_criterion - if criterion < len(self.scorers): - scorer = self.scorers[criterion]() - self.compbox.setTitle(f"Model Comparison by {scorer.name}") - else: - self.compbox.setTitle(f"Model Comparison") @Inputs.preprocessor def set_preprocessor(self, preproc): @@ -552,13 +564,12 @@ def shuffle_split_changed(self): self._param_changed() def _param_changed(self): - self.modcompbox.setEnabled(self.resampling == OWTestAndScore.KFold) self._update_view_enabled() self._invalidate() self.__update() def _update_view_enabled(self): - self.comparison_table.setEnabled( + self.compbox.setEnabled( self.resampling == OWTestAndScore.KFold and len(self.learners) > 1 and self.data is not None) @@ -640,7 +651,8 @@ def update_stats_model(self): item.setData(float(stat.value[0]), Qt.DisplayRole) else: item.setToolTip(str(stat.exception)) - if scorer.name in self.score_table.shown_scores: + # pylint: disable=unsubscriptable-object + if self.score_table.show_score_hints[scorer.__name__]: has_missing_scores = True row.append(item) @@ -701,7 +713,6 @@ def _set_comparison_headers(self, names): def _scores_by_folds(self, slots): scorer = self.scorers[self.comparison_criterion]() - self._update_compbox_title() if scorer.is_binary: if self.class_selection != self.TARGET_AVERAGE: class_var = self.data.domain.class_var @@ -885,6 +896,9 @@ def migrate_settings(cls, settings_, version): settings_["context_settings"] = [ c for c in settings_.get("context_settings", ()) if not hasattr(c, 'classes')] + if version < 4: + if "score_table" in settings_: + ScoreTable.migrate_to_show_scores_hints(settings_["score_table"]) @Slot(float) def setProgressValue(self, value): @@ -902,6 +916,7 @@ def __update(self): self.Warning.test_data_missing.clear() self.Warning.cant_stratify.clear() self.Information.cant_stratify_numeric.clear() + self.Information.cant_stratify_multitarget.clear() self.Information.test_data_transformed( shown=self.resampling == self.TestOnTest and self.data is not None @@ -930,7 +945,10 @@ def __update(self): return do_stratify = self.cv_stratified if do_stratify: - if self.data.domain.class_var.is_discrete: + if len(self.data.domain.class_vars) > 1: + self.Information.cant_stratify_multitarget() + do_stratify = False + elif self.data.domain.class_var.is_discrete: least = min(filter(None, np.bincount(self.data.Y.astype(int)))) if least < k: @@ -1073,18 +1091,15 @@ def __task_complete(self, f: 'Future[Results]'): assert all(learner in learner_key for learner in learners) # Update the results for individual learners - class_var = results.domain.class_var for learner, result in zip(learners, results.split_by_model()): - stats = None - if class_var.is_primitive(): - ex = result.failed[0] - if ex: - stats = [Try.Fail(ex)] * len(self.scorers) - result = Try.Fail(ex) - else: - stats = [Try(scorer_caller(scorer, result)) - for scorer in self.scorers] - result = Try.Success(result) + ex = result.failed[0] + if ex: + stats = [Try.Fail(ex)] * len(self.scorers) + result = Try.Fail(ex) + else: + stats = [Try(scorer_caller(scorer, result)) + for scorer in self.scorers] + result = Try.Success(result) key = learner_key.get(learner) self.learners[key] = \ self.learners[key]._replace(results=result, stats=stats) @@ -1213,5 +1228,5 @@ def results_one_vs_rest(results, pos_index): WidgetPreview(OWTestAndScore).run( set_train_data=preview_data, set_test_data=preview_data, - set_learner=[(learner, i) for i, learner in enumerate(prev_learners)] + insert_learner=list(enumerate(prev_learners)) ) diff --git a/Orange/widgets/evaluate/tests/base.py b/Orange/widgets/evaluate/tests/base.py index 93fafea1e51..12478844e36 100644 --- a/Orange/widgets/evaluate/tests/base.py +++ b/Orange/widgets/evaluate/tests/base.py @@ -1,9 +1,59 @@ +from unittest.mock import Mock + +import numpy as np + from Orange import classification, evaluation -from Orange.data import Table +from Orange.data import Table, Domain, DiscreteVariable +from Orange.evaluation import Results +from Orange.evaluation.performance_curves import Curves +from Orange.tests import test_filename + +from Orange.widgets.tests.base import WidgetTest + +class EvaluateTest(WidgetTest): + def setUp(self): + super().setUp() + + n, p = (0, 1) + actual, probs = np.array([ + (p, .8), (n, .7), (p, .6), (p, .55), (p, .54), (n, .53), (n, .52), + (p, .51), (n, .505), (p, .4), (n, .39), (p, .38), (n, .37), + (n, .36), (n, .35), (p, .34), (n, .33), (p, .30), (n, .1)]).T + self.curves = Curves(actual, probs) + probs2 = (probs + 1) / 2 + self.curves2 = Curves(actual, probs2) + pred = probs > 0.5 + pred2 = probs2 > 0.5 + probs = np.vstack((1 - probs, probs)).T + probs2 = np.vstack((1 - probs2, probs2)).T + domain = Domain([], DiscreteVariable("y", values=("a", "b"))) + self.results = Results( + domain=domain, + actual=actual, + folds=np.array([Ellipsis]), + models=np.array([[Mock(), Mock()]]), + row_indices=np.arange(19), + predicted=np.array((pred, pred2)), + probabilities=np.array([probs, probs2])) + + self.lenses = data = Table(test_filename("datasets/lenses.tab")) + majority = classification.MajorityLearner() + majority.name = "majority" + knn3 = classification.KNNLearner(n_neighbors=3) + knn3.name = "knn-3" + knn1 = classification.KNNLearner(n_neighbors=1) + knn1.name = "knn-1" + self.lenses_results = evaluation.TestOnTestData( + store_data=True, store_models=True)( + data=data[::2], test_data=data[1::2], + learners=[majority, knn3, knn1]) + self.lenses_results.learner_names = ["majority", "knn-3", "knn-1"] -class EvaluateTest: def test_many_evaluation_results(self): + if not hasattr(self, "widget"): + return + data = Table("iris") learners = [ classification.MajorityLearner(), diff --git a/Orange/widgets/evaluate/tests/test_owcalibrationplot.py b/Orange/widgets/evaluate/tests/test_owcalibrationplot.py index ab56c804509..38a947d4027 100644 --- a/Orange/widgets/evaluate/tests/test_owcalibrationplot.py +++ b/Orange/widgets/evaluate/tests/test_owcalibrationplot.py @@ -1,5 +1,6 @@ import copy import warnings +import unittest from unittest.mock import Mock, patch import numpy as np @@ -8,57 +9,17 @@ from sklearn.exceptions import ConvergenceWarning -from Orange.data import Table, DiscreteVariable, Domain, ContinuousVariable -import Orange.evaluation -import Orange.classification -from Orange.evaluation import Results -from Orange.evaluation.performance_curves import Curves +from orangewidget.utils.combobox import qcombobox_emit_activated +from Orange.data import Domain, ContinuousVariable +from Orange.evaluation.performance_curves import Curves from Orange.widgets.evaluate.tests.base import EvaluateTest from Orange.widgets.evaluate.owcalibrationplot import OWCalibrationPlot -from Orange.widgets.tests.base import WidgetTest -from Orange.tests import test_filename -class TestOWCalibrationPlot(WidgetTest, EvaluateTest): +class TestOWCalibrationPlot(EvaluateTest): def setUp(self): super().setUp() - - n, p = (0, 1) - actual, probs = np.array([ - (p, .8), (n, .7), (p, .6), (p, .55), (p, .54), (n, .53), (n, .52), - (p, .51), (n, .505), (p, .4), (n, .39), (p, .38), (n, .37), - (n, .36), (n, .35), (p, .34), (n, .33), (p, .30), (n, .1)]).T - self.curves = Curves(actual, probs) - probs2 = (probs + 0.5) / 2 + 1 - self.curves2 = Curves(actual, probs2) - pred = probs > 0.5 - pred2 = probs2 > 0.5 - probs = np.vstack((1 - probs, probs)).T - probs2 = np.vstack((1 - probs2, probs2)).T - domain = Domain([], DiscreteVariable("y", values=("a", "b"))) - self.results = Results( - domain=domain, - actual=actual, - folds=np.array([Ellipsis]), - models=np.array([[Mock(), Mock()]]), - row_indices=np.arange(19), - predicted=np.array((pred, pred2)), - probabilities=np.array([probs, probs2])) - - self.lenses = data = Table(test_filename("datasets/lenses.tab")) - majority = Orange.classification.MajorityLearner() - majority.name = "majority" - knn3 = Orange.classification.KNNLearner(n_neighbors=3) - knn3.name = "knn-3" - knn1 = Orange.classification.KNNLearner(n_neighbors=1) - knn1.name = "knn-1" - self.lenses_results = Orange.evaluation.TestOnTestData( - store_data=True, store_models=True)( - data=data[::2], test_data=data[1::2], - learners=[majority, knn3, knn1]) - self.lenses_results.learner_names = ["majority", "knn-3", "knn-1"] - self.widget = self.create_widget(OWCalibrationPlot) # type: OWCalibrationPlot warnings.filterwarnings("ignore", ".*", ConvergenceWarning) @@ -140,8 +101,7 @@ def test_regression_input_error(self): @staticmethod def _set_combo(combo, val): combo.setCurrentIndex(val) - combo.activated[int].emit(val) - combo.activated[str].emit(combo.currentText()) + qcombobox_emit_activated(combo, val) @staticmethod def _set_radio_buttons(radios, val): @@ -381,6 +341,8 @@ def test_threshold_flips_on_two_classes(self): @patch("Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner") def test_apply_no_output(self, *_): """Test no output warnings""" + # Similar to test_owcalibrationplot, but just a little different, hence + # pylint: disable=duplicate-code widget = self.widget model_list = widget.controls.selected_classifiers @@ -394,7 +356,7 @@ def test_apply_no_output(self, *_): multiple_selected: "select a single model - the widget can output only one", non_binary_class: - "cannot calibrate non-binary classes"} + "cannot calibrate non-binary models"} def test_shown(shown): widget_msg = widget.Information.no_output @@ -614,6 +576,7 @@ def test_single_class_folds(self, *_): results = self.lenses_results results.folds = [slice(0, 5), slice(5, 19)] results.models = results.models.repeat(2, axis=0) + results.actual = results.actual.copy() results.actual[:3] = 0 results.probabilities[1, 3:5] = np.nan # after this, model 1 has just negative instances in fold 0 @@ -636,3 +599,19 @@ def test_warn_nan_probabilities(self, *_): self.assertTrue(widget.Warning.omitted_nan_prob_points.is_shown()) self._set_list_selection(widget.controls.selected_classifiers, [0, 2]) self.assertFalse(widget.Warning.omitted_folds.is_shown()) + + @patch("Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier") + @patch("Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner") + def test_no_folds(self, *_): + """Don't crash on malformed Results with folds=None""" + widget = self.widget + + self.results.folds = None + self.send_signal(widget.Inputs.evaluation_results, self.results) + widget.selected_classifiers = [0] + widget.commit.now() + self.assertIsNotNone(self.get_output(widget.Outputs.calibrated_model)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/evaluate/tests/test_owconfusionmatrix.py b/Orange/widgets/evaluate/tests/test_owconfusionmatrix.py index b3127c382c0..20c1695684f 100644 --- a/Orange/widgets/evaluate/tests/test_owconfusionmatrix.py +++ b/Orange/widgets/evaluate/tests/test_owconfusionmatrix.py @@ -1,14 +1,15 @@ # pylint: disable=missing-docstring, protected-access +import unittest import numpy as np -from Orange.data import Table +from Orange.data import Table, Domain from Orange.classification import NaiveBayesLearner, TreeLearner from Orange.regression import MeanLearner from Orange.evaluation.testing import CrossValidation, TestOnTrainingData, \ ShuffleSplit, Results from Orange.widgets.evaluate.owconfusionmatrix import OWConfusionMatrix from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin -from Orange.widgets.tests.utils import possible_duplicate_table +from Orange.widgets.tests.utils import possible_duplicate_table, simulate class TestOWConfusionMatrix(WidgetTest, WidgetOutputsTestMixin): @@ -27,7 +28,7 @@ def setUpClass(cls): cls.results_2_iris = cv(cls.iris, [bayes, tree]) cls.results_2_titanic = cv(titanic, [bayes, tree]) - cls.signal_name = "Evaluation Results" + cls.signal_name = OWConfusionMatrix.Inputs.evaluation_results cls.signal_data = cls.results_1_iris cls.same_input_output_domain = False @@ -125,3 +126,67 @@ def test_unique_output_domain(self): self.send_signal(self.widget.Inputs.evaluation_results, input_data) output = self.get_output(self.widget.Outputs.annotated_data) self.assertEqual(output.domain.metas[0].name, 'iris(Learner #1) (1)') + + def test_unique_var_names(self): + bayes = NaiveBayesLearner() + domain = self.iris.domain + results = CrossValidation(k=3, store_data=True)(self.iris, [bayes]) + self.widget.append_probabilities = True + self.widget.append_predictions = True + self.send_signal(self.widget.Inputs.evaluation_results, results) + + out_data = self.get_output(self.widget.Outputs.annotated_data) + + widget2 = self.create_widget(OWConfusionMatrix) + data2 = out_data.transform( + Domain(domain.attributes, domain.class_vars, + [meta for meta in out_data.domain.metas if "versicolor" not in meta.name])) + results2 = CrossValidation(k=3, store_data=True)(data2, [bayes]) + widget2.append_probabilities = True + widget2.append_predictions = True + self.send_signal(widget2.Inputs.evaluation_results, results2) + out_data2 = self.get_output(widget2.Outputs.annotated_data) + self.assertEqual({meta.name for meta in out_data2.domain.metas}, + {'Selected', 'Selected (1)', + 'iris(Learner #1)', 'iris(Learner #1) (1)', + 'p(Iris-setosa)', 'p(Iris-virginica)', + 'p(Iris-setosa) (1)', 'p(Iris-versicolor) (1)', + 'p(Iris-virginica) (1)'}) + + def test_sum_of_probabilities(self): + results: Results = self.results_1_iris + self.send_signal(self.widget.Inputs.evaluation_results, results) + + model = self.widget.tablemodel + n = model.rowCount() - 3 + matrix = np.zeros((n, n)) + probabilities = results.probabilities[0] + for label_index in np.unique(results.actual).astype(int): + mask = results.actual == label_index + prob_sum = np.sum(probabilities[mask], axis=0) + matrix[label_index] = prob_sum + colsum = matrix.sum(axis=0) + rowsum = matrix.sum(axis=1) + + simulate.combobox_activate_index( + self.widget.controls.selected_quantity, 3) + # matrix + for i in range(n): + for j in range(n): + value = model.data(model.index(i + 2, j + 2)) + self.assertAlmostEqual(float(value), matrix[i, j], 1) + # rowsum + for i in range(n): + value = model.data(model.index(i + 2, n + 2)) + self.assertAlmostEqual(float(value), rowsum[i], 0) + # colsum + for i in range(n): + value = model.data(model.index(n + 2, i + 2)) + self.assertAlmostEqual(float(value), colsum[i], 0) + # total + value = model.data(model.index(n + 2, n + 2)) + self.assertAlmostEqual(float(value), colsum.sum(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/evaluate/tests/test_owfeatureaspredictor.py b/Orange/widgets/evaluate/tests/test_owfeatureaspredictor.py new file mode 100644 index 00000000000..db2a7ef91b1 --- /dev/null +++ b/Orange/widgets/evaluate/tests/test_owfeatureaspredictor.py @@ -0,0 +1,315 @@ +import unittest +from unittest.mock import patch, Mock + +import numpy as np + +from Orange.data import Table, Domain, \ + StringVariable, DiscreteVariable, ContinuousVariable +from Orange.widgets.evaluate.owfeatureaspredictor import OWFeatureAsPredictor +from Orange.widgets.tests.base import WidgetTest + + +class OWFeatureAsPredictorTest(WidgetTest): + def setUp(self): + self.widget = self.create_widget(OWFeatureAsPredictor) + self.disc_a = DiscreteVariable("a", values=("a", "b", "c")) + self.disc_b = DiscreteVariable("b", values=("c", "a", "b")) + self.disc_c = DiscreteVariable("c", values=("c", "a", "b", "d")) + self.disc_d = DiscreteVariable("d", values=("c", "b")) + self.disc_de = DiscreteVariable("de", values=("b", "c")) + self.cont_e = ContinuousVariable("e") + self.cont_f = ContinuousVariable("f") + self.cont_g = ContinuousVariable("g") + + attrs = [self.disc_b, self.disc_c, self.disc_d, + self.cont_e] + meta_attrs = [self.cont_f, StringVariable("s"), self.disc_de] + x = np.array([[0, 1, 0, 0.1], + [1, 1, 1, 0.6], + [2, 0, np.nan, 0.2], + [0, np.nan, 1, np.nan], + [np.nan, 0, 6, 0.8]]) + y = np.array([0, 1, 0, 1, 0]) + metas = np.array([[-0.1, "a", 0], + [0.3, "b", 1], + [np.nan, "c", 1], + [0.9, "d", 1], + [0.24, "e", 0]]) + + self.class_data = Table.from_numpy( + Domain(attrs, self.disc_a, meta_attrs), + x, y, metas + ) + + self.bin_data = Table.from_numpy( + Domain(attrs, self.disc_de, meta_attrs[:-1]), + x, y, metas[:, :-1] + ) + + self.regr_data = Table.from_numpy( + Domain(attrs[1:], self.cont_g, meta_attrs), + x[:, 1:], y, metas + ) + + def set_column(self, var): + combo = self.widget.column_combo + index = combo.model().indexOf(var) + self.widget.column_combo.setCurrentIndex(index) + self.widget.column_combo.activated.emit(index) + + def test_model(self): + w = self.widget + model = w.column_combo.model() + self.send_signal(self.class_data) + self.assertEqual(list(model), [self.disc_b, self.disc_d, self.disc_de]) + self.assertIsNotNone(self.get_output(w.Outputs.model)) + self.assertIsNotNone(self.get_output(w.Outputs.learner)) + + self.send_signal(None) + self.assertIsNone(self.get_output(w.Outputs.model)) + self.assertIsNone(self.get_output(w.Outputs.learner)) + self.assertEqual(list(model), []) + + self.send_signal(self.bin_data) + self.assertEqual(list(model), [self.disc_d, self.cont_e, self.cont_f]) + self.assertIsNotNone(self.get_output(w.Outputs.model)) + self.assertIsNotNone(self.get_output(w.Outputs.learner)) + + self.send_signal(self.regr_data) + self.assertEqual(list(model), [self.cont_e, self.cont_f]) + self.assertIsNotNone(self.get_output(w.Outputs.model)) + self.assertIsNotNone(self.get_output(w.Outputs.learner)) + + self.send_signal( + self.bin_data.transform(Domain(self.bin_data.domain.attributes))) + self.assertIsNone(self.get_output(w.Outputs.model)) + self.assertIsNone(self.get_output(w.Outputs.learner)) + self.assertTrue(w.Error.no_class.is_shown()) + self.assertFalse(w.Error.no_variables.is_shown()) + + self.send_signal(self.regr_data) + self.assertIsNotNone(self.get_output(w.Outputs.model)) + self.assertIsNotNone(self.get_output(w.Outputs.learner)) + self.assertFalse(w.Error.no_class.is_shown()) + self.assertFalse(w.Error.no_variables.is_shown()) + + self.send_signal( + self.regr_data.transform( + Domain([self.disc_b, self.disc_c, self.disc_d], self.cont_g))) + self.assertIsNone(self.get_output(w.Outputs.model)) + self.assertIsNone(self.get_output(w.Outputs.learner)) + self.assertFalse(w.Error.no_class.is_shown()) + self.assertTrue(w.Error.no_variables.is_shown()) + + def test_combo_hint(self): + self.send_signal(self.bin_data) + self.assertEqual(self.widget.column, self.disc_d) + + self.send_signal(self.regr_data) + self.assertEqual(self.widget.column, self.cont_e) + + self.set_column(self.cont_f) + # Keep f, because it exists + self.send_signal(self.bin_data) + self.assertEqual(self.widget.column, self.cont_f) + + self.set_column(self.disc_d) + # Can't keep + self.send_signal(self.regr_data) + self.assertEqual(self.widget.column, self.cont_e) + + # Keep hint when there is no data + self.set_column(self.cont_f) + self.send_signal(None) + self.send_signal(self.regr_data) + self.assertEqual(self.widget.column, self.cont_f) + + def set_checked(self, checked): + cb = self.widget.cb_transformation + if cb.isChecked() != checked: + cb.click() + + def test_update_transform_checkbox(self): + check = self.widget.cb_transformation + # No data: button is enabled + self.assertTrue(check.isEnabled()) + + # Check the checkbox so that we see it behaves properly + # when disabled, unchecked and re-enabled + self.set_checked(True) + + with self.subTest("Multinomial target"): + self.send_signal(self.class_data) + # Discrete target: button is disabled and unchecked + # transformation not applied + self.assertFalse(check.isEnabled()) + self.assertFalse(check.isChecked()) + self.assertFalse(self.widget.apply_transformation) + + # No data: re-enabled and re-checked + self.send_signal(None) + self.assertTrue(check.isEnabled()) + self.assertTrue(check.isChecked()) + + self.send_signal(self.bin_data) + # Binary target, discrete column: button is disabled and unchecked + # transformation not applied + self.assertIs(self.widget.column, self.disc_d) + self.assertFalse(check.isEnabled()) + self.assertFalse(check.isChecked()) + self.assertFalse(self.widget.apply_transformation) + + with self.subTest("Binary target, numeric column within range"): + # Binary target, numeric column within range: + # enabled, checked (because of setting) + self.set_column(self.cont_e) + self.assertTrue(check.isEnabled()) + self.assertTrue(check.isChecked()) + self.assertTrue(self.widget.apply_transformation) + # Binary target, numeric column outside range: + # disabled, unchecked (because of setting) + self.set_column(self.cont_e) + self.assertTrue(check.isEnabled()) + self.assertTrue(check.isChecked()) + self.assertTrue(self.widget.apply_transformation) + # Go back to numeric withing range to verify that it is re-checked + self.set_column(self.cont_e) + self.assertTrue(check.isEnabled()) + self.assertTrue(check.isChecked()) + self.assertTrue(self.widget.apply_transformation) + + self.send_signal(None) + self.set_checked(False) + self.send_signal(self.bin_data) + self.set_column(self.cont_e) + self.assertTrue(check.isEnabled()) + self.assertFalse(check.isChecked()) + self.assertFalse(self.widget.apply_transformation) + + with self.subTest("Regression target"): + self.send_signal(self.regr_data) + # Regression target: button is enabled and checked + self.assertTrue(check.isEnabled()) + self.assertFalse(check.isChecked()) + self.assertFalse(self.widget.apply_transformation) + + self.set_checked(True) + self.assertTrue(self.widget.apply_transformation) + + self.send_signal(self.class_data) + assert not check.isEnabled() + assert not check.isChecked() + + self.send_signal(self.regr_data) + self.assertTrue(check.isEnabled()) + self.assertTrue(check.isChecked()) + self.assertTrue(self.widget.apply_transformation) + + # Column that would be out of range for discrete, + # but regression doesn't mind + self.set_checked(False) + self.set_column(self.cont_f) + self.assertTrue(check.isEnabled()) + self.assertFalse(check.isChecked()) + self.assertFalse(self.widget.apply_transformation) + + def test_checkbox_text(self): + check = self.widget.cb_transformation + self.send_signal(self.bin_data) + self.assertIn("logistic", check.text()) + self.assertIn("logistic", check.toolTip()) + self.send_signal(self.regr_data) + self.assertIn("linear", check.text()) + self.assertIn("linear", check.toolTip()) + self.send_signal(self.class_data) + self.assertIn("logistic", check.text()) + self.assertIn("logistic", check.toolTip()) + + @patch("Orange.widgets.evaluate.owfeatureaspredictor.ColumnLearner") + def test_commit(self, learner): + model = self.widget.column_combo.model() + extract = self.regr_data.domain.class_var + + self.assertIsNone(self.get_output(self.widget.Outputs.model)) + self.assertIsNone(self.get_output(self.widget.Outputs.learner)) + self.set_checked(False) + + learner.reset_mock() + self.send_signal(self.regr_data) + learner.assert_called_once() + assert not self.widget.apply_transformation + self.assertEqual(learner.call_args, ((extract, model[0], False),)) + self.assertIs(self.get_output( + self.widget.Outputs.learner), learner.return_value) + self.assertIs(self.get_output( + self.widget.Outputs.model), learner.return_value.return_value) + + learner.reset_mock() + self.set_column(model[1]) + assert not self.widget.apply_transformation + learner.assert_called_once() + self.assertEqual(learner.call_args, ((extract, model[1], False),)) + self.assertIs(self.get_output( + self.widget.Outputs.learner), learner.return_value) + self.assertIs(self.get_output( + self.widget.Outputs.model), learner.return_value.return_value) + + learner.reset_mock() + self.set_checked(True) + learner.assert_called_once() + self.assertEqual(learner.call_args, ((extract, model[1], True),)) + self.assertIs(self.get_output( + self.widget.Outputs.learner), learner.return_value) + self.assertIs(self.get_output( + self.widget.Outputs.model), learner.return_value.return_value) + + @patch("Orange.modelling.column.ColumnModel") + def test_report_data(self, model): + def assert_items(*expected): + self.assertEqual(tuple(i[1] for i in items.call_args[0][0][1:]), + expected) + + items = self.widget.report_items = Mock() + model.return_value.intercept = 1 + model.return_value.coefficient = 2 + + self.widget.send_report() + items.assert_not_called() + + self.send_signal(self.class_data) + self.set_column(self.disc_d) + self.widget.send_report() + self.assertEqual(items.call_args[0][0][0][1], "d") + assert_items(False, False, False) + + self.set_column(self.disc_b) + self.widget.send_report() + self.assertEqual(items.call_args[0][0][0][1], "b") + assert_items(False, False, False) + + self.send_signal(self.regr_data) + self.set_checked(False) + self.widget.send_report() + self.assertEqual(items.call_args[0][0][0][1], "e") + assert_items(False, False, False) + + self.set_checked(True) + self.widget.send_report() + self.assertEqual(items.call_args[0][0][0][1], "e") + assert_items("linear", 1, 2) + + self.send_signal(self.bin_data) + self.set_column(self.disc_d) + self.widget.send_report() + self.assertEqual(items.call_args[0][0][0][1], "d") + assert_items(False, False, False) + + self.set_column(self.cont_e) + assert self.widget.apply_transformation + self.widget.send_report() + self.assertEqual(items.call_args[0][0][0][1], "e") + assert_items("logistic", 1, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/evaluate/tests/test_owliftcurve.py b/Orange/widgets/evaluate/tests/test_owliftcurve.py index 9ba1ade2ece..f5b22259917 100644 --- a/Orange/widgets/evaluate/tests/test_owliftcurve.py +++ b/Orange/widgets/evaluate/tests/test_owliftcurve.py @@ -1,9 +1,13 @@ +# pylint: disable=protected-access,duplicate-code import copy import unittest from unittest.mock import Mock import numpy as np +from AnyQt.QtGui import QFont, QPen + +from Orange.classification import ThresholdClassifier from Orange.data import Table import Orange.evaluation import Orange.classification @@ -12,11 +16,12 @@ from Orange.widgets.tests.base import WidgetTest from Orange.widgets.tests.utils import simulate from Orange.widgets.evaluate.owliftcurve import OWLiftCurve, cumulative_gains, \ - cumulative_gains_from_results + cumulative_gains_from_results, CurveTypes, precision_recall_from_results, \ + points_from_results, compute_area from Orange.tests import test_filename -class TestOWLiftCurve(WidgetTest, EvaluateTest): +class TestOWLiftCurve(EvaluateTest): @classmethod def setUpClass(cls): super().setUpClass() @@ -51,12 +56,184 @@ def test_empty_input(self): def test_nan_input(self): res = copy.copy(self.res) + res.actual = res.actual.copy() res.actual[0] = np.nan self.send_signal(self.widget.Inputs.evaluation_results, res) self.assertTrue(self.widget.Error.invalid_results.is_shown()) self.send_signal(self.widget.Inputs.evaluation_results, None) self.assertFalse(self.widget.Error.invalid_results.is_shown()) + def test_cumulative_gains(self): + self.send_signal(self.widget.Inputs.evaluation_results, self.res) + radio_buttons = self.widget.controls.curve_type.buttons + radio_buttons[CurveTypes.CumulativeGains].click() + self.assertEqual(self.widget.curve_type, CurveTypes.CumulativeGains) + + def test_precision_recall(self): + self.send_signal(self.widget.Inputs.evaluation_results, self.res) + radio_buttons = self.widget.controls.curve_type.buttons + radio_buttons[CurveTypes.PrecisionRecall].click() + self.assertEqual(self.widget.curve_type, CurveTypes.PrecisionRecall) + + def test_get_threshold(self): + recall = np.array([1, 2 / 3, 2 / 3, 1 / 3, 0]) + thresholds = np.array([0.4, 0.5, 0.6, 0.9, 1]) + + self.widget.rate = 1 + threshold = self.widget._get_threshold(recall, thresholds) + self.assertEqual(threshold, 0.4) + + self.widget.rate = 0.7 + threshold = self.widget._get_threshold(recall, thresholds) + self.assertEqual(threshold, 0.4) + + self.widget.rate = 0.5 + threshold = self.widget._get_threshold(recall, thresholds) + self.assertEqual(threshold, 0.6) + + self.widget.rate = 0.3 + threshold = self.widget._get_threshold(recall, thresholds) + self.assertEqual(threshold, 0.9) + + self.widget.rate = 0 + threshold = self.widget._get_threshold(recall, thresholds) + self.assertEqual(threshold, 1) + + def test_threshold_tooltip(self): + data = Table("heart_disease") + test_on_test = Orange.evaluation.TestOnTestData( + store_data=True, store_models=True) + res = test_on_test(data=data[::2], test_data=data[1::2], + learners=[Orange.classification.MajorityLearner(), + Orange.classification.KNNLearner()]) + self.send_signal(self.widget.Inputs.evaluation_results, res) + self.assertEqual(self.widget.tooltip.toPlainText(), + "Probability threshold(s):\n— 0.526\n— 0.4") + + self.widget.line.setPos(0.9) + self.assertEqual(self.widget.tooltip.toPlainText(), + "Probability threshold(s):\n— 0.526\n— 0.2") + + self.widget.line.setPos(0.0) + self.assertEqual(self.widget.tooltip.toPlainText(), + "Probability threshold(s):\n— 0.526\n— 1.0") + + def test_point_tooltip(self): + data = Table("heart_disease") + test_on_test = Orange.evaluation.TestOnTestData( + store_data=True, store_models=True) + res = test_on_test(data=data[::2], test_data=data[1::2], + learners=[Orange.classification.MajorityLearner(), + Orange.classification.KNNLearner()]) + self.send_signal(self.widget.Inputs.evaluation_results, res) + scatter = self.widget.plot.curve_items[-1].scatter + + vb = scatter.getViewBox() + vb.setToolTip = Mock() + + ev = Mock() + ev.exit = False + scatter._maskAt = Mock(side_effect= + lambda *_: np.array([1] + 5 * [0], dtype=bool)) + scatter.hoverEvent(ev) + text = 'P Rate: 0.086\nLift: 1.521\nThreshold: 1.0' + vb.setToolTip.assert_called_with(text) + + def test_output(self): + data = Table("heart_disease") + test_on_test = Orange.evaluation.TestOnTestData( + store_data=True, store_models=True) + res = test_on_test(data=data[::2], test_data=data[1::2], + learners=[Orange.classification.MajorityLearner(), + Orange.classification.KNNLearner()]) + self.send_signal(self.widget.Inputs.evaluation_results, res) + model = self.get_output(self.widget.Outputs.calibrated_model) + self.assertIsNone(model) + self.assertTrue(self.widget.Information.no_output.is_shown()) + + self.widget.selected_classifiers = [1] + self.widget._on_classifiers_changed() + model = self.get_output(self.widget.Outputs.calibrated_model) + self.assertIsInstance(model, ThresholdClassifier) + self.assertEqual(model.threshold, 0.6) + self.assertFalse(self.widget.Information.no_output.is_shown()) + + @WidgetTest.skipNonEnglish + def test_visual_settings(self): + graph = self.widget.plot + + def test_settings(): + font = QFont("Helvetica", italic=True, pointSize=20) + self.assertFontEqual( + graph.parameter_setter.title_item.item.font(), font + ) + + font.setPointSize(16) + for item in graph.parameter_setter.axis_items: + self.assertFontEqual(item.label.font(), font) + + font.setPointSize(15) + for item in graph.parameter_setter.axis_items: + self.assertFontEqual(item.style["tickFont"], font) + + self.assertEqual( + graph.parameter_setter.title_item.item.toPlainText(), "Foo" + ) + self.assertEqual(graph.parameter_setter.title_item.text, "Foo") + + for line in graph.curve_items: + pen: QPen = line.opts["pen"] + self.assertEqual(pen.width(), 10) + + pen: QPen = graph.default_line_item.opts["pen"] + self.assertEqual(pen.width(), 4) + + test_on_test = Orange.evaluation.TestOnTestData(store_data=True) + res = test_on_test( + data=self.lenses[::2], test_data=self.lenses[1::2], + learners=[Orange.classification.MajorityLearner(), + Orange.classification.KNNLearner()] + ) + self.send_signal(self.widget.Inputs.evaluation_results, res) + key, value = ("Fonts", "Font family", "Font family"), "Helvetica" + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Title", "Font size"), 20 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Title", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Axis title", "Font size"), 16 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis title", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Axis ticks", "Font size"), 15 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis ticks", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Annotations", "Title", "Title"), "Foo" + self.widget.set_visual_settings(key, value) + + key, value = ("Figure", "Line", "Width"), 10 + self.widget.set_visual_settings(key, value) + + key, value = ("Figure", "Default Line", "Width"), 4 + self.widget.set_visual_settings(key, value) + + self.send_signal(self.widget.Inputs.evaluation_results, res) + test_settings() + + self.send_signal(self.widget.Inputs.evaluation_results, None) + self.send_signal(self.widget.Inputs.evaluation_results, res) + test_settings() + + def assertFontEqual(self, font1, font2): + self.assertEqual(font1.family(), font2.family()) + self.assertEqual(font1.pointSize(), font2.pointSize()) + self.assertEqual(font1.italic(), font2.italic()) + class UtilsTest(unittest.TestCase): @staticmethod @@ -116,6 +293,102 @@ def test_cumulative_gains_from_results(): assert_almost_equal(respondents, []) assert_almost_equal(thresholds, []) + @staticmethod + def test_precision_recall_from_results(): + y_true = np.array([1, 0, 1, 0, 0, 1]) + y_scores = np.array([0.6, 0.5, 0.9, 0.4, 0.2, 0.4]) + + results = Mock() + results.actual = y_true + results.probabilities = \ + [Mock(), Mock(), np.vstack((1 - y_scores, y_scores)).T] + + recall, precision, thresholds = \ + precision_recall_from_results(results, 1, 2) + np.testing.assert_equal(precision, + np.array([1 / 2, 3 / 5, 2 / 3, 1, 1, 1])) + np.testing.assert_equal(recall, + np.array([1, 1, 2 / 3, 2 / 3, 1 / 3, 0])) + np.testing.assert_equal(thresholds, + np.array([0.2, 0.4, 0.5, 0.6, 0.9, 1])) + + @staticmethod + def test_precision_recall_from_results_one(): + y_true = np.array([1, 0, 1, 0, 0, 1]) + y_scores = np.array([0.6, 0.5, 1, 0.4, 0.2, 0.4]) + + results = Mock() + results.actual = y_true + results.probabilities = \ + [Mock(), Mock(), np.vstack((1 - y_scores, y_scores)).T] + + recall, precision, thresholds = \ + precision_recall_from_results(results, 1, 2) + np.testing.assert_equal(precision, + np.array([1 / 2, 3 / 5, 2 / 3, 1, 1])) + np.testing.assert_equal(recall, + np.array([1, 1, 2 / 3, 2 / 3, 1 / 3])) + np.testing.assert_equal(thresholds, + np.array([0.2, 0.4, 0.5, 0.6, 1])) + + @staticmethod + def test_precision_recall_from_results_multiclass(): + y_true = np.array([1, 0, 1, 0, 2, 2]) + y_scores = np.array([[0.3, 0.3, 0.4], + [0.3, 0.4, 0.4], + [0.1, 0.9, 0.1], + [0.4, 0.2, 0.4], + [0.1, 0.2, 0.7], + [0.1, 0.1, 0.8]]) + + results = Mock() + results.actual = y_true + results.probabilities = [Mock(), Mock(), y_scores] + + recall, precision, thresholds = \ + precision_recall_from_results(results, 1, 2) + np.testing.assert_equal(precision, + np.array([1 / 3, 2 / 5, 2 / 3, 1 / 2, 1, 1])) + np.testing.assert_equal(recall, np.array([1, 1, 1, 1 / 2, 1 / 2, 0])) + np.testing.assert_equal(thresholds, + np.array([0.1, 0.2, 0.3, 0.4, 0.9, 1])) + + @staticmethod + def test_points_from_results_cumulative_gain(): + y_scores = np.array([0.6, 0.5, 0.9, 0.4, 0.2, 0.4]) + results = Mock() + results.actual = np.array([1, 0, 1, 0, 0, 1]) + results.probabilities = \ + [Mock(), Mock(), np.vstack((1 - y_scores, y_scores)).T] + + contacted, respondents, thresholds = \ + cumulative_gains_from_results(results, 1, 2) + res = points_from_results(results, 1, 2, CurveTypes.CumulativeGains) + np.testing.assert_almost_equal(res.contacted, contacted) + np.testing.assert_almost_equal(res.respondents, respondents) + np.testing.assert_almost_equal(res.thresholds, thresholds) + + @staticmethod + def test_points_from_results_precision_recall(): + y_scores = np.array([0.6, 0.5, 0.9, 0.4, 0.2, 0.4]) + results = Mock() + results.actual = np.array([1, 0, 1, 0, 0, 1]) + results.probabilities = \ + [Mock(), Mock(), np.vstack((1 - y_scores, y_scores)).T] + + contacted, respondents, thresholds = \ + precision_recall_from_results(results, 1, 2) + res = points_from_results(results, 1, 2, CurveTypes.PrecisionRecall) + np.testing.assert_almost_equal(res.contacted, contacted) + np.testing.assert_almost_equal(res.respondents, respondents) + np.testing.assert_almost_equal(res.thresholds, thresholds) + + def test_area(self): + x = np.array([5, 8, 9, 11]) + y = np.array([7, 14, 8, 0]) + area = compute_area(x, y) + self.assertEqual(area, 51) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/evaluate/tests/test_owparameterfitter.py b/Orange/widgets/evaluate/tests/test_owparameterfitter.py new file mode 100644 index 00000000000..68b01cdbf22 --- /dev/null +++ b/Orange/widgets/evaluate/tests/test_owparameterfitter.py @@ -0,0 +1,653 @@ +# pylint: disable=missing-docstring,protected-access +import unittest +from unittest.mock import patch, Mock + +import pyqtgraph as pg + +from AnyQt.QtCore import QPointF +from AnyQt.QtGui import QFont +from AnyQt.QtWidgets import QToolTip + +from Orange.classification import NaiveBayesLearner +from Orange.data import Table, Domain +from Orange.modelling import RandomForestLearner +from Orange.regression import PLSRegressionLearner +from Orange.widgets.evaluate.owparameterfitter import OWParameterFitter +from Orange.widgets.model.owrandomforest import OWRandomForest +from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.tests.utils import simulate + + +class DummyLearner(PLSRegressionLearner): + @property + def fitted_parameters(self): + return [ + self.FittedParameter("n_components", "Foo", int, 5, None), + self.FittedParameter("n_components", "Bar", int, 5, 10), + self.FittedParameter("n_components", "Baz", int, None, 10), + self.FittedParameter("n_components", "Qux", int, None, None) + ] + + +class TestOWParameterFitter(WidgetTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._heart = Table("heart_disease")[::10] + cls._housing = Table("housing")[::10] + cls._naive_bayes = NaiveBayesLearner() + cls._pls = PLSRegressionLearner() + cls._rf = RandomForestLearner(n_estimators=3) + cls._dummy = DummyLearner() + + def setUp(self): + self.widget = self.create_widget(OWParameterFitter) + + def test_init(self): + self.widget.controls.minimum.setValue(3) + self.widget.controls.maximum.setValue(6) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.widget.cancel() + self.assertEqual(self.widget.controls.parameter_index.currentText(), + "Components") + self.assertEqual(self.widget.minimum, 3) + self.assertEqual(self.widget.maximum, 6) + + self.send_signal(self.widget.Inputs.learner, None) + self.widget.cancel() + self.assertEqual(self.widget.controls.parameter_index.currentText(), + "") + + def test_input(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.widget.cancel() + + self.send_signal(self.widget.Inputs.data, self._heart) + self.widget.cancel() + self.assertTrue(self.widget.Error.incompatible_learner.is_shown()) + + self.send_signal(self.widget.Inputs.learner, None) + self.widget.cancel() + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + def test_input_no_params(self): + self.send_signal(self.widget.Inputs.data, self._heart) + self.send_signal(self.widget.Inputs.learner, self._naive_bayes) + self.widget.cancel() + self.assertTrue(self.widget.Warning.no_parameters.is_shown()) + + self.send_signal(self.widget.Inputs.learner, None) + self.widget.cancel() + self.assertFalse(self.widget.Warning.no_parameters.is_shown()) + + def test_random_forest(self): + rf_widget = self.create_widget(OWRandomForest) + learner = self.get_output(rf_widget.Outputs.learner) + + self.send_signal(self.widget.Inputs.learner, learner) + self.widget.cancel() + self.assertFalse(self.widget.Warning.no_parameters.is_shown()) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + self.send_signal(self.widget.Inputs.data, self._heart) + self.widget.cancel() + self.assertFalse(self.widget.Warning.no_parameters.is_shown()) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + self.send_signal(self.widget.Inputs.data, self._housing) + self.widget.cancel() + self.assertFalse(self.widget.Warning.no_parameters.is_shown()) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + def test_classless_data(self): + data = self._housing + classless_data = data.transform(Domain(data.domain.attributes)) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, classless_data) + self.widget.cancel() + self.assertTrue(self.widget.Error.missing_target.is_shown()) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.cancel() + self.assertFalse(self.widget.Error.missing_target.is_shown()) + + def test_multiclass_data(self): + data = self._housing + multiclass_data = data.transform(Domain(data.domain.attributes[2:], + data.domain.attributes[:2])) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, multiclass_data) + self.widget.cancel() + self.assertTrue(self.widget.Error.multiple_targets_data.is_shown()) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.cancel() + self.assertFalse(self.widget.Error.multiple_targets_data.is_shown()) + + def test_plot(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + + x = self.widget.graph._FitterPlot__bar_item_tr.opts["x"] + self.assertEqual(list(x), [-0.2, 0.8]) + x = self.widget.graph._FitterPlot__bar_item_cv.opts["x"] + self.assertEqual(list(x), [0.2, 1.2]) + + @patch.object(QToolTip, "showText") + def test_tooltip(self, show_text): + graph = self.widget.graph + + self.assertFalse(self.widget.graph.help_event(Mock())) + self.assertIsNone(show_text.call_args) + + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + + for item in graph.items(): + if isinstance(item, pg.BarGraphItem): + item.mapFromScene = Mock(return_value=QPointF(0.2, 0.2)) + + self.assertTrue(self.widget.graph.help_event(Mock())) + self.assertIn("Train:", show_text.call_args[0][1]) + self.assertIn("CV:", show_text.call_args[0][1]) + + for item in graph.items(): + if isinstance(item, pg.BarGraphItem): + item.mapFromScene = Mock(return_value=QPointF(0.5, 0.5)) + self.assertFalse(self.widget.graph.help_event(Mock())) + + def test_manual_steps(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.widget.controls.manual_steps.setText("1, 2, 3") + self.widget.controls.type.buttons[1].click() + self.wait_until_finished() + + x = self.widget.graph._FitterPlot__bar_item_tr.opts["x"] + self.assertEqual(list(x), [-0.2, 0.8, 1.8]) + x = self.widget.graph._FitterPlot__bar_item_cv.opts["x"] + self.assertEqual(list(x), [0.2, 1.2, 2.2]) + + def test_manual_steps_limits(self): + w = self.widget + + def check(cases): + for setting, steps in cases: + w.controls.manual_steps.setText(setting) + w.controls.manual_steps.returnPressed.emit() + self.assertEqual(w.steps, steps, f"setting: {setting}") + self.assertIs(w.Error.manual_steps_error.is_shown(), not steps, + f"setting: {setting}") + + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + + # 5 to None + simulate.combobox_activate_index(w.controls.parameter_index, 0) + check([("6, 9, 7", (6, 7, 9)), + ("6, 9, 7, 3", ()), + ("6, 9, 7", (6, 7, 9)), + ("6, 9, 7, 3", ())]) + + # None to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 2) + self.assertFalse(w.Error.manual_steps_error.is_shown()) + + check([("12, 1, 3, -5", ()), + ("1, 3, -5", (-5, 1, 3)), + ("12, 1, 3, -5", ())]) + + # No limits + simulate.combobox_activate_index(w.controls.parameter_index, 3) + + self.assertEqual(w.steps, (-5, 1, 3, 12)) + self.assertFalse(w.Error.manual_steps_error.is_shown()) + + # 5 to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 1) + + self.assertEqual(w.steps, ()) + self.assertTrue(w.Error.manual_steps_error.is_shown()) + + check([("12, 8, 7, 5", ()), + ("8, 7, -5", ()), + ("8, 7, 5", (5, 7, 8))]) + self.widget.cancel() + + def test_steps_preview(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.widget.cancel() + self.assertEqual(self.widget.range_preview.steps(), (1, 2)) + + self.widget.controls.type.buttons[1].click() + self.widget.cancel() + self.assertIsNone(self.widget.range_preview.steps()) + + def test_on_parameter_changed(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._dummy) + self.wait_until_finished() + + self.widget.commit.deferred = Mock() + + for i in range(1, 4): + self.widget.commit.deferred.reset_mock() + simulate.combobox_activate_index( + self.widget.controls.parameter_index, i) + self.wait_until_finished() + self.widget.commit.deferred.assert_called_once() + + def test_not_enough_data(self): + self.send_signal(self.widget.Inputs.data, self._housing[:5]) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + self.assertTrue(self.widget.Error.not_enough_data.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + + def test_unknown_err(self): + self.send_signal(self.widget.Inputs.data, Table("iris")[:50]) + self.send_signal(self.widget.Inputs.learner, self._rf) + self.wait_until_finished() + self.assertTrue(self.widget.Error.unknown_err.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + + def test_fitted_parameters(self): + self.assertEqual(self.widget.fitted_parameters, []) + + self.send_signal(self.widget.Inputs.data, self._housing) + self.assertEqual(self.widget.fitted_parameters, []) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.widget.cancel() + self.assertEqual(len(self.widget.fitted_parameters), 1) + + def test_initial_parameters(self): + self.assertEqual(self.widget.initial_parameters, {}) + + self.send_signal(self.widget.Inputs.data, self._housing) + self.assertEqual(self.widget.initial_parameters, {}) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.assertEqual(len(self.widget.initial_parameters), 3) + self.widget.cancel() + + self.send_signal(self.widget.Inputs.learner, self._rf) + self.assertEqual(len(self.widget.initial_parameters), 13) + self.widget.cancel() + + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(len(self.widget.initial_parameters), 14) + + self.send_signal(self.widget.Inputs.learner, None) + self.assertEqual(self.widget.initial_parameters, {}) + + def test_bounds(self): + self.widget.controls.minimum.setValue(-3) + self.widget.controls.maximum.setValue(2) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, None) + self.widget.controls.minimum.setValue(-3) + self.widget.controls.maximum.setValue(2) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + + def test_saved_workflow(self): + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._dummy) + self.widget.cancel() + simulate.combobox_activate_index( + self.widget.controls.parameter_index, 2) + self.widget.controls.minimum.setValue(3) + self.widget.controls.maximum.setValue(6) + self.widget.cancel() + + settings = self.widget.settingsHandler.pack_data(self.widget) + widget = self.create_widget(OWParameterFitter, + stored_settings=settings) + self.send_signal(widget.Inputs.data, self._housing, widget=widget) + self.send_signal(widget.Inputs.learner, self._dummy, widget=widget) + widget.cancel() + self.assertEqual(widget.controls.parameter_index.currentText(), "Baz") + self.assertEqual(widget.minimum, 3) + self.assertEqual(widget.maximum, 6) + + def test_retain_settings(self): + self.send_signal(self.widget.Inputs.learner, self._dummy) + + controls = self.widget.controls + + def _test(): + self.assertEqual(controls.parameter_index.currentText(), "Bar") + self.assertEqual(controls.minimum.value(), 6) + self.assertEqual(controls.maximum.value(), 8) + self.assertEqual(self.widget.parameter_index, 1) + self.assertEqual(self.widget.minimum, 6) + self.assertEqual(self.widget.maximum, 8) + + simulate.combobox_activate_index(controls.parameter_index, 1) + controls.minimum.setValue(6) + controls.maximum.setValue(8) + + self.send_signal(self.widget.Inputs.data, self._housing) + _test() + + self.send_signal(self.widget.Inputs.learner, + DummyLearner(n_components=6)) + _test() + + self.send_signal(self.widget.Inputs.data, None) + self.send_signal(self.widget.Inputs.data, self._housing) + _test() + + self.send_signal(self.widget.Inputs.learner, self._rf) + self.assertEqual(controls.parameter_index.currentText(), + "Number of trees") + self.assertEqual(controls.minimum.value(), 1) + self.assertEqual(controls.maximum.value(), 3) + self.assertEqual(self.widget.parameter_index, 0) + self.assertEqual(self.widget.minimum, 1) + self.assertEqual(self.widget.maximum, 3) + self.widget.cancel() + + def test_visual_settings(self): + graph = self.widget.graph + + def test_settings(): + font = QFont("Helvetica", italic=True, pointSize=20) + for item in graph.parameter_setter.axis_items: + self.assertFontEqual(item.label.font(), font) + font.setPointSize(15) + for item in graph.parameter_setter.axis_items: + self.assertFontEqual(item.style["tickFont"], font) + font.setPointSize(17) + for legend_item in graph.parameter_setter.legend_items: + self.assertFontEqual(legend_item[1].item.font(), font) + self.assertFalse(graph.getAxis("left").grid) + + key, value = ("Fonts", "Font family", "Font family"), "Helvetica" + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Axis title", "Font size"), 20 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis title", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Axis ticks", "Font size"), 15 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis ticks", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Legend", "Font size"), 17 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Legend", "Italic"), True + self.widget.set_visual_settings(key, value) + + key, value = ("Figure", "Gridlines", "Show"), False + self.widget.set_visual_settings(key, value) + key, value = ("Figure", "Gridlines", "Opacity"), 20 + self.widget.set_visual_settings(key, value) + + test_settings() + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, self._heart[:10]) + self.widget.cancel() + test_settings() + + self.send_signal(self.widget.Inputs.data, None) + self.send_signal(self.widget.Inputs.learner, None) + + self.send_signal(self.widget.Inputs.learner, self._pls) + self.send_signal(self.widget.Inputs.data, self._heart[:10]) + self.widget.cancel() + test_settings() + + def assertFontEqual(self, font1: QFont, font2: QFont): + self.assertEqual(font1.family(), font2.family()) + self.assertEqual(font1.pointSize(), font2.pointSize()) + self.assertEqual(font1.italic(), font2.italic()) + + def test_send_report(self): + self.widget.send_report() + + self.send_signal(self.widget.Inputs.data, self._housing) + self.send_signal(self.widget.Inputs.learner, self._pls) + self.wait_until_finished() + self.widget.send_report() + + self.send_signal(self.widget.Inputs.data, self._heart) + self.send_signal(self.widget.Inputs.learner, self._naive_bayes) + self.wait_until_finished() + self.widget.send_report() + + def test_steps_from_range_error(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._heart) + self.send_signal(w.Inputs.learner, self._dummy) + self.widget.cancel() + w.type = w.FROM_RANGE + + w.minimum = 10 + w.maximum = 5 + self.assertEqual(w.steps, ()) + self.assertTrue(w.Error.min_max_error.is_shown()) + + w.maximum = 15 + self.assertNotEqual(w.steps, ()) + self.assertFalse(w.Error.min_max_error.is_shown()) + + w.minimum = 10 + w.maximum = 5 + w.steps # pylint: disable=pointless-statement + self.assertTrue(w.Error.min_max_error.is_shown()) + + self.send_signal(w.Inputs.learner, None) + self.assertFalse(w.Error.min_max_error.is_shown()) + + def test_steps_from_ranges_steps(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._heart) + self.send_signal(w.Inputs.learner, self._dummy) + self.widget.cancel() + w.type = w.FROM_RANGE + + for mini, maxi, exp in [ + (1, 2, (1, 2)), + (1, 5, (1, 2, 3, 4, 5)), + (1, 10, (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)), + (2, 14, (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14)), + (2, 20, (2, 10, 20)), + (2, 22, (2, 10, 20, 22)), + (2, 10, (2, 3, 4, 5, 6, 7, 8, 9, 10)), + (2, 5, (2, 3, 4, 5)), + (2, 4, (2, 3, 4)), + (1, 1, (1,)), + (1, 50, (1, 10, 20, 30, 40, 50)), + (3, 49, (3, 10, 20, 30, 40, 49)), + (9, 31, (9, 10, 20, 30, 31)), + (90, 398, (90, 100, 200, 300, 398)), + (90, 1010, + (90, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1010)), + (810, 1234, (810, 900, 1000, 1100, 1200, 1234)), + (4980, 18030, + (4980, 5000, 6000, 7000, 8000, 9000, 10000, 11000, 12000, + 13000, 14000, 15000, 16000, 17000, 18000, 18030))]: + w.minimum = mini + w.maximum = maxi + self.assertEqual(w.steps, exp, f"min={mini}, max={maxi}") + + def test_steps_from_manual_error(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.widget.cancel() + simulate.combobox_activate_index(w.controls.parameter_index, 3) + w.type = w.MANUAL + + w.manual_steps = "1, 2, 3, asdf, 4, 5" + self.assertEqual(w.steps, ()) + self.assertTrue(w.Error.manual_steps_error.is_shown()) + + w.manual_steps = "1, 2, 3, 4, 5" + self.assertNotEqual(w.steps, ()) + self.assertFalse(w.Error.manual_steps_error.is_shown()) + + w.manual_steps = "1, 2, 3, asdf, 4, 5" + w.steps # pylint: disable=pointless-statement + self.assertTrue(w.Error.manual_steps_error.is_shown()) + + self.send_signal(w.Inputs.learner, None) + self.assertFalse(w.Error.manual_steps_error.is_shown()) + + def test_steps_from_manual_no_dots(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.widget.cancel() + simulate.combobox_activate_index(w.controls.parameter_index, 3) + w.type = w.MANUAL + + w.manual_steps = "1, 2, 3, 4, 5" + self.assertEqual(w.steps, (1, 2, 3, 4, 5)) + + w.manual_steps = "1, 2, 3, 4, 5, 6" + self.assertEqual(w.steps, (1, 2, 3, 4, 5, 6)) + + w.manual_steps = "1, 2, 10, 3, 4, 123, 5, 6" + self.assertEqual(w.steps, (1, 2, 3, 4, 5, 6, 10, 123)) + + def test_steps_from_manual_dots(self): + def check(cases): + for settings, steps in cases: + w.manual_steps = settings + self.assertEqual(w.steps, steps, f"setting: {settings}") + self.assertIs(w.Error.manual_steps_error.is_shown(), not steps, + f"setting: {settings}") + + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.widget.cancel() + w.type = w.MANUAL + + # No limits + simulate.combobox_activate_index(w.controls.parameter_index, 3) + self.widget.cancel() + check([("1, 2, ..., 5", (1, 2, 3, 4, 5)), + ("1, 2, 3, ..., 5, 6, 7", (1, 2, 3, 4, 5, 6, 7)), + ("3, ..., 5, 6", (3, 4, 5, 6)), + ("..., 5, 6", ()), + ("5, 6, ...", ()), + ("1, 2, 3, 4, 5, ...", ()), + ("1, ..., 5", ()), + ("1, 2, ..., 5, 6, ..., 8", ())]) + + # 5 to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 1) + self.widget.cancel() + check([("4, 5, ..., 8", ()), + ("5, 6, ..., 12", ()), + ("5, 6, ..., 9", (5, 6, 7, 8, 9)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("6, 7, ..., 8, 9", (6, 7, 8, 9)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ("6, 7, ...", (6, 7, 8, 9, 10)), + ("6, 7, 8, 9, ...", (6, 7, 8, 9, 10)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ]) + + # 5 to None + simulate.combobox_activate_index(w.controls.parameter_index, 0) + self.widget.cancel() + check([("4, 5, ..., 8", ()), + ("5, 6, ..., 12", (5, 6, 7, 8, 9, 10, 11, 12)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("6, 7, ..., 8, 9", (6, 7, 8, 9)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ("6, 7, ...", ()) + ]) + + # None to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 2) + self.widget.cancel() + check([("4, 5, ..., 8", (4, 5, 6, 7, 8)), + ("5, 6, ..., 12", ()), + ("5, 6, ..., 9", (5, 6, 7, 8, 9)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("..., 8, 9", ()), + ("6, 7, ...", (6, 7, 8, 9, 10)), + ("6, 7, 8, 9, ...", (6, 7, 8, 9, 10)), + ("..., 8, 9", ()), + ]) + + def test_steps_from_manual_dots_corrections(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.widget.cancel() + w.type = w.MANUAL + + # 5 to 10 + simulate.combobox_activate_index(w.controls.parameter_index, 1) + self.widget.cancel() + + for settings, steps in [ + ("5, 6..., 8", (5, 6, 7, 8)), + ("5,6...,8", (5, 6, 7, 8)), + ("5,6...8", (5, 6, 7, 8)), + ("5, 6 ... 8", (5, 6, 7, 8)), + ("5, 6 ... 8", (5, 6, 7, 8)), + ("5, 6 ... ", (5, 6, 7, 8, 9, 10)), + ("..., 7, 8", (5, 6, 7, 8)), + ("..., 7, 8, ...", ()), + ("5, 6, ..., 7, 8, ...", ()), + ("5, 6, 8, ...", ()), + ("5, 6, 8, ...", ()), + ("5, 6, ..., 8, 10", ()), + ("5, 7, ..., 8, 10", ()), + ("8, 7, 6, ...", ()), + ("5, 6, 7, ..., 7, 8", ()), + ]: + w.manual_steps = settings + self.assertEqual(w.steps, steps, f"setting: {settings}") + self.assertIs(w.Error.manual_steps_error.is_shown(), not steps, + f"setting: {settings}") + + def test_manual_tooltip(self): + w: OWParameterFitter = self.widget + self.send_signal(w.Inputs.data, self._housing) + self.send_signal(w.Inputs.learner, self._dummy) + self.widget.cancel() + + simulate.combobox_activate_index(w.controls.parameter_index, 0) + self.assertIn("greater or equal to 5", w.edit.toolTip()) + + simulate.combobox_activate_index(w.controls.parameter_index, 1) + self.assertIn("between 5 and 10", w.edit.toolTip()) + + simulate.combobox_activate_index(w.controls.parameter_index, 2) + self.assertIn("smaller or equal to 10", w.edit.toolTip()) + + simulate.combobox_activate_index(w.controls.parameter_index, 3) + self.assertEqual("", w.edit.toolTip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/evaluate/tests/test_owpermutationplot.py b/Orange/widgets/evaluate/tests/test_owpermutationplot.py new file mode 100644 index 00000000000..9e9aa029827 --- /dev/null +++ b/Orange/widgets/evaluate/tests/test_owpermutationplot.py @@ -0,0 +1,125 @@ +# pylint: disable=missing-docstring,protected-access +import unittest + +from Orange.classification import LogisticRegressionLearner, NaiveBayesLearner +from Orange.data import Table, Domain +from Orange.ensembles import StackedFitter +from Orange.regression import LinearRegressionLearner +from Orange.widgets.evaluate.owpermutationplot import OWPermutationPlot +from Orange.widgets.tests.base import WidgetTest + + +class TestOWPermutationPlot(WidgetTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.heart = Table("heart_disease") + cls.housing = Table("housing") + cls.naive_bayes = NaiveBayesLearner() + cls.lin_reg = LinearRegressionLearner() + + def setUp(self): + self.widget = self.create_widget(OWPermutationPlot, + stored_settings={"n_permutations": 3}) + + def test_input_disc_target(self): + self.send_signal(self.widget.Inputs.data, self.heart) + self.send_signal(self.widget.Inputs.learner, self.naive_bayes) + self.wait_until_finished() + + lin_reg = LinearRegressionLearner() + self.send_signal(self.widget.Inputs.learner, lin_reg) + self.wait_until_finished() + self.assertTrue(self.widget.Error.incompatible_learner.is_shown()) + + self.send_signal(self.widget.Inputs.learner, None) + self.assertFalse(self.widget.Error.incompatible_learner.is_shown()) + + def test_input_cont_target(self): + self.send_signal(self.widget.Inputs.data, self.housing) + self.send_signal(self.widget.Inputs.learner, self.lin_reg) + self.wait_until_finished() + + log_reg = LogisticRegressionLearner() + self.send_signal(self.widget.Inputs.learner, log_reg) + self.wait_until_finished() + self.assertTrue(self.widget.Error.incompatible_learner.is_shown()) + + self.send_signal(self.widget.Inputs.learner, None) + self.assertFalse(self.widget.Error.unknown_err.is_shown()) + + def test_input_cont_target_ensemble(self): + self.send_signal(self.widget.Inputs.data, self.housing) + + learner = StackedFitter([LinearRegressionLearner()]) + self.send_signal(self.widget.Inputs.learner, learner) + self.wait_until_finished() + + learner = StackedFitter([LogisticRegressionLearner()]) + self.send_signal(self.widget.Inputs.learner, learner) + self.wait_until_finished() + self.assertTrue(self.widget.Error.unknown_err.is_shown()) + + def test_input_no_target(self): + domain = Domain(self.housing.domain.attributes) + data = self.housing.transform(domain) + self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.learner, self.lin_reg) + self.wait_until_finished() + self.assertTrue(self.widget.Error.incompatible_learner.is_shown()) + + def test_input_multi_target(self): + domain = Domain(self.housing.domain.attributes[:-2], + self.housing.domain.attributes[-2:]) + data = self.housing.transform(domain) + self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.learner, self.lin_reg) + self.wait_until_finished() + self.assertTrue(self.widget.Error.multiple_targets_data.is_shown()) + + def test_sample_data(self): + self.send_signal(self.widget.Inputs.learner, self.naive_bayes) + self.send_signal(self.widget.Inputs.data, self.heart[:6]) + self.assertTrue(self.widget.Error.not_enough_data.is_shown()) + self.send_signal(self.widget.Inputs.data, self.heart[:7]) + self.assertFalse(self.widget.Error.not_enough_data.is_shown()) + self.wait_until_finished() + + def test_info(self): + self.send_signal(self.widget.Inputs.learner, self.naive_bayes) + self.send_signal(self.widget.Inputs.data, self.heart) + self.wait_until_finished() + self.assertIn('CV', + self.widget._info.text()) + self.assertIn('Train', + self.widget._info.text()) + + text = """Train + 0.6686 + 0.9200""" + self.assertIn(text, self.widget._info.text()) + + text = """CV + 0.5292 + 0.9076""" + self.assertIn(text, self.widget._info.text()) + + self.send_signal(self.widget.Inputs.learner, None) + self.assertEqual(self.widget._info.text(), "No data available.") + + def test_send_report(self): + self.widget.send_report() + self.send_signal(self.widget.Inputs.data, self.heart[:10]) + self.send_signal(self.widget.Inputs.learner, self.naive_bayes) + self.wait_until_finished() + self.widget.send_report() + self.send_signal(self.widget.Inputs.data, self.housing[:10]) + self.send_signal(self.widget.Inputs.learner, self.lin_reg) + self.wait_until_finished() + self.send_signal(self.widget.Inputs.data, None) + self.send_signal(self.widget.Inputs.learner, self.lin_reg) + self.widget.send_report() + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/evaluate/tests/test_owpredictions.py b/Orange/widgets/evaluate/tests/test_owpredictions.py index 28b2e15f5ce..f72a0aa8081 100644 --- a/Orange/widgets/evaluate/tests/test_owpredictions.py +++ b/Orange/widgets/evaluate/tests/test_owpredictions.py @@ -1,30 +1,46 @@ """Tests for OWPredictions""" -# pylint: disable=protected-access -import io +# pylint: disable=protected-access,too-many-lines,too-many-public-methods +import os +import random import unittest -from unittest.mock import Mock +from functools import partial +from tempfile import NamedTemporaryFile +from typing import Optional +from unittest.mock import Mock, patch import numpy as np -from AnyQt.QtCore import QItemSelectionModel, QItemSelection -from AnyQt.QtGui import QStandardItemModel +from AnyQt.QtCore import QItemSelectionModel, QItemSelection, Qt, QRect +from AnyQt.QtWidgets import QToolTip + +from orangewidget.settings import Context from Orange.base import Model -from Orange.classification import LogisticRegressionLearner +from Orange.classification import LogisticRegressionLearner, NaiveBayesLearner +from Orange.classification.majority import ConstantModel, MajorityLearner from Orange.data.io import TabReader -from Orange.widgets.tests.base import WidgetTest +from Orange.evaluation.scoring import TargetScore +from Orange.preprocess import Remove +from Orange.regression import LinearRegressionLearner, MeanLearner, \ + PLSRegressionLearner +from Orange.widgets.tests.base import WidgetTest, GuiTest from Orange.widgets.evaluate.owpredictions import ( - OWPredictions, SortProxyModel, SharedSelectionModel, SharedSelectionStore) + OWPredictions, SharedSelectionModel, SharedSelectionStore, DataModel, + PredictionsModel, + PredictionsItemDelegate, ClassificationItemDelegate, RegressionItemDelegate, + NoopItemDelegate, RegressionErrorDelegate, ClassificationErrorDelegate, + NO_ERR, DIFF_ERROR, ABSDIFF_ERROR, REL_ERROR, ABSREL_ERROR) from Orange.widgets.evaluate.owcalibrationplot import OWCalibrationPlot from Orange.widgets.evaluate.owconfusionmatrix import OWConfusionMatrix from Orange.widgets.evaluate.owliftcurve import OWLiftCurve from Orange.widgets.evaluate.owrocanalysis import OWROCAnalysis -from Orange.data import Table, Domain, DiscreteVariable +from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable from Orange.modelling import ConstantLearner, TreeLearner from Orange.evaluation import Results from Orange.widgets.tests.utils import excepthook_catch, \ - possible_duplicate_table + possible_duplicate_table, simulate +from Orange.widgets.utils.annotated_data import ANNOTATED_DATA_FEATURE_NAME from Orange.widgets.utils.colorpalettes import LimitedDiscretePalette @@ -33,6 +49,11 @@ class TestOWPredictions(WidgetTest): def setUp(self): self.widget = self.create_widget(OWPredictions) # type: OWPredictions self.iris = Table("iris") + self.iris_classless = self.iris.transform(Domain(self.iris.domain.attributes, [])) + self.housing = Table("housing") + + def test_minimum_size(self): + pass def test_rowCount_from_model(self): """Don't crash if the bottom row is visible""" @@ -41,24 +62,26 @@ def test_rowCount_from_model(self): def test_nan_target_input(self): data = self.iris[::10].copy() - data.Y[1] = np.nan - yvec, _ = data.get_column_view(data.domain.class_var) + with data.unlocked(): + data.Y[1] = np.nan + yvec = data.get_column(data.domain.class_var) self.send_signal(self.widget.Inputs.data, data) self.send_signal(self.widget.Inputs.predictors, ConstantLearner()(data), 1) - pred = self.get_output(self.widget.Outputs.predictions) + pred = self.get_output(self.widget.Outputs.selected_predictions) self.assertIsInstance(pred, Table) np.testing.assert_array_equal( - yvec, pred.get_column_view(data.domain.class_var)[0]) + yvec, pred.get_column(data.domain.class_var)) evres = self.get_output(self.widget.Outputs.evaluation_results) self.assertIsInstance(evres, Results) self.assertIsInstance(evres.data, Table) - ev_yvec, _ = evres.data.get_column_view(data.domain.class_var) + ev_yvec = evres.data.get_column(data.domain.class_var) self.assertTrue(np.all(~np.isnan(ev_yvec))) self.assertTrue(np.all(~np.isnan(evres.actual))) - data.Y[:] = np.nan + with data.unlocked(): + data.Y[:] = np.nan self.send_signal(self.widget.Inputs.data, data) evres = self.get_output(self.widget.Outputs.evaluation_results) self.assertEqual(len(evres.data), 0) @@ -74,7 +97,7 @@ def test_no_values_target(self): test = Table(domain, np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]]), np.full((3, 1), np.nan)) self.send_signal(self.widget.Inputs.data, test) - pred = self.get_output(self.widget.Outputs.predictions) + pred = self.get_output(self.widget.Outputs.selected_predictions) self.assertEqual(len(pred), len(test)) results = self.get_output(self.widget.Outputs.evaluation_results) @@ -127,8 +150,50 @@ def test_no_class_on_test(self): no_class = titanic.transform(Domain(titanic.domain.attributes, None)) self.send_signal(self.widget.Inputs.predictors, majority_titanic, 1) self.send_signal(self.widget.Inputs.data, no_class) - out = self.get_output(self.widget.Outputs.predictions) - np.testing.assert_allclose(out.get_column_view("constant")[0], 0) + out = self.get_output(self.widget.Outputs.selected_predictions) + np.testing.assert_allclose(out.get_column("constant"), 0) + + predmodel = self.widget.predictionsview.model() + self.assertTrue(np.isnan( + predmodel.data(predmodel.index(0, 0), Qt.UserRole))) + self.assertIn(predmodel.data(predmodel.index(0, 0))[0], + titanic.domain.class_var.values) + self.widget.send_report() + + housing = self.housing[::5] + mean_housing = ConstantLearner()(housing) + no_target = housing.transform(Domain(housing.domain.attributes, None)) + self.send_signal(self.widget.Inputs.data, no_target) + self.send_signal(self.widget.Inputs.predictors, mean_housing, 1) + self.widget.send_report() + + def test_invalid_regression_target(self): + widget = self.widget + self.send_signal(widget.Inputs.predictors, + LinearRegressionLearner()(self.housing), 0) + + dom = self.housing.domain + wrong_class = self.iris.transform(Domain(dom.attributes[:-1], + dom.attributes[-1])) + self.send_signal(widget.Inputs.data, wrong_class) + + # can't make a prediction + predmodel = self.widget.predictionsview.model() + self.assertTrue(np.isnan( + predmodel.data(predmodel.index(0, 0), Qt.UserRole))) + # ... but model reports a value + self.assertFalse(np.isnan(predmodel.data(predmodel.index(0, 0))[0])) + + no_class = self.iris.transform(Domain(dom.attributes[:-1], + dom.attributes[-1])) + self.send_signal(widget.Inputs.data, no_class) + + # can't make a prediction + predmodel = self.widget.predictionsview.model() + self.assertTrue(np.isnan( + predmodel.data(predmodel.index(0, 0), Qt.UserRole))) + # ... but model reports a value + self.assertFalse(np.isnan(predmodel.data(predmodel.index(0, 0))[0])) def test_bad_data(self): """ @@ -147,8 +212,10 @@ def test_bad_data(self): child\tmale\tyes child\tfemale\tyes """ - file1 = io.StringIO(filestr1) - table = TabReader(file1).read() + with NamedTemporaryFile(mode="w", delete=False) as tmp: + tmp.write(filestr1) + table = TabReader(tmp.name).read() + os.unlink(tmp.name) learner = TreeLearner() tree = learner(table) @@ -161,9 +228,11 @@ def test_bad_data(self): child\tmale\tyes child\tfemale\tunknown """ - file2 = io.StringIO(filestr2) - bad_table = TabReader(file2).read() + with NamedTemporaryFile(mode="w", delete=False) as tmp: + tmp.write(filestr2) + bad_table = TabReader(tmp.name).read() + os.unlink(tmp.name) self.send_signal(self.widget.Inputs.predictors, tree, 1) with excepthook_catch(): @@ -175,6 +244,22 @@ def test_continuous_class(self): self.send_signal(self.widget.Inputs.predictors, cl_data, 1) self.send_signal(self.widget.Inputs.data, data) + def test_changed_class_var(self): + def set_input(data, model): + self.send_signals([ + (self.widget.Inputs.data, data), + (self.widget.Inputs.predictors, model) + ]) + + iris = self.iris + learner = ConstantLearner() + heart_disease = Table("heart_disease") + # catch exceptions in item delegates etc. during switching inputs + with excepthook_catch(): + set_input(iris[:5], learner(iris)) + set_input(Table("housing"), None) + set_input(heart_disease[:5], learner(heart_disease)) + def test_predictor_fails(self): titanic = Table("titanic") failing_model = ConstantLearner()(titanic) @@ -187,11 +272,7 @@ def test_predictor_fails(self): def test_sort_matching(self): def get_items_order(model): - n = pred_model.rowCount() - return [ - pred_model.mapToSource(model.index(i, 0)).row() - for i in range(n) - ] + return model.mapToSourceRows(np.arange(model.rowCount())) w = self.widget @@ -201,46 +282,43 @@ def get_items_order(model): self.send_signal(self.widget.Inputs.data, titanic) pred_model = w.predictionsview.model() - data_model = w.predictionsview.model() + data_model = w.dataview.model() n = pred_model.rowCount() # no sort pred_order = get_items_order(pred_model) data_order = get_items_order(data_model) - self.assertListEqual(pred_order, list(range(n))) - self.assertListEqual(data_order, list(range(n))) + np.testing.assert_array_equal(pred_order, np.arange(n)) + np.testing.assert_array_equal(data_order, np.arange(n)) # sort by first column in prediction table pred_model.sort(0) w.predictionsview.horizontalHeader().sectionClicked.emit(0) pred_order = get_items_order(pred_model) data_order = get_items_order(data_model) - self.assertListEqual(pred_order, data_order) + np.testing.assert_array_equal(pred_order, data_order) # sort by second column in data table data_model.sort(1) w.dataview.horizontalHeader().sectionClicked.emit(0) pred_order = get_items_order(pred_model) data_order = get_items_order(data_model) - self.assertListEqual(pred_order, data_order) + np.testing.assert_array_equal(pred_order, data_order) # restore order w.reset_button.click() pred_order = get_items_order(pred_model) data_order = get_items_order(data_model) - self.assertListEqual(pred_order, list(range(n))) - self.assertListEqual(data_order, list(range(n))) + np.testing.assert_array_equal(pred_order, np.arange(n)) + np.testing.assert_array_equal(data_order, np.arange(n)) def test_sort_predictions(self): """ Test whether sorting of probabilities by FilterSortProxy is correct. """ + def get_items_order(model): - n = pred_model.rowCount() - return [ - pred_model.mapToSource(model.index(i, 0)).row() - for i in range(n) - ] + return model.mapToSourceRows(np.arange(model.rowCount())) log_reg_iris = LogisticRegressionLearner()(self.iris) self.send_signal(self.widget.Inputs.predictors, log_reg_iris) @@ -427,7 +505,7 @@ def test_unique_output_domain(self): self.send_signal(self.widget.Inputs.data, data) self.send_signal(self.widget.Inputs.predictors, predictor) - output = self.get_output(self.widget.Outputs.predictions) + output = self.get_output(self.widget.Outputs.selected_predictions) self.assertEqual(output.domain.metas[0].name, 'constant (1)') def test_select(self): @@ -442,6 +520,92 @@ def test_select(self): for index in self.widget.dataview.selectionModel().selectedIndexes()} self.assertEqual(sel, {(1, col) for col in range(5)}) + def test_selection_output(self): + log_reg_iris = LogisticRegressionLearner()(self.iris) + self.send_signal(self.widget.Inputs.predictors, log_reg_iris) + self.send_signal(self.widget.Inputs.data, self.iris) + + selmodel = self.widget.dataview.selectionModel() + pred_model = self.widget.predictionsview.model() + + selmodel.select(self.widget.dataview.model().index(1, 0), QItemSelectionModel.Select) + selmodel.select(self.widget.dataview.model().index(3, 0), QItemSelectionModel.Select) + output = self.get_output(self.widget.Outputs.selected_predictions) + self.assertEqual(len(output), 2) + self.assertEqual(output[0], self.iris[1]) + self.assertEqual(output[1], self.iris[3]) + output = self.get_output(self.widget.Outputs.annotated) + self.assertEqual(len(output), len(self.iris)) + col = output.get_column(ANNOTATED_DATA_FEATURE_NAME) + self.assertEqual(np.sum(col), 2) + self.assertEqual(col[1], 1) + self.assertEqual(col[3], 1) + + pred_model.sort(0) + output = self.get_output(self.widget.Outputs.selected_predictions) + self.assertEqual(len(output), 2) + self.assertEqual(output[0], self.iris[1]) + self.assertEqual(output[1], self.iris[3]) + ann_output = self.get_output(self.widget.Outputs.annotated) + self.assertEqual(len(ann_output), len(self.iris)) + col = ann_output.get_column(ANNOTATED_DATA_FEATURE_NAME) + self.assertEqual(np.sum(col), 2) + np.testing.assert_array_equal(ann_output[col == 1].X, output.X) + + pred_model.sort(0, Qt.DescendingOrder) + output = self.get_output(self.widget.Outputs.selected_predictions) + self.assertEqual(len(output), 2) + self.assertEqual(output[0], self.iris[3]) + self.assertEqual(output[1], self.iris[1]) + ann_output = self.get_output(self.widget.Outputs.annotated) + self.assertEqual(len(ann_output), len(self.iris)) + col = ann_output.get_column(ANNOTATED_DATA_FEATURE_NAME) + self.assertEqual(np.sum(col), 2) + np.testing.assert_array_equal(ann_output[col == 1].X, output.X) + + def test_no_selection_output(self): + log_reg_iris = LogisticRegressionLearner()(self.iris) + self.send_signal(self.widget.Inputs.predictors, log_reg_iris) + self.send_signal(self.widget.Inputs.data, self.iris) + + data_model = self.widget.dataview.model() + + output = self.get_output(self.widget.Outputs.selected_predictions) + self.assertEqual(len(output), len(self.iris)) + output = self.get_output(self.widget.Outputs.annotated) + self.assertEqual(len(output), len(self.iris)) + col = output.get_column(ANNOTATED_DATA_FEATURE_NAME) + self.assertFalse(np.any(col)) + + data_model.sort(2) + col_name = data_model.headerData(2, Qt.Horizontal, Qt.DisplayRole) # "sepal width" + output = self.get_output(self.widget.Outputs.selected_predictions) + self.assertEqual(len(output), len(self.iris)) + col = output.get_column(col_name) + self.assertTrue(np.all(col[1:] >= col[:-1])) + + output = self.get_output(self.widget.Outputs.annotated) + self.assertEqual(len(output), len(self.iris)) + col = output.get_column(col_name) + self.assertTrue(np.all(col[1:] >= col[:-1])) + col = output.get_column(ANNOTATED_DATA_FEATURE_NAME) + self.assertFalse(np.any(col)) + + data_model.sort(2, Qt.DescendingOrder) + col_name = data_model.headerData(2, Qt.Horizontal, Qt.DisplayRole) # "sepal width" + output = self.get_output(self.widget.Outputs.selected_predictions) + self.assertEqual(len(output), len(self.iris)) + col = output.get_column(col_name) + self.assertTrue(np.all(col[1:] <= col[:-1])) + + output = self.get_output(self.widget.Outputs.annotated) + self.assertEqual(len(output), len(self.iris)) + col = output.get_column(col_name) + self.assertTrue(np.all(col[1:] <= col[:-1])) + col = output.get_column(ANNOTATED_DATA_FEATURE_NAME) + self.assertFalse(np.any(col)) + + def test_select_data_first(self): log_reg_iris = LogisticRegressionLearner()(self.iris) self.send_signal(self.widget.Inputs.data, self.iris) @@ -464,7 +628,7 @@ def test_selection_in_setting(self): for index in widget.dataview.selectionModel().selectedIndexes()} self.assertEqual(sel, {(row, col) for row in [1, 3, 4] for col in range(5)}) - out = self.get_output(widget.Outputs.predictions) + out = self.get_output(widget.Outputs.selected_predictions) exp = self.iris[np.array([1, 3, 4])] np.testing.assert_equal(out.X, exp.X) @@ -473,28 +637,781 @@ def test_unregister_prediction_model(self): self.send_signal(self.widget.Inputs.predictors, log_reg_iris) self.send_signal(self.widget.Inputs.data, self.iris) self.widget.selection_store.unregister = Mock() - prev_model = self.widget.predictionsview.model() self.send_signal(self.widget.Inputs.predictors, log_reg_iris) - self.widget.selection_store.unregister.called_with(prev_model) + self.widget.selection_store.unregister.assert_called_once() + + def test_multi_inputs(self): + w = self.widget + data = self.iris[::5].copy() + + p1 = ConstantLearner()(data) + p1.name = "P1" + p2 = ConstantLearner()(data) + p2.name = "P2" + p3 = ConstantLearner()(data) + p3.name = "P3" + for i, p in enumerate([p1, p2, p3], 1): + self.send_signal(w.Inputs.predictors, p, i) + self.send_signal(w.Inputs.data, data) + + def check_evres(expected): + out = self.get_output(w.Outputs.evaluation_results) + self.assertSequenceEqual(out.learner_names, expected) + self.assertEqual(out.folds, [...]) + self.assertEqual(out.models.shape, (1, len(out.learner_names))) + self.assertIsInstance(out.models[0, 0], ConstantModel) + + check_evres(["P1", "P2", "P3"]) + + self.send_signal(w.Inputs.predictors, None, 2) + check_evres(["P1", "P3"]) + + self.send_signal(w.Inputs.predictors, p2, 2) + check_evres(["P1", "P2", "P3"]) + + self.send_signal(w.Inputs.predictors, + w.Inputs.predictors.closing_sentinel, 2) + check_evres(["P1", "P3"]) + + self.send_signal(w.Inputs.predictors, p2, 2) + check_evres(["P1", "P3", "P2"]) + + def test_missing_target_cls(self): + mask = np.zeros(len(self.iris), dtype=bool) + mask[::2] = True + train_data = self.iris[~mask] + predict_data = self.iris[mask] + model = LogisticRegressionLearner()(train_data) + + self.send_signal(self.widget.Inputs.predictors, model) + self.send_signal(self.widget.Inputs.data, predict_data) + self.assertFalse(self.widget.Warning.missing_targets.is_shown()) + self.assertFalse(self.widget.Error.scorer_failed.is_shown()) + + with predict_data.unlocked(): + predict_data.Y[0] = np.nan + self.send_signal(self.widget.Inputs.data, predict_data) + self.assertTrue(self.widget.Warning.missing_targets.is_shown()) + self.assertFalse(self.widget.Error.scorer_failed.is_shown()) + + with predict_data.unlocked(): + predict_data.Y[:] = np.nan + self.send_signal(self.widget.Inputs.data, predict_data) + self.assertTrue(self.widget.Warning.missing_targets.is_shown()) + self.assertFalse(self.widget.Error.scorer_failed.is_shown()) + + self.send_signal(self.widget.Inputs.predictors, None) + self.assertFalse(self.widget.Warning.missing_targets.is_shown()) + self.assertFalse(self.widget.Error.scorer_failed.is_shown()) + + self.send_signal(self.widget.Inputs.predictors, model) + self.assertTrue(self.widget.Warning.missing_targets.is_shown()) + self.widget.controls.show_scores.setChecked(False) + self.assertFalse(self.widget.Warning.missing_targets.is_shown()) + + def test_missing_target_reg(self): + mask = np.zeros(len(self.housing), dtype=bool) + mask[::2] = True + train_data = self.housing[~mask] + predict_data = self.housing[mask] + model = LinearRegressionLearner()(train_data) + + self.send_signal(self.widget.Inputs.predictors, model) + self.send_signal(self.widget.Inputs.data, predict_data) + self.assertFalse(self.widget.Warning.missing_targets.is_shown()) + self.assertFalse(self.widget.Error.scorer_failed.is_shown()) + + with predict_data.unlocked(): + predict_data.Y[0] = np.nan + self.send_signal(self.widget.Inputs.data, predict_data) + self.assertTrue(self.widget.Warning.missing_targets.is_shown()) + self.assertFalse(self.widget.Error.scorer_failed.is_shown()) + + with predict_data.unlocked(): + predict_data.Y[:] = np.nan + self.send_signal(self.widget.Inputs.data, predict_data) + self.assertTrue(self.widget.Warning.missing_targets.is_shown()) + self.assertFalse(self.widget.Error.scorer_failed.is_shown()) + + self.send_signal(self.widget.Inputs.predictors, None) + self.assertFalse(self.widget.Warning.missing_targets.is_shown()) + self.assertFalse(self.widget.Error.scorer_failed.is_shown()) + + def _mock_predictors(self): + def pred(values): + slot = Mock() + slot.predictor.domain = Domain([], DiscreteVariable("c", tuple(values))) + return slot + + def predc(): + slot = Mock() + slot.predictor.domain = Domain([], ContinuousVariable("c")) + return slot + + widget = self.widget + model = Mock() + model.setProbInd = Mock() + widget.predictionsview.model = Mock(return_value=model) + + widget.predictors = \ + [pred(values) for values in ("abc", "ab", "cbd", "e")] + [predc()] + + def test_update_prediction_delegate_discrete(self): + self._mock_predictors() + + widget = self.widget + prob_combo = widget.controls.shown_probs + set_prob_ind = widget.predictionsview.model().setProbInd + widget._non_errored_predictors = lambda: widget.predictors[:4] + + widget.data = Table.from_list( + Domain([], DiscreteVariable("c", values=tuple("abc"))), []) + + widget._update_control_visibility() + self.assertFalse(prob_combo.isHidden()) + + widget._set_class_values() + self.assertEqual(widget.class_values, list("abcde")) + + widget._set_target_combos() + self.assertEqual( + [prob_combo.itemText(i) for i in range(prob_combo.count())], + widget.PROB_OPTS + list("abc")) + + widget.shown_probs = widget.NO_PROBS + widget._update_prediction_delegate() + for delegate in widget._delegates: + if isinstance(delegate, ClassificationItemDelegate): + self.assertEqual(list(delegate.shown_probabilities), []) + self.assertEqual(delegate.tooltip, "") + set_prob_ind.assert_called_with([[], [], [], []]) + + widget.shown_probs = widget.DATA_PROBS + widget._update_prediction_delegate() + self.assertEqual(widget._delegates[0].shown_probabilities, [0, 1, 2]) + self.assertEqual(widget._delegates[2].shown_probabilities, [0, 1, None]) + self.assertEqual(widget._delegates[2].shown_probabilities, [0, 1, None]) + self.assertEqual(widget._delegates[4].shown_probabilities, [None, 1, 2]) + self.assertEqual(widget._delegates[6].shown_probabilities, [None, None, None]) + for delegate in widget._delegates[:-1:2]: + self.assertEqual(delegate.tooltip, "p(a, b, c)") + set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [1, 2], []]) + + widget.shown_probs = widget.MODEL_PROBS + widget._update_prediction_delegate() + self.assertEqual(widget._delegates[0].shown_probabilities, [0, 1, 2]) + self.assertEqual(widget._delegates[0].tooltip, "p(a, b, c)") + self.assertEqual(widget._delegates[2].shown_probabilities, [0, 1]) + self.assertEqual(widget._delegates[2].tooltip, "p(a, b)") + self.assertEqual(widget._delegates[4].shown_probabilities, [2, 1, 3]) + self.assertEqual(widget._delegates[4].tooltip, "p(c, b, d)") + self.assertEqual(widget._delegates[6].shown_probabilities, [4]) + self.assertEqual(widget._delegates[6].tooltip, "p(e)") + set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [2, 1, 3], [4]]) + + widget.shown_probs = widget.BOTH_PROBS + widget._update_prediction_delegate() + self.assertEqual(widget._delegates[0].shown_probabilities, [0, 1, 2]) + self.assertEqual(widget._delegates[0].tooltip, "p(a, b, c)") + self.assertEqual(widget._delegates[2].shown_probabilities, [0, 1]) + self.assertEqual(widget._delegates[2].tooltip, "p(a, b)") + self.assertEqual(widget._delegates[4].shown_probabilities, [1, 2]) + self.assertEqual(widget._delegates[4].tooltip, "p(b, c)") + self.assertEqual(widget._delegates[6].shown_probabilities, []) + self.assertEqual(widget._delegates[6].tooltip, "") + set_prob_ind.assert_called_with([[0, 1, 2], [0, 1], [1, 2], []]) + + n_fixed = len(widget.PROB_OPTS) + widget.shown_probs = n_fixed # a + widget._update_prediction_delegate() + self.assertEqual(widget._delegates[0].shown_probabilities, [0]) + self.assertEqual(widget._delegates[2].shown_probabilities, [0]) + self.assertEqual(widget._delegates[4].shown_probabilities, [None]) + self.assertEqual(widget._delegates[6].shown_probabilities, [None]) + for delegate in widget._delegates[:-1:2]: + self.assertEqual(delegate.tooltip, "p(a)") + set_prob_ind.assert_called_with([[0], [0], [], []]) + + n_fixed = len(widget.PROB_OPTS) + widget.shown_probs = n_fixed + 1 # b + widget._update_prediction_delegate() + self.assertEqual(widget._delegates[0].shown_probabilities, [1]) + self.assertEqual(widget._delegates[2].shown_probabilities, [1]) + self.assertEqual(widget._delegates[4].shown_probabilities, [1]) + self.assertEqual(widget._delegates[6].shown_probabilities, [None]) + for delegate in widget._delegates[:-1:2]: + self.assertEqual(delegate.tooltip, "p(b)") + set_prob_ind.assert_called_with([[1], [1], [1], []]) + + n_fixed = len(widget.PROB_OPTS) + widget.shown_probs = n_fixed + 2 # c + widget._update_prediction_delegate() + self.assertEqual(widget._delegates[0].shown_probabilities, [2]) + self.assertEqual(widget._delegates[2].shown_probabilities, [None]) + self.assertEqual(widget._delegates[4].shown_probabilities, [2]) + self.assertEqual(widget._delegates[6].shown_probabilities, [None]) + for delegate in widget._delegates[:-1:2]: + self.assertEqual(delegate.tooltip, "p(c)") + set_prob_ind.assert_called_with([[2], [], [2], []]) + + def test_update_delegates_continuous(self): + self._mock_predictors() + + widget = self.widget + widget.shown_probs = widget.DATA_PROBS + + widget.data = Table.from_list(Domain([], ContinuousVariable("c")), []) + + # only regression + all_predictors = widget.predictors + widget.predictors = [widget.predictors[-1]] + widget._update_control_visibility() + self.assertTrue(widget.controls.shown_probs.isHidden()) + self.assertTrue(widget.controls.target_class.isHidden()) + + # regression and classification + widget.predictors = all_predictors + widget._update_control_visibility() + self.assertFalse(widget.controls.shown_probs.isHidden()) + self.assertTrue(widget.controls.target_class.isHidden()) + + widget._set_class_values() + self.assertEqual(widget.class_values, list("abcde")) + + widget._set_target_combos() + self.assertEqual(widget.shown_probs, widget.NO_PROBS) + + def is_enabled(prob_item): + return widget.controls.shown_probs.model().item(prob_item).flags() & Qt.ItemIsEnabled + self.assertTrue(is_enabled(widget.NO_PROBS)) + self.assertTrue(is_enabled(widget.MODEL_PROBS)) + self.assertFalse(is_enabled(widget.DATA_PROBS)) + self.assertFalse(is_enabled(widget.BOTH_PROBS)) + + def test_delegate_ranges(self): + widget = self.widget + + class Model1(Model): + name = "foo" + + def predict(self, X): + return X[:, 0] - 2 + + class Model2(Model): + name = "bar" + + def predict(self, X): + return np.full(len(X), np.nan) + + domain = Domain([ContinuousVariable("x")], ContinuousVariable("y")) + x = np.arange(12, 17, dtype=float)[:, None] + y = np.array([12, 13, 14, 15, np.nan]) + data = Table(domain, x, y) + + ddomain = Domain( + [ContinuousVariable("x")], + DiscreteVariable("y", values=tuple("abcdefghijklmnopq"))) + self.send_signal(widget.Inputs.data, data) + self.send_signal(widget.Inputs.predictors, Model1(domain), 1) + self.send_signal(widget.Inputs.predictors, Model2(domain), 2) + self.send_signal(widget.Inputs.predictors, Model1(ddomain), 3) + + delegate = widget.predictionsview.itemDelegateForColumn(0) + # values for model are 10 to 14 (incl), Y goes from 12 to 15 (incl) + self.assertIsInstance(delegate, RegressionItemDelegate) + self.assertEqual(delegate.offset, 10) + self.assertEqual(delegate.span, 5) + + delegate = widget.predictionsview.itemDelegateForColumn(2) + # values for model are all-nan, Y goes from 12 to 15 (incl) + self.assertIsInstance(delegate, RegressionItemDelegate) + self.assertEqual(delegate.offset, 12) + self.assertEqual(delegate.span, 3) + + delegate = widget.predictionsview.itemDelegateForColumn(4) + self.assertIsInstance(delegate, ClassificationItemDelegate) + + data = Table(domain, x, np.full(5, np.nan)) + self.send_signal(widget.Inputs.data, data) + delegate = widget.predictionsview.itemDelegateForColumn(0) + # values for model are 10 to 14 (incl), Y is nan + self.assertIsInstance(delegate, RegressionItemDelegate) + self.assertEqual(delegate.offset, 10) + self.assertEqual(delegate.span, 4) + + delegate = widget.predictionsview.itemDelegateForColumn(2) + # values for model and y are nan + self.assertIsInstance(delegate, RegressionItemDelegate) + self.assertEqual(delegate.offset, 0) + self.assertEqual(delegate.span, 1) + + delegate = widget.predictionsview.itemDelegateForColumn(4) + self.assertIsInstance(delegate, ClassificationItemDelegate) + + class _Scorer(TargetScore): + # pylint: disable=arguments-differ + def compute_score(self, _, target, **__): + return [42 if target is None else target] + + def test_output_wrt_shown_probs_1(self): + """Data has one class less, models have same, different or one more""" + widget = self.widget + iris012 = self.iris + purge = Remove(class_flags=Remove.RemoveUnusedValues) + iris01 = purge(iris012[:100]) + iris12 = purge(iris012[50:]) + + bayes01 = NaiveBayesLearner()(iris01) + bayes12 = NaiveBayesLearner()(iris12) + bayes012 = NaiveBayesLearner()(iris012) + + self.send_signal(widget.Inputs.data, iris01) + self.send_signal(widget.Inputs.predictors, bayes01, 0) + self.send_signal(widget.Inputs.predictors, bayes12, 1) + self.send_signal(widget.Inputs.predictors, bayes012, 2) + widget.controls.show_probability_errors.setChecked(False) + + for i, pred in enumerate(widget.predictors): + p = pred.results.unmapped_probabilities + p[0] = 10 + 100 * i + np.arange(p.shape[1]) + pred.results.unmapped_predicted[:] = i + + widget.shown_probs = widget.NO_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 1, 2]) + + widget.shown_probs = widget.DATA_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 11, 1, 0, 110, 2, 210, 211]) + + widget.shown_probs = widget.MODEL_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 11, 1, 110, 111, 2, 210, 211, 212]) + + widget.shown_probs = widget.BOTH_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 11, 1, 110, 2, 210, 211]) + + widget.shown_probs = widget.BOTH_PROBS + 1 + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 1, 0, 2, 210]) + + widget.shown_probs = widget.BOTH_PROBS + 2 + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 11, 1, 110, 2, 211]) + + def test_output_wrt_shown_probs_2(self): + """One model misses one class""" + widget = self.widget + iris012 = self.iris + purge = Remove(class_flags=Remove.RemoveUnusedValues) + iris01 = purge(iris012[:100]) + + bayes01 = NaiveBayesLearner()(iris01) + bayes012 = NaiveBayesLearner()(iris012) + + self.send_signal(widget.Inputs.data, iris012) + self.send_signal(widget.Inputs.predictors, bayes01, 0) + self.send_signal(widget.Inputs.predictors, bayes012, 1) + widget.controls.show_probability_errors.setChecked(False) + + for i, pred in enumerate(widget.predictors): + p = pred.results.unmapped_probabilities + p[0] = 10 + 100 * i + np.arange(p.shape[1]) + pred.results.unmapped_predicted[:] = i + + widget.shown_probs = widget.NO_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 1]) + + widget.shown_probs = widget.DATA_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 11, 0, 1, 110, 111, 112]) + + widget.shown_probs = widget.MODEL_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 11, 1, 110, 111, 112]) + + widget.shown_probs = widget.BOTH_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 11, 1, 110, 111, 112]) + + widget.shown_probs = widget.BOTH_PROBS + 1 + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 1, 110]) + + widget.shown_probs = widget.BOTH_PROBS + 2 + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 11, 1, 111]) + + widget.shown_probs = widget.BOTH_PROBS + 3 + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 0, 1, 112]) + + def test_output_regression(self): + widget = self.widget + self.send_signal(widget.Inputs.data, self.housing) + self.send_signal(widget.Inputs.predictors, + LinearRegressionLearner()(self.housing), 0) + self.send_signal(widget.Inputs.predictors, + MeanLearner()(self.housing), 1) + out = self.get_output(widget.Outputs.selected_predictions) + np.testing.assert_equal( + out.metas[:, [0, 2]], + np.hstack([pred.results.predicted.T for pred in widget.predictors])) + + def test_classless(self): + widget = self.widget + iris012 = self.iris + purge = Remove(class_flags=Remove.RemoveUnusedValues) + iris01 = purge(iris012[:100]) + iris12 = purge(iris012[50:]) + + bayes01 = NaiveBayesLearner()(iris01) + bayes12 = NaiveBayesLearner()(iris12) + bayes012 = NaiveBayesLearner()(iris012) + + self.send_signal(widget.Inputs.data, self.iris_classless) + self.send_signal(widget.Inputs.predictors, bayes01, 0) + self.send_signal(widget.Inputs.predictors, bayes12, 1) + self.send_signal(widget.Inputs.predictors, bayes012, 2) + + for i, pred in enumerate(widget.predictors): + p = pred.results.unmapped_probabilities + p[0] = 10 + 100 * i + np.arange(p.shape[1]) + pred.results.unmapped_predicted[:] = i + + widget.shown_probs = widget.NO_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 1, 2]) + + widget.shown_probs = widget.MODEL_PROBS + widget._commit_predictions() + out = self.get_output(widget.Outputs.selected_predictions) + self.assertEqual(list(out.metas[0]), [0, 10, 11, 1, 110, 111, 2, 210, 211, 212]) + + @patch("Orange.widgets.evaluate.owpredictions.usable_scorers", + Mock(return_value=[_Scorer])) + def test_change_target(self): + widget = self.widget + table = widget.score_table + combo = widget.controls.target_class + + log_reg_iris = LogisticRegressionLearner()(self.iris) + self.send_signal(widget.Inputs.predictors, log_reg_iris) + self.send_signal(widget.Inputs.data, self.iris) + + self.assertEqual(table.model.rowCount(), 1) + self.assertEqual(table.model.columnCount(), 4) + self.assertEqual(float(table.model.data(table.model.index(0, 3))), 42) + + for idx, value in enumerate(widget.class_var.values): + simulate.combobox_activate_item(combo, value, Qt.DisplayRole) + self.assertEqual(table.model.rowCount(), 1) + self.assertEqual(table.model.columnCount(), 4) + self.assertEqual(float(table.model.data(table.model.index(0, 3))), + idx) + + def test_multi_target_input(self): + widget = self.widget + + domain = Domain([ContinuousVariable('var1')], + class_vars=[ + ContinuousVariable('c1'), + DiscreteVariable('c2', values=('no', 'yes')) + ]) + data = Table.from_list(domain, [[1, 5, 0], [2, 10, 1]]) + + mock_model = Mock(spec=Model, return_value=np.asarray([0.2, 0.1])) + mock_model.name = 'Mockery' + mock_model.domain = domain + + self.send_signal(widget.Inputs.data, data) + self.send_signal(widget.Inputs.predictors, mock_model, 1) + pred = self.get_output(widget.Outputs.selected_predictions) + self.assertIsInstance(pred, Table) + + def test_error_controls_visibility(self): + widget = self.widget + senddata = partial(self.send_signal, widget.Inputs.data) + sendpredictor = partial(self.send_signal, widget.Inputs.predictors) + clshidden = widget._cls_error_controls[0].isHidden + reghidden = widget._reg_error_controls[0].isHidden + colhidden = widget.predictionsview.isColumnHidden + delegate = widget.predictionsview.itemDelegateForColumn + + iris = self.iris + regiris = iris.transform(Domain(iris.domain.attributes[:3], + iris.domain.attributes[3])) + riris = MeanLearner()(regiris) + ciris = MajorityLearner()(iris) + + self.assertFalse(clshidden()) + self.assertFalse(reghidden()) + + senddata(self.housing) + self.assertTrue(clshidden()) + self.assertFalse(reghidden()) + + senddata(self.iris) + self.assertFalse(clshidden()) + self.assertTrue(reghidden()) + + senddata(None) + self.assertTrue(clshidden()) + self.assertTrue(reghidden()) + + senddata(self.iris_classless) + self.assertTrue(clshidden()) + self.assertTrue(reghidden()) + + sendpredictor(ciris, 0) + sendpredictor(riris, 1) + self.assertFalse(colhidden(0)) + self.assertTrue(colhidden(1)) + self.assertFalse(colhidden(2)) + self.assertTrue(colhidden(3)) + self.assertIsInstance(delegate(1), NoopItemDelegate) + self.assertIsInstance(delegate(3), NoopItemDelegate) + + senddata(regiris) + self.assertFalse(colhidden(0)) + self.assertTrue(colhidden(1)) + self.assertFalse(colhidden(2)) + self.assertFalse(colhidden(3)) + self.assertIsInstance(delegate(1), NoopItemDelegate) + self.assertIsInstance(delegate(3), RegressionErrorDelegate) + + err_combo = self.widget.controls.show_reg_errors + err_combo.setCurrentIndex(0) + err_combo.activated.emit(0) + self.assertTrue(colhidden(1)) + self.assertTrue(colhidden(3)) + self.assertIsInstance(delegate(1), NoopItemDelegate) + self.assertIsInstance(delegate(3), (RegressionErrorDelegate, + NoopItemDelegate)) + + senddata(iris) + self.assertFalse(colhidden(1)) + self.assertTrue(colhidden(3)) + self.assertIsInstance(delegate(1), ClassificationErrorDelegate) + self.assertIsInstance(delegate(3), NoopItemDelegate) + + err_box = self.widget.controls.show_probability_errors + err_box.click() + self.assertTrue(colhidden(1)) + self.assertIsInstance(delegate(1), (ClassificationErrorDelegate, + NoopItemDelegate)) + self.assertIsInstance(delegate(3), NoopItemDelegate) + + def test_regression_error_delegate_ranges(self): + def set_type(tpe): + combo = widget.controls.show_reg_errors + combo.setCurrentIndex(tpe) + combo.activated.emit(tpe) + + def get_delegate() -> Optional[RegressionErrorDelegate]: + return widget.predictionsview.itemDelegateForColumn(1) + + widget = self.widget + domain = Domain([ContinuousVariable("x")], + ContinuousVariable("y")) + data = Table.from_numpy(domain, np.arange(2, 12)[:, None], np.arange(2, 12)) + model = MeanLearner()(data) + model.mean = 5 + self.send_signal(widget.Inputs.data, data) + self.send_signal(widget.Inputs.predictors, model, 0) + + set_type(NO_ERR) + self.assertIsInstance(get_delegate(), NoopItemDelegate) + + set_type(DIFF_ERROR) + delegate = get_delegate() + self.assertEqual(delegate.span, 6) + self.assertTrue(delegate.centered) + + set_type(ABSDIFF_ERROR) + delegate = get_delegate() + self.assertEqual(delegate.span, 6) + self.assertFalse(delegate.centered) + + set_type(REL_ERROR) + delegate = get_delegate() + self.assertEqual(delegate.span, max(3 / 2, 6 / 11)) + self.assertTrue(delegate.centered) + + set_type(ABSREL_ERROR) + delegate = get_delegate() + self.assertEqual(delegate.span, max(3 / 2, 6 / 11)) + self.assertFalse(delegate.centered) + + def test_regression_error_no_model(self): + data = self.housing[:5] + self.send_signal(self.widget.Inputs.data, data) + combo = self.widget.controls.show_reg_errors + with excepthook_catch(raise_on_exit=True): + simulate.combobox_activate_index(combo, 1) + + def test_report(self): + widget = self.widget + + log_reg_iris = LogisticRegressionLearner()(self.iris) + self.send_signal(widget.Inputs.predictors, log_reg_iris) + self.send_signal(widget.Inputs.data, self.iris) + + widget.report_paragraph = Mock() + reports = set() + for widget.shown_probs in range(len(widget.PROB_OPTS)): + widget.send_report() + reports.add(widget.report_paragraph.call_args[0][1]) + self.assertEqual(len(reports), len(widget.PROB_OPTS)) + + for widget.shown_probs, value in enumerate( + widget.class_var.values, start=widget.shown_probs + 1): + widget.send_report() + self.assertIn(value, widget.report_paragraph.call_args[0][1]) + + def test_migrate_shown_scores(self): + settings = {"score_table": {"shown_scores": {"Sensitivity"}}} + self.widget.migrate_settings(settings, 1) + self.assertTrue(settings["score_table"]["show_score_hints"]["Sensitivity"]) + + def test_migrate_context_2_3(self): + settings = { + 'controlAreaVisible': True, 'selection': [], + 'show_scores': True, + 'score_table': { + 'show_score_hints': { + 'Model_': True, 'Train_': False, 'Test_': False, + 'CA': True, 'PrecisionRecallFSupport': True, + 'TargetScore': True, 'Precision': True, + 'Recall': True, 'F1': True, 'AUC': True, + 'LogLoss': False, 'Specificity': False, + 'MatthewsCorrCoefficient': True, 'MSE': True, + 'RMSE': True, 'MAE': True, 'MAPE': True, 'R2': True, + 'CVRMSE': False, 'ClusteringScore': True, + 'Silhouette': True, 'AdjustedMutualInfoScore': True}}, + 'context_settings': [ + Context( + classes=('Iris-setosa', + 'Iris-versicolor', + 'Iris-virginica'), + values={'show_probability_errors': True, + 'show_reg_errors': 1, + 'shown_probs': 1, + 'score_table': {}, + 'target_class': '(Average over classes)', + '__version__': 2})], + '__version__': 2, + } + + widget = self.create_widget(OWPredictions, stored_settings=settings) + self.send_signal(widget.Inputs.data, self.iris) + self.assertEqual(widget.target_class, "") + + settings["context_settings"][0].values["target_class"] = "Iris-versicolor" + settings["__version__"] = 2 + widget = self.create_widget(OWPredictions, stored_settings=settings) + self.send_signal(widget.Inputs.data, self.iris) + self.assertEqual(widget.target_class, "Iris-versicolor") + + # Test fallback for older workflows opened in wrong language + settings["context_settings"][0].values["target_class"] = "Povprečje" + settings["__version__"] = 2 + widget = self.create_widget(OWPredictions, stored_settings=settings) + self.send_signal(widget.Inputs.data, self.iris) + self.assertEqual(widget.target_class, "") + + + def test_output_error_reg(self): + data = self.housing + lin_reg = LinearRegressionLearner() + self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.predictors, lin_reg(data), 0) + self.send_signal(self.widget.Inputs.predictors, + LinearRegressionLearner(fit_intercept=False)(data), 1) + pred = self.get_output(self.widget.Outputs.selected_predictions) + + names = ["", " (error)"] + names = [f"{n}{i}" for i in ("", " (1)") for n in names] + names = [f"{lin_reg.name}{x}" for x in names] + self.assertEqual(names, [m.name for m in pred.domain.metas]) + self.assertAlmostEqual(pred.metas[0, 1], 6.0, 1) + self.assertAlmostEqual(pred.metas[0, 3], 5.1, 1) + + def test_output_error_cls(self): + data = self.iris + log_reg = LogisticRegressionLearner() + self.send_signal(self.widget.Inputs.predictors, log_reg(data), 0) + self.send_signal(self.widget.Inputs.predictors, + LogisticRegressionLearner(penalty="l1", max_iter=1000)(data), 1) + with data.unlocked(data.Y): + data.Y[1] = np.nan + self.send_signal(self.widget.Inputs.data, data) + pred = self.get_output(self.widget.Outputs.selected_predictions) + + names = [""] + [f" ({v})" for v in + list(data.domain.class_var.values) + ["error"]] + names = [f"{n}{i}" for i in ("", " (1)") for n in names] + names = [f"{log_reg.name}{x}" for x in names] + self.assertEqual(names, [m.name for m in pred.domain.metas]) + self.assertAlmostEqual(pred.metas[0, 4], 0.018, 3) + self.assertAlmostEqual(pred.metas[0, 9], 0.008, 3) + self.assertTrue(np.isnan(pred.metas[1, 4])) + self.assertTrue(np.isnan(pred.metas[1, 9])) + + def test_multiple_targets_pls(self): + class_vars = [self.housing.domain.class_var, + self.housing.domain.attributes[0]] + domain = Domain(self.housing.domain.attributes[1:], + class_vars=class_vars) + multiple_targets_data = self.housing.transform(domain) + + self.send_signal(self.widget.Inputs.data, multiple_targets_data) + self.send_signal(self.widget.Inputs.predictors, + PLSRegressionLearner()(multiple_targets_data)) + self.assertTrue(self.widget.Error.predictor_failed.is_shown()) + self.assertIn("Multiple targets are not supported.", + str(self.widget.Error.predictor_failed)) + + self.send_signal(self.widget.Inputs.data, None) + self.send_signal(self.widget.Inputs.predictors, None) + + self.send_signal(self.widget.Inputs.data, self.housing) + self.send_signal(self.widget.Inputs.predictors, + PLSRegressionLearner()(self.housing)) + self.assertFalse(self.widget.Error.predictor_failed.is_shown()) class SelectionModelTest(unittest.TestCase): def setUp(self): - self.sourceModel1 = QStandardItemModel(5, 2) - self.proxyModel1 = SortProxyModel() - self.proxyModel1.setSourceModel(self.sourceModel1) - self.store = SharedSelectionStore(self.proxyModel1) - self.model1 = SharedSelectionModel(self.store, self.proxyModel1, None) + iris = Table("iris") - self.sourceModel2 = QStandardItemModel(5, 3) - self.proxyModel2 = SortProxyModel() - self.proxyModel2.setSourceModel(self.sourceModel2) - self.model2 = SharedSelectionModel(self.store, self.proxyModel2, None) + self.datamodel1 = DataModel(iris[:5, :2]) + self.store = SharedSelectionStore(self.datamodel1) + self.model1 = SharedSelectionModel(self.store, self.datamodel1, None) + + self.datamodel2 = DataModel(iris[-5:, :3]) + self.model2 = SharedSelectionModel(self.store, self.datamodel2, None) def itsel(self, rows): sel = QItemSelection() for row in rows: - index = self.store.proxy.index(row, 0) + index = self.store.model.index(row, 0) sel.select(index, index) return sel @@ -512,33 +1429,33 @@ def test_select_rows(self): store = self.store store.select_rows({1, 2}, QItemSelectionModel.Select) self.assertEqual(store.rows, {1, 2}) - emit1.assert_called_with({1, 2}, set()) - emit2.assert_called_with({1, 2}, set()) + emit1.assert_called_with([1, 2], []) + emit2.assert_called_with([1, 2], []) store.select_rows({1, 2, 4}, QItemSelectionModel.Select) self.assertEqual(store.rows, {1, 2, 4}) - emit1.assert_called_with({4}, set()) - emit2.assert_called_with({4}, set()) + emit1.assert_called_with([4], []) + emit2.assert_called_with([4], []) store.select_rows({3, 4}, QItemSelectionModel.Toggle) self.assertEqual(store.rows, {1, 2, 3}) - emit1.assert_called_with({3}, {4}) - emit2.assert_called_with({3}, {4}) + emit1.assert_called_with([3], [4]) + emit2.assert_called_with([3], [4]) store.select_rows({0, 2}, QItemSelectionModel.Deselect) self.assertEqual(store.rows, {1, 3}) - emit1.assert_called_with(set(), {2}) - emit2.assert_called_with(set(), {2}) + emit1.assert_called_with([], [2]) + emit2.assert_called_with([], [2]) store.select_rows({2, 3, 4}, QItemSelectionModel.ClearAndSelect) self.assertEqual(store.rows, {2, 3, 4}) - emit1.assert_called_with({2, 4}, {1}) - emit2.assert_called_with({2, 4}, {1}) + emit1.assert_called_with([2, 4], [1]) + emit2.assert_called_with([2, 4], [1]) store.select_rows({2, 3, 4}, QItemSelectionModel.Clear) self.assertEqual(store.rows, set()) - emit1.assert_called_with(set(), {2, 3, 4}) - emit2.assert_called_with(set(), {2, 3, 4}) + emit1.assert_called_with([], [2, 3, 4]) + emit2.assert_called_with([], [2, 3, 4]) store.select_rows({2, 3, 4}, QItemSelectionModel.ClearAndSelect) emit1.reset_mock() @@ -552,13 +1469,13 @@ def test_select_maps_from_proxy(self): store.select_rows = Mock() # Map QItemSelection - store.proxy.setSortIndices([1, 2, 3, 4, 0]) + store.model.setSortIndices(np.array([4, 0, 1, 2, 3])) store.select(self.itsel([1, 2]), QItemSelectionModel.Select) store.select_rows.assert_called_with({0, 1}, QItemSelectionModel.Select) # Map QModelIndex - store.proxy.setSortIndices([1, 2, 3, 4, 0]) - store.select(store.proxy.index(0, 0), QItemSelectionModel.Select) + store.model.setSortIndices(np.array([4, 0, 1, 2, 3])) + store.select(store.model.index(0, 0), QItemSelectionModel.Select) store.select_rows.assert_called_with({4}, QItemSelectionModel.Select) # Map empty selection @@ -576,8 +1493,8 @@ def test_clear(self): store.clear_selection() self.assertEqual(store.rows, set()) - emit1.assert_called_with(set(), {1, 2, 4}) - emit2.assert_called_with(set(), {1, 2, 4}) + emit1.assert_called_with([], [1, 2, 4]) + emit2.assert_called_with([], [1, 2, 4]) def test_reset(self): store = self.store @@ -596,20 +1513,26 @@ def test_reset(self): def test_emit_changed_maps_to_proxy(self): store = self.store emit1 = self.model1.emit_selection_rows_changed = Mock() + self.model2.emit_selection_rows_changed = Mock() + + def assert_called(exp_selected, exp_deselected): + # pylint: disable=unsubscriptable-object + selected, deselected = emit1.call_args[0] + self.assertEqual(list(selected), exp_selected) + self.assertEqual(list(deselected), exp_deselected) - store.proxy.setSortIndices([1, 2, 3, 4, 0]) + store.model.setSortIndices([4, 0, 1, 2, 3]) store.select_rows({3, 4}, QItemSelectionModel.Select) - emit1.assert_called_with({4, 0}, set()) + assert_called([4, 0], []) - store.proxy.setSortIndices(None) + store.model.setSortIndices(None) store.select_rows({4}, QItemSelectionModel.Deselect) - emit1.assert_called_with(set(), {0}) + assert_called([], [4]) - store.proxy.setSortIndices([1, 0, 3, 4, 2]) - store.proxy.setSortIndices(None) - self.proxyModel1.sort(-1) + store.model.setSortIndices([1, 0, 3, 4, 2]) + store.model.setSortIndices(None) store.select_rows({2, 3}, QItemSelectionModel.Deselect) - emit1.assert_called_with(set(), {3}) + assert_called([], [3]) class SharedSelectionModelTest(SelectionModelTest): @@ -623,9 +1546,9 @@ def test_selection_from_rows(self): sel = self.model1.selection_from_rows({1, 2}) self.assertEqual(len(sel), 2) ind1 = sel[0] - self.assertIs(ind1.model(), self.proxyModel1) + self.assertIs(ind1.model(), self.model1.model()) self.assertEqual(ind1.left(), 0) - self.assertEqual(ind1.right(), self.proxyModel1.columnCount() - 1) + self.assertEqual(ind1.right(), self.model1.model().columnCount() - 1) self.assertEqual(ind1.top(), 1) self.assertEqual(ind1.bottom(), 1) @@ -634,25 +1557,26 @@ def test_emit_selection_rows_changed(self): m2 = Mock() self.model1.selectionChanged.connect(m1) self.model2.selectionChanged.connect(m2) - self.proxyModel1.setSortIndices([1, 0, 2, 4, 3]) + self.model1.model().setSortIndices(np.array([1, 0, 2, 4, 3])) self.model1.select(self.itsel({1, 3}), QItemSelectionModel.Select) self.assertEqual(self.store.rows, {0, 4}) - for model, m in zip((self.proxyModel1, self.proxyModel2), (m1, m2)): + for model, m in zip((self.model1, self.model2), (m1, m2)): sel = m.call_args[0][0] self.assertEqual(len(sel), 2) for ind, row in zip(sel, (1, 3)): - self.assertIs(ind.model(), model) + self.assertIs(ind.model(), model.model()) self.assertEqual(ind.left(), 0) - self.assertEqual(ind.right(), model.columnCount() - 1) + self.assertEqual(ind.right(), model.model().columnCount() - 1) self.assertEqual(ind.top(), row) self.assertEqual(ind.bottom(), row) def test_methods(self): def rowcol(sel): return {(index.row(), index.column()) for index in sel} - self.proxyModel1.setSortIndices([1, 0, 2, 4, 3]) - self.proxyModel2.setSortIndices([1, 0, 2, 4, 3]) + + self.model1.model().setSortIndices(np.array([1, 0, 2, 4, 3])) + self.model2.model().setSortIndices(np.array([1, 0, 2, 4, 3])) self.assertFalse(self.model1.hasSelection()) self.assertFalse(self.model1.isColumnSelected(1)) @@ -699,5 +1623,530 @@ def rowcol(sel): self.assertEqual(self.model1.selectedIndexes(), []) +class PredictionsModelTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.values = np.array([[0, 1, 1, 2, 0], [0, 0, 0, 1, 0]], dtype=float) + cls.actual = np.array([0, 1, 2, 1, 0], dtype=float) + cls.probs = [np.array([[80, 10, 10], + [30, 70, 0], + [15, 80, 5], + [0, 10, 90], + [55, 40, 5]]) / 100, + np.array([[80, 0, 20], + [90, 5, 5], + [70, 10, 20], + [10, 60, 30], + [50, 25, 25]]) / 100] + cls.no_probs = [np.zeros((5, 0)), np.zeros((5, 0))] + + def test_model_classification(self): + model = PredictionsModel(self.values, self.probs, self.actual) + self.assertEqual(model.rowCount(), 5) + self.assertEqual(model.columnCount(), 4) + + val, prob = model.data(model.index(0, 2)) + self.assertEqual(val, 0) + np.testing.assert_equal(prob, [0.8, 0, 0.2]) + + val, prob = model.data(model.index(3, 2)) + self.assertEqual(val, 1) + np.testing.assert_equal(prob, [0.1, 0.6, 0.3]) + + def test_model_classification_errors(self): + model = PredictionsModel(self.values, self.probs, self.actual) + + np.testing.assert_almost_equal( + [model.data(model.index(row, 1)) for row in range(5)], + 1 - np.array([80, 70, 5, 10, 55]) / 100) + + np.testing.assert_almost_equal( + [model.data(model.index(row, 3)) for row in range(5)], + 1 - np.array([80, 5, 20, 60, 50]) / 100) + + def test_model_regression(self): + model = PredictionsModel(self.values, self.no_probs, self.actual) + self.assertEqual(model.rowCount(), 5) + self.assertEqual(model.columnCount(), 4) + + val, prob = model.data(model.index(0, 2)) + self.assertEqual(val, 0) + np.testing.assert_equal(prob, []) + + val, prob = model.data(model.index(3, 2)) + self.assertEqual(val, 1) + np.testing.assert_equal(prob, []) + + def test_model_regression_errors(self): + actual = np.array([40, 0, 12, 0, -45]) + model = PredictionsModel(values=np.array([[0] * 5, + [30, 0, 12, -5, -40]]), + probs=self.no_probs, + actual=actual, + reg_error_type=NO_ERR) + + self.assertIsNone(model.data(model.index(0, 1))) + + model.setRegErrorType(DIFF_ERROR) + diff_error = np.array([-10, 0, 0, -5, 5]) + np.testing.assert_almost_equal( + [model.data(model.index(row, 3)) for row in range(5)], + diff_error) + np.testing.assert_almost_equal(model.errorColumn(1), diff_error) + + model.setRegErrorType(ABSDIFF_ERROR) + np.testing.assert_almost_equal( + [model.data(model.index(row, 3)) for row in range(5)], + np.abs(diff_error)) + np.testing.assert_almost_equal(model.errorColumn(1), np.abs(diff_error)) + + model.setRegErrorType(REL_ERROR) + rel_error = [-10 / 40, 0, 0, -np.inf, 5 / 45] + np.testing.assert_almost_equal( + [model.data(model.index(row, 3)) for row in range(5)], rel_error) + np.testing.assert_almost_equal(model.errorColumn(1), rel_error) + + model.setRegErrorType(ABSREL_ERROR) + np.testing.assert_almost_equal( + [model.data(model.index(row, 3)) for row in range(5)], np.abs(rel_error)) + np.testing.assert_almost_equal(model.errorColumn(1), np.abs(rel_error)) + + model.setRegErrorType(DIFF_ERROR) + np.testing.assert_almost_equal( + [model.data(model.index(row, 1)) for row in range(5)], -actual) + np.testing.assert_almost_equal(model.errorColumn(0), -actual) + + def test_model_actual(self): + model = PredictionsModel(self.values, self.no_probs, self.actual) + self.assertEqual(model.data(model.index(2, 0), Qt.UserRole), + self.actual[2]) + + def test_model_no_actual(self): + model = PredictionsModel(self.values, self.no_probs, None) + self.assertTrue(np.isnan(model.data(model.index(2, 0), Qt.UserRole)), + self.actual[2]) + + def test_model_header(self): + model = PredictionsModel(self.values, self.probs, self.actual) + self.assertIsNone(model.headerData(0, Qt.Horizontal)) + self.assertEqual(model.headerData(3, Qt.Vertical), "4") + + model = PredictionsModel(self.values, self.probs, self.actual, ["a", "b"]) + self.assertEqual(model.headerData(0, Qt.Horizontal), "a") + self.assertEqual(model.headerData(1, Qt.Horizontal), "error") + self.assertEqual(model.headerData(2, Qt.Horizontal), "b") + self.assertEqual(model.headerData(3, Qt.Horizontal), "error") + self.assertIsNone(model.headerData(4, Qt.Horizontal)) + self.assertEqual(model.headerData(4, Qt.Vertical), "5") + + model = PredictionsModel(self.values, self.probs, self.actual, ["a"]) + self.assertEqual(model.headerData(0, Qt.Horizontal), "a") + self.assertEqual(model.headerData(1, Qt.Horizontal), "error") + self.assertIsNone(model.headerData(2, Qt.Horizontal)) + self.assertEqual(model.headerData(3, Qt.Vertical), "4") + + def test_model_empty(self): + model = PredictionsModel() + self.assertEqual(model.rowCount(), 0) + self.assertEqual(model.columnCount(), 0) + self.assertIsNone(model.headerData(1, Qt.Horizontal)) + + def test_sorting_classification(self): + model = PredictionsModel(self.values, self.probs, self.actual) + + val, prob = model.data(model.index(0, 2)) + self.assertEqual(val, 0) + np.testing.assert_equal(prob, [0.8, 0, 0.2]) + + val, prob = model.data(model.index(3, 2)) + self.assertEqual(val, 1) + np.testing.assert_equal(prob, [0.1, 0.6, 0.3]) + + model.setProbInd([[2], [2]]) + model.sort(0, Qt.DescendingOrder) + val, prob = model.data(model.index(0, 0)) + self.assertEqual(val, 2) + np.testing.assert_equal(prob, [0, 0.1, 0.9]) + val, prob = model.data(model.index(0, 2)) + self.assertEqual(val, 1) + np.testing.assert_equal(prob, [0.1, 0.6, 0.3]) + + model.setProbInd([[2], [2]]) + model.sort(2, Qt.AscendingOrder) + val, prob = model.data(model.index(0, 2)) + self.assertEqual(val, 0) + np.testing.assert_equal(prob, [0.9, 0.05, 0.05]) + val, prob = model.data(model.index(0, 0)) + self.assertEqual(val, 1) + np.testing.assert_equal(prob, [0.3, 0.7, 0]) + + model.setProbInd([[1, 0], [1, 0]]) + model.sort(0, Qt.AscendingOrder) + np.testing.assert_equal(model.data(model.index(0, 0))[1], [0, .1, .9]) + np.testing.assert_equal(model.data(model.index(1, 0))[1], [0.8, .1, .1]) + + model.setProbInd([[1, 2], [1, 2]]) + model.sort(0, Qt.AscendingOrder) + np.testing.assert_equal(model.data(model.index(0, 0))[1], [0.8, .1, .1]) + np.testing.assert_equal(model.data(model.index(1, 0))[1], [0, .1, .9]) + + model.setProbInd([[], []]) + model.sort(0, Qt.AscendingOrder) + self.assertEqual([model.data(model.index(i, 0))[0] + for i in range(model.rowCount())], [0, 0, 1, 1, 2]) + + model.setProbInd([[], []]) + model.sort(0, Qt.DescendingOrder) + self.assertEqual([model.data(model.index(i, 0))[0] + for i in range(model.rowCount())], [2, 1, 1, 0, 0]) + + def test_sorting_classification_error(self): + model = PredictionsModel(self.values, self.probs, self.actual) + + np.testing.assert_almost_equal( + [model.data(model.index(row, 1)) for row in range(5)], + 1 - np.array([80, 70, 5, 10, 55]) / 100) + + np.testing.assert_almost_equal( + [model.data(model.index(row, 3)) for row in range(5)], + 1 - np.array([80, 5, 20, 60, 50]) / 100) + + model.sort(1, Qt.AscendingOrder) + np.testing.assert_almost_equal( + [model.data(model.index(row, 1)) for row in range(5)], + 1 - np.array(sorted([80, 70, 5, 10, 55], reverse=True)) / 100) + + model.sort(3, Qt.DescendingOrder) + np.testing.assert_almost_equal( + [model.data(model.index(row, 3)) for row in range(5)], + 1 - np.array(sorted([80, 5, 20, 60, 50])) / 100) + + # Numpy's sort puts nan's at the end, and the widget counts on it + # because we want to show them last. If this test fails, this + # (undocumented) numpy's behavior has changed, and the widget needs + # to be updated. + data = list(range(10)) + [np.nan] * 10 + copy = data.copy() + for _ in range(10): + random.shuffle(data) + np.testing.assert_equal(np.sort(data), copy) + + def test_sorting_classification_different(self): + model = PredictionsModel(self.values, self.probs, self.actual) + + model.setProbInd([[2], [0]]) + model.sort(0, Qt.DescendingOrder) + val, prob = model.data(model.index(0, 0)) + self.assertEqual(val, 2) + np.testing.assert_equal(prob, [0, 0.1, 0.9]) + val, prob = model.data(model.index(0, 2)) + self.assertEqual(val, 1) + np.testing.assert_equal(prob, [0.1, 0.6, 0.3]) + model.sort(2, Qt.DescendingOrder) + val, prob = model.data(model.index(0, 0)) + self.assertEqual(val, 1) + np.testing.assert_equal(prob, [0.3, 0.7, 0]) + val, prob = model.data(model.index(0, 2)) + self.assertEqual(val, 0) + np.testing.assert_equal(prob, [0.9, 0.05, 0.05]) + + def test_sorting_regression(self): + model = PredictionsModel(self.values, self.no_probs, self.actual) + + self.assertEqual(model.data(model.index(0, 2))[0], 0) + self.assertEqual(model.data(model.index(3, 2))[0], 1) + + model.setProbInd([2]) + model.sort(0, Qt.AscendingOrder) + self.assertEqual([model.data(model.index(i, 0))[0] + for i in range(model.rowCount())], [0, 0, 1, 1, 2]) + + model.setProbInd([]) + model.sort(0, Qt.DescendingOrder) + self.assertEqual([model.data(model.index(i, 0))[0] + for i in range(model.rowCount())], [2, 1, 1, 0, 0]) + + model.setProbInd(None) + model.sort(0, Qt.AscendingOrder) + self.assertEqual([model.data(model.index(i, 0))[0] + for i in range(model.rowCount())], [0, 0, 1, 1, 2]) + + def test_sorting_regression_error(self): + actual = np.array([40, 0, 12, 0, -45]) + model = PredictionsModel(values=np.array([[30, 0, 12, -5, -40]]), + probs=self.no_probs[:1], + actual=actual, + reg_error_type=NO_ERR) + + model.setRegErrorType(DIFF_ERROR) + model.sort(1, Qt.AscendingOrder) + diff_error = [-10, 0, 0, -5, 5] + np.testing.assert_almost_equal( + [model.data(model.index(row, 1)) for row in range(5)], + sorted(diff_error)) + + model.setRegErrorType(ABSDIFF_ERROR) + model.sort(1, Qt.AscendingOrder) + np.testing.assert_almost_equal( + [model.data(model.index(row, 1)) for row in range(5)], + sorted(np.abs(diff_error))) + + model.setRegErrorType(REL_ERROR) + rel_error = [-10 / 40, 0, 0, -np.inf, 5 / 45] + model.sort(1, Qt.AscendingOrder) + np.testing.assert_almost_equal( + [model.data(model.index(row, 1)) for row in range(5)], + sorted(rel_error)) + + model.setRegErrorType(ABSREL_ERROR) + model.sort(1, Qt.AscendingOrder) + np.testing.assert_almost_equal( + [model.data(model.index(row, 1)) for row in range(5)], + sorted(np.abs(rel_error))) + + +class TestPredictionsItemDelegate(GuiTest): + def test_displayText(self): + delegate = PredictionsItemDelegate() + delegate.fmt = "{value:.3f}" + self.assertEqual(delegate.displayText((0.12345, [1, 2, 3]), Mock()), "0.123") + delegate.fmt = "{value:.1f}" + self.assertEqual(delegate.displayText((0.12345, [1, 2, 3]), Mock()), "0.1") + delegate.fmt = "{value:.1f} - {dist[2]}" + self.assertEqual(delegate.displayText((0.12345, [1, 2, 3]), Mock()), "0.1 - 3") + + self.assertEqual(delegate.displayText(None, Mock()), "") + + +class TestClassificationItemDelegate(GuiTest): + @patch.object(QToolTip, "showText") + def test_format(self, showText): + delegate = ClassificationItemDelegate(["foo", "bar", "baz"], + [(1, 2, 3), (4, 5, 6), (7, 8, 9)], + (1, None, 0), ("foo", "baz") + ) + delegate.helpEvent(Mock(), Mock(), Mock(), Mock()) + self.assertEqual(showText.call_args[0][1], "p(foo, baz)") + self.assertEqual(delegate.displayText((["baz", (0.4, 0.6)]), Mock()), + "0.60 : - : 0.40 → baz") + showText.reset_mock() + + delegate = ClassificationItemDelegate(["foo", "bar", "baz"], + [(1, 2, 3), (4, 5, 6), (7, 8, 9)], + ) + delegate.helpEvent(Mock(), Mock(), Mock(), Mock()) + self.assertEqual(showText.call_args[0][1], "") + self.assertEqual(delegate.displayText((["baz", (0.4, 0.6)]), Mock()), + "baz") + + def test_drawbar(self): + delegate = ClassificationItemDelegate( + ["foo", "bar", "baz", "bax"], + [(1, 2, 3), (4, 5, 6), (7, 8, 9), (10, 11, 12)], + (1, None, 0, 2), ("baz", "foo", "bax")) + painter = Mock() + rr = painter.drawRoundedRect + index = Mock() + index.data = lambda *_: 2 + rect = QRect(0, 0, 256, 16) + + delegate.cachedData = lambda *_: None + delegate.drawBar(painter, Mock(), index, rect) + rr.assert_not_called() + + delegate.cachedData = lambda *_: (1, (0.25, 0, 0.75, 0)) + delegate.drawBar(painter, Mock(), index, rect) + self.assertEqual(rr.call_count, 2) + rect = rr.call_args_list[0][0][0] + self.assertEqual(rect.width(), 64) + self.assertEqual(rect.height(), 8) + rect = rr.call_args_list[1][0][0] + self.assertEqual(rect.width(), 192) + self.assertEqual(rect.height(), 16) + + +class TestNoopItemDelegate(GuiTest): + def test_donothing(self): + delegate = NoopItemDelegate() + delegate.paint(Mock(), Mock(), Mock(), Mock()) + delegate.sizeHint() + + +class TestRegressionItemDelegate(GuiTest): + def test_format(self): + delegate = RegressionItemDelegate("%6.3f") + self.assertEqual(delegate.displayText((5.13, None), Mock()), " 5.130") + self.assertEqual(delegate.offset, 0) + self.assertEqual(delegate.span, 1) + + delegate = RegressionItemDelegate("%6.3f", 2, 5) + self.assertEqual(delegate.displayText((5.13, None), Mock()), " 5.130") + self.assertEqual(delegate.offset, 2) + self.assertEqual(delegate.span, 3) + + delegate = RegressionItemDelegate(None, 2, 5) + self.assertEqual(delegate.displayText((5.1, None), Mock()), "5.10") + self.assertEqual(delegate.offset, 2) + self.assertEqual(delegate.span, 3) + + def test_drawBar(self): + delegate = RegressionItemDelegate("%6.3f", 2, 10) + painter = Mock() + dr = painter.drawRect + el = painter.drawEllipse + index = Mock() + rect = QRect(0, 0, 256, 16) + + ### Actual is missing + index.data = lambda *_: np.nan + + # Prediction is missing + delegate.cachedData = lambda *_: None + delegate.drawBar(painter, Mock(), index, rect) + + dr.assert_not_called() + el.assert_not_called() + + # Prediction is known + delegate.cachedData = lambda *_: (8.0, None) + delegate.drawBar(painter, Mock(), index, rect) + + dr.assert_called_once() + rrect = dr.call_args[0][0] + self.assertEqual(rrect.width(), 192) + el.assert_not_called() + dr.reset_mock() + + ### Actual is known + index.data = lambda *_: 8.0 + + # Prediction is correct + delegate.cachedData = lambda *_: (8.0, None) + delegate.drawBar(painter, Mock(), index, rect) + + dr.assert_called_once() + rrect = dr.call_args[0][0] + self.assertEqual(rrect.width(), 192) + el.assert_called_once() + center = el.call_args[0][0] + self.assertEqual(center.x(), 192) + dr.reset_mock() + el.reset_mock() + + # Prediction is below + delegate.cachedData = lambda *_: (6.0, None) + delegate.drawBar(painter, Mock(), index, rect) + + dr.assert_called_once() + rrect = dr.call_args[0][0] + self.assertEqual(rrect.width(), 128) + el.assert_called_once() + center = el.call_args[0][0] + self.assertEqual(center.x(), 192) + dr.reset_mock() + el.reset_mock() + + # Prediction is above + delegate.cachedData = lambda *_: (9.0, None) + delegate.drawBar(painter, Mock(), index, rect) + + dr.assert_called_once() + rrect = dr.call_args[0][0] + self.assertEqual(rrect.width(), 224) + el.assert_called_once() + center = el.call_args[0][0] + self.assertEqual(center.x(), 192) + dr.reset_mock() + el.reset_mock() + + +class TestClassificationErrorDelegate(GuiTest): + def test_displayText(self): + delegate = ClassificationErrorDelegate() + self.assertEqual(delegate.displayText(0.12345, Mock()), "0.123") + self.assertEqual(delegate.displayText(np.nan, Mock()), "?") + + def test_drawBar(self): + delegate = ClassificationErrorDelegate() + painter = Mock() + dr = painter.drawRect + index = Mock() + rect = QRect(0, 0, 256, 16) + + delegate.cachedData = lambda *_: np.nan + delegate.drawBar(painter, Mock(), index, rect) + dr.assert_not_called() + + delegate.cachedData = lambda *_: None + delegate.drawBar(painter, Mock(), index, rect) + dr.assert_not_called() + + delegate.cachedData = lambda *_: 1 / 4 + delegate.drawBar(painter, Mock(), index, rect) + dr.assert_called_once() + r = dr.call_args[0][0] + self.assertEqual(r.x(), 0) + self.assertEqual(r.y(), 0) + self.assertEqual(r.width(), 64) + self.assertEqual(r.height(), 16) + + +class TestRegressionErrorDelegate(GuiTest): + def test_displayText(self): + delegate = RegressionErrorDelegate("", True, 4) + self.assertEqual(delegate.displayText(0.1234567, Mock()), "") + + delegate = RegressionErrorDelegate("%.5f", True, 4) + self.assertEqual(delegate.displayText(0.1234567, Mock()), "0.12346") + self.assertEqual(delegate.displayText(np.nan, Mock()), "?") + self.assertEqual(delegate.displayText(np.inf, Mock()), "∞") + self.assertEqual(delegate.displayText(-np.inf, Mock()), "-∞") + + def test_drawBar(self): + painter = Mock() + dr = painter.drawRect + index = Mock() + rect = QRect(0, 0, 256, 16) + + delegate = RegressionErrorDelegate("%.5f", True, 0) + delegate.drawBar(painter, Mock(), index, rect) + dr.assert_not_called() + + delegate = RegressionErrorDelegate("%.5f", True, 12) + + delegate.cachedData = lambda *_: np.nan + delegate.drawBar(painter, Mock(), index, rect) + dr.assert_not_called() + + delegate.cachedData = lambda *_: None + delegate.drawBar(painter, Mock(), index, rect) + dr.assert_not_called() + + delegate.cachedData = lambda *_: 3 + delegate.drawBar(painter, Mock(), index, rect) + r = dr.call_args[0][0] + self.assertEqual(r.x(), 128) + self.assertEqual(r.y(), 0) + self.assertEqual(r.width(), 32) + self.assertEqual(r.height(), 16) + + delegate.cachedData = lambda *_: -3 + delegate.drawBar(painter, Mock(), index, rect) + r = dr.call_args[0][0] + self.assertEqual(r.x(), 128) + self.assertEqual(r.y(), 0) + self.assertEqual(r.width(), -32) + self.assertEqual(r.height(), 16) + + delegate = RegressionErrorDelegate("%.5f", False, 12) + delegate.cachedData = lambda *_: 3 + delegate.drawBar(painter, Mock(), index, rect) + r = dr.call_args[0][0] + self.assertEqual(r.x(), 0) + self.assertEqual(r.y(), 0) + self.assertEqual(r.width(), 64) + self.assertEqual(r.height(), 16) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/evaluate/tests/test_owrocanalysis.py b/Orange/widgets/evaluate/tests/test_owrocanalysis.py index 54a2476a975..19dfe164194 100644 --- a/Orange/widgets/evaluate/tests/test_owrocanalysis.py +++ b/Orange/widgets/evaluate/tests/test_owrocanalysis.py @@ -1,20 +1,21 @@ # pylint: disable=protected-access - -import unittest -from unittest.mock import patch import copy +import unittest +from unittest.mock import patch, Mock + import numpy as np import pyqtgraph as pg from AnyQt.QtWidgets import QToolTip +from AnyQt.QtCore import QItemSelection from Orange.data import Table import Orange.evaluation import Orange.classification +from Orange.evaluation import Results from Orange.widgets.evaluate import owrocanalysis from Orange.widgets.evaluate.owrocanalysis import OWROCAnalysis from Orange.widgets.evaluate.tests.base import EvaluateTest -from Orange.widgets.tests.base import WidgetTest from Orange.widgets.tests.utils import mouseMove, simulate from Orange.tests import test_filename @@ -74,7 +75,7 @@ def test_ROCData_from_results(self): self.assertFalse(rocdata.avg_threshold.is_valid) -class TestOWROCAnalysis(WidgetTest, EvaluateTest): +class TestOWROCAnalysis(EvaluateTest): @classmethod def setUpClass(cls): super().setUpClass() @@ -106,10 +107,19 @@ def setUp(self): ) # type: OWROCAnalysis def tearDown(self): - super().tearDown() self.widget.onDeleteWidget() self.widgets.remove(self.widget) self.widget = None + super().tearDown() + + @staticmethod + def _set_list_selection(listview, selection): + model = listview.model() + selectionmodel = listview.selectionModel() + itemselection = QItemSelection() + for item in selection: + itemselection.select(model.index(item, 0), model.index(item, 0)) + selectionmodel.select(itemselection, selectionmodel.ClearAndSelect) def test_basic(self): res = self.res @@ -173,16 +183,34 @@ def test_nan_input(self): self.assertFalse(self.widget.Error.invalid_results.is_shown()) def test_tooltips(self): - data_in = Orange.data.Table("titanic") - res = Orange.evaluation.TestOnTrainingData( - store_data=True + # See https://people.inf.elte.hu/kiss/11dwhdm/roc.pdf for the curve + # representing this data + actual = np.array([float(c == "n") for c in "ppnpppnnpnpnpnnnpnpn"]) + p = np.array([.9, .8, .7, .6, .55, .54, .53, .52, .51, .505, + .4, .39, .38, .37, .36, .35, .34, .33, .30, .1]) + n = 1 - p + predicted = (p > .5).astype(float) + + # The second curve is like the first except for the first three points: + # it goes to the right and then up + p2 = p.copy() + p2[:4] = [0.7, 0.8, 0.9, 0.59] + n2 = 1 - p2 + predicted2 = (p2 < .5).astype(float) + + data = Orange.data.Table( + Orange.data.Domain( + [], + [Orange.data.DiscreteVariable("y", values=tuple("pn"))]), + np.empty((len(p), 0), dtype=float), + actual ) - res = res( - data=data_in, - learners=[Orange.classification.KNNLearner(), - Orange.classification.LogisticRegressionLearner()] + res = Results( + data=data, + actual=actual, + predicted=np.array([list(predicted), list(predicted2)]), + probabilities=np.array([list(zip(p, n)), list(zip(p2, n2))]) ) - self.send_signal(self.widget.Inputs.evaluation_results, res) self.widget.roc_averaging = OWROCAnalysis.Merge self.widget.target_index = 0 @@ -203,32 +231,40 @@ def test_tooltips(self): show_text.assert_not_called() # test single point - pos = item.mapToScene(0.22504, 0.45400) + pos = item.mapToScene(0, 0.1) pos = view.mapFromScene(pos) mouseMove(view.viewport(), pos) - (_, text), _ = show_text.call_args - self.assertIn("(#1) 0.400", text) + (_, text, *_), _ = show_text.call_args + self.assertIn("(#1) 0.900", text) + self.assertNotIn("#2", text) # test overlapping points pos = item.mapToScene(0.0, 0.0) pos = view.mapFromScene(pos) mouseMove(view.viewport(), pos) - (_, text), _ = show_text.call_args + (_, text, *_), _ = show_text.call_args self.assertIn("(#1) 1.000\n(#2) 1.000", text) + pos = item.mapToScene(0.1, 0.3) + pos = view.mapFromScene(pos) + mouseMove(view.viewport(), pos) + (_, text, *_), _ = show_text.call_args + self.assertIn("(#1) 0.600\n(#2) 0.590", text) + show_text.reset_mock() + # test that cache is invalidated when changing averaging mode self.widget.roc_averaging = OWROCAnalysis.Threshold self.widget._replot() mouseMove(view.viewport(), pos) - (_, text), _ = show_text.call_args - self.assertIn("(#1) 1.000\n(#2) 1.000", text) + (_, text, *_), _ = show_text.call_args + self.assertIn("(#1) 0.600\n(#2) 0.590", text) + show_text.reset_mock() # test nan thresholds self.widget.roc_averaging = OWROCAnalysis.Vertical self.widget._replot() mouseMove(view.viewport(), pos) - (_, text), _ = show_text.call_args - self.assertEqual(text, "") + show_text.assert_not_called() def test_target_prior(self): w = self.widget @@ -242,6 +278,103 @@ def test_target_prior(self): simulate.combobox_activate_item(w.controls.target_index, "soft") self.assertEqual(np.round(5/12 * 100), w.target_prior) + def test_target_prior_reload(self): + w = self.widget + self.send_signal(w.Inputs.evaluation_results, self.res) + simulate.combobox_activate_item(w.controls.target_index, "soft") + self.assertEqual(np.round(5/12 * 100), w.target_prior) + self.send_signal(w.Inputs.evaluation_results, self.res) + self.assertEqual(np.round(5/12 * 100), w.target_prior) + + @patch("Orange.widgets.evaluate.owrocanalysis.ThresholdClassifier") + def test_apply_no_output(self, *_): + """Test no output warnings""" + # Similar to test_owcalibrationplot, but just a little different, hence + # pylint: disable=duplicate-code + widget = self.widget + model_list = widget.controls.selected_classifiers + + multiple_folds, multiple_selected, no_models, non_binary_class = "abcd" + messages = { + multiple_folds: + "each training data sample produces a different model", + no_models: + "test results do not contain stored models - try testing on " + "separate data or on training data", + multiple_selected: + "select a single model - the widget can output only one", + non_binary_class: + "cannot calibrate non-binary models"} + + def test_shown(shown): + widget_msg = widget.Information.no_output + output = self.get_output(widget.Outputs.calibrated_model) + if not shown: + self.assertFalse(widget_msg.is_shown()) + self.assertIsNotNone(output) + else: + self.assertTrue(widget_msg.is_shown()) + self.assertIsNone(output) + for msg_id in shown: + msg = messages[msg_id] + self.assertIn(msg, widget_msg.formatted, + f"{msg} not included in the message") + + self.send_signal(widget.Inputs.evaluation_results, self.results) + test_shown({multiple_selected}) + + self._set_list_selection(model_list, [0]) + test_shown(()) + widget.controls.display_perf_line.click() + output = self.get_output(widget.Outputs.calibrated_model) + self.assertIsNone(output) + widget.controls.display_perf_line.click() + output = self.get_output(widget.Outputs.calibrated_model) + self.assertIsNotNone(output) + + self._set_list_selection(model_list, [0, 1]) + + self.results.models = None + self.send_signal(widget.Inputs.evaluation_results, self.results) + test_shown({multiple_selected, no_models}) + + self.send_signal(widget.Inputs.evaluation_results, self.lenses_results) + test_shown({multiple_selected, non_binary_class}) + + self._set_list_selection(model_list, [0]) + test_shown({non_binary_class}) + + self.results.folds = [slice(0, 5), slice(5, 10), slice(10, 19)] + self.results.models = np.array([[Mock(), Mock()]] * 3) + + self.send_signal(widget.Inputs.evaluation_results, self.results) + test_shown({multiple_selected, multiple_folds}) + + self._set_list_selection(model_list, [0]) + test_shown({multiple_folds}) + + @patch("Orange.widgets.evaluate.owrocanalysis.ThresholdClassifier") + def test_calibrated_output(self, tc): + widget = self.widget + model_list = widget.controls.selected_classifiers + + self.send_signal(widget.Inputs.evaluation_results, self.results) + self._set_list_selection(model_list, [0]) + + model, threshold = tc.call_args[0] + self.assertIs(model, self.results.models[0][0]) + self.assertAlmostEqual(threshold, 0.47) + + widget.controls.fp_cost.setValue(1000) + model, threshold = tc.call_args[0] + self.assertIs(model, self.results.models[0][0]) + self.assertAlmostEqual(threshold, 0.9) + + self._set_list_selection(model_list, [1]) + model, threshold = tc.call_args[0] + self.assertIs(model, self.results.models[0][1]) + self.assertAlmostEqual(threshold, 0.45) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/evaluate/tests/test_owtestandscore.py b/Orange/widgets/evaluate/tests/test_owtestandscore.py index 1d84d8557f7..efd2219a3b4 100644 --- a/Orange/widgets/evaluate/tests/test_owtestandscore.py +++ b/Orange/widgets/evaluate/tests/test_owtestandscore.py @@ -2,8 +2,10 @@ # pylint: disable=protected-access import unittest from unittest.mock import Mock, patch +from packaging.version import parse as parse_version import numpy as np +from sklearn import __version__ as sklearn_version from AnyQt.QtCore import Qt from AnyQt.QtTest import QTest from AnyQt.QtWidgets import QApplication @@ -16,12 +18,11 @@ from Orange.evaluation import Results, TestOnTestData, scoring from Orange.evaluation.scoring import ClassificationScore, RegressionScore, \ Score -from Orange.base import Learner +from Orange.base import Learner, Model from Orange.modelling import ConstantLearner -from Orange.regression import MeanLearner +from Orange.regression import MeanLearner, PLSRegressionLearner from Orange.widgets.evaluate.owtestandscore import ( OWTestAndScore, results_one_vs_rest) -from Orange.widgets.evaluate.utils import BUILTIN_SCORERS_ORDER from Orange.widgets.settings import ( ClassValuesContextHandler, PerfectDomainContextHandler) from Orange.widgets.tests.base import WidgetTest @@ -71,14 +72,31 @@ def test_basic(self): self.assertIsNotNone(res.domain) self.assertIsNotNone(res.data) - def test_more_learners(self): - data = Table("iris")[::15] + def test_multiple_learners(self): + def check_evres_names(expeced): + res = self.get_output(self.widget.Outputs.evaluations_results) + self.assertSequenceEqual(res.learner_names, expeced) + + data = Table("iris")[::15].copy() + m1 = MajorityLearner() + m1.name = "M1" + m2 = MajorityLearner() + m2.name = "M2" self.send_signal(self.widget.Inputs.train_data, data) - self.send_signal(self.widget.Inputs.learner, MajorityLearner(), 0) - self.get_output(self.widget.Outputs.evaluations_results, wait=5000) - self.send_signal(self.widget.Inputs.learner, MajorityLearner(), 1) - res = self.get_output(self.widget.Outputs.evaluations_results, wait=5000) + self.send_signal(self.widget.Inputs.learner, m1, 1) + self.send_signal(self.widget.Inputs.learner, m2, 2) + res = self.get_output(self.widget.Outputs.evaluations_results) np.testing.assert_equal(res.probabilities[0], res.probabilities[1]) + check_evres_names(["M1", "M2"]) + self.send_signal(self.widget.Inputs.learner, None, 1) + check_evres_names(["M2"]) + self.send_signal(self.widget.Inputs.learner, m1, 1) + check_evres_names(["M1", "M2"]) + self.send_signal(self.widget.Inputs.learner, + self.widget.Inputs.learner.closing_sentinel, 1) + check_evres_names(["M2"]) + self.send_signal(self.widget.Inputs.learner, m1, 1) + check_evres_names(["M2", "M1"]) def test_testOnTest(self): data = Table("iris") @@ -95,9 +113,10 @@ def test_testOnTest_incompatible_domain(self): self.widget.resampling = OWTestAndScore.TestOnTest # test data with the same class (otherwise the widget shows a different error) # and a non-nan X - iris_test = iris.transform(Domain([ContinuousVariable("x")], - class_vars=iris.domain.class_vars)) - iris_test.X[:, 0] = 1 + iris_test = iris.transform( + Domain([ContinuousVariable("x")], class_vars=iris.domain.class_vars)).copy() + with iris_test.unlocked(): + iris_test.X[:, 0] = 1 self.send_signal(self.widget.Inputs.test_data, iris_test) self.get_output(self.widget.Outputs.evaluations_results, wait=5000) self.assertTrue(self.widget.Error.test_data_incompatible.is_shown()) @@ -136,6 +155,11 @@ def test_migrate_removes_invalid_contexts(self): self.widget.migrate_settings(settings, 2) self.assertEqual(settings['context_settings'], [context_valid]) + def test_migrate_shown_scores(self): + settings = {"score_table": {"shown_scores": {"Sensitivity"}}} + self.widget.migrate_settings(settings, 3) + self.assertTrue(settings["score_table"]["show_score_hints"]["Sensitivity"]) + def test_memory_error(self): """ Handling memory error. @@ -160,7 +184,7 @@ def test_one_class_value(self): table = Table.from_list( Domain( [ContinuousVariable("a"), ContinuousVariable("b")], - [DiscreteVariable("c", values=("y", ))]), + [DiscreteVariable("c", values=("y",))]), list(zip( [42.48, 16.84, 15.23, 23.8], [1., 2., 3., 4.], @@ -168,19 +192,21 @@ def test_one_class_value(self): ) self.widget.n_folds = 0 self.assertFalse(self.widget.Error.train_data_error.is_shown()) - self.send_signal("Data", table) - self.send_signal("Learner", MajorityLearner(), 0, wait=1000) + self.send_signal(self.widget.Inputs.train_data, table) + self.send_signal(self.widget.Inputs.learner, MajorityLearner(), 0, wait=1000) self.assertTrue(self.widget.Error.train_data_error.is_shown()) def test_data_errors(self): """ Test all data_errors """ + def assertErrorShown(data, is_shown, message): - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.train_data, data) self.assertEqual(is_shown, self.widget.Error.train_data_error.is_shown()) self.assertEqual(message, str(self.widget.Error.train_data_error)) - data = Table("iris")[::30] - data.Y[:] = np.nan + data = Table("iris")[::30].copy() + with data.unlocked(): + data.Y[:] = np.nan iris_empty_x = Table.from_table( Domain([], data.domain.class_var), Table("iris") @@ -205,34 +231,39 @@ def test_addon_scorers(self): # These classes are registered, pylint: disable=unused-variable class NewScore(Score): class_types = (DiscreteVariable, ContinuousVariable) + name = "new scorer" + + @staticmethod + def is_compatible(domain: Domain) -> bool: + return True class NewClassificationScore(ClassificationScore): - pass + name = "new classification scorer" + default_visible = False class NewRegressionScore(RegressionScore): pass - builtins = BUILTIN_SCORERS_ORDER - self.send_signal("Data", Table("iris")) - scorer_names = [scorer.name for scorer in self.widget.scorers] - self.assertEqual( - tuple(scorer_names[:len(builtins[DiscreteVariable])]), - builtins[DiscreteVariable]) - self.assertIn("NewScore", scorer_names) - self.assertIn("NewClassificationScore", scorer_names) + widget = self.create_widget(OWTestAndScore) + header = widget.score_table.view.horizontalHeader() + self.send_signal(widget.Inputs.train_data, Table("iris")) + scorer_names = [scorer.name for scorer in widget.scorers] + self.assertIn("new scorer", scorer_names) + self.assertFalse(header.isSectionHidden(3 + scorer_names.index("new scorer"))) + self.assertIn("new classification scorer", scorer_names) + self.assertTrue(header.isSectionHidden(3 + scorer_names.index("new classification scorer"))) self.assertNotIn("NewRegressionScore", scorer_names) + model = widget.score_table.model + - self.send_signal("Data", Table("housing")) - scorer_names = [scorer.name for scorer in self.widget.scorers] - self.assertEqual( - tuple(scorer_names[:len(builtins[ContinuousVariable])]), - builtins[ContinuousVariable]) - self.assertIn("NewScore", scorer_names) - self.assertNotIn("NewClassificationScore", scorer_names) + self.send_signal(widget.Inputs.train_data, Table("housing")) + scorer_names = [scorer.name for scorer in widget.scorers] + self.assertIn("new scorer", scorer_names) + self.assertNotIn("new classification scorer", scorer_names) self.assertIn("NewRegressionScore", scorer_names) - self.send_signal("Data", None) - self.assertEqual(self.widget.scorers, []) + self.send_signal(widget.Inputs.train_data, None) + self.assertEqual(widget.scorers, []) finally: del Score.registry["NewScore"] # pylint: disable=no-member del Score.registry["NewClassificationScore"] # pylint: disable=no-member @@ -272,13 +303,13 @@ def test_resort_on_data_change(self): setosa = iris[:51] versicolor = iris[49:100] - class SetosaLearner: + class SetosaLearner(Learner): def __call__(self, data): model = ConstantModel([1., 0, 0]) model.domain = iris.domain return model - class VersicolorLearner: + class VersicolorLearner(Learner): def __call__(self, data): model = ConstantModel([0, 1., 0]) model.domain = iris.domain @@ -287,9 +318,8 @@ def __call__(self, data): # this is done manually to avoid multiple computations self.widget.resampling = 5 self.widget.set_train_data(iris) - self.widget.set_learner(SetosaLearner(), 1) - self.widget.set_learner(VersicolorLearner(), 2) - + self.widget.insert_learner(0, SetosaLearner()) + self.widget.insert_learner(1, VersicolorLearner()) self.send_signal(self.widget.Inputs.test_data, setosa, wait=5000) self.widget.adjustSize() @@ -297,7 +327,7 @@ def __call__(self, data): header = view.horizontalHeader() p = header.rect().center() # second visible header section (after 'Model') - _, idx, *_ = (i for i in range(header.count()) + _, _, idx, *_ = (i for i in range(header.count()) if not header.isSectionHidden(i)) p.setX(header.sectionPosition(idx) + 5) QTest.mouseClick(header.viewport(), Qt.LeftButton, pos=p) @@ -305,10 +335,10 @@ def __call__(self, data): # Ensure that the click on header caused an ascending sort # Ascending sort means that wrong model should be listed first self.assertEqual(header.sortIndicatorOrder(), Qt.AscendingOrder) - self.assertEqual(view.model().index(0, 0).data(), "VersicolorLearner") + self.assertEqual(view.model().index(0, 0).data(), "versicolor") self.send_signal(self.widget.Inputs.test_data, versicolor, wait=5000) - self.assertEqual(view.model().index(0, 0).data(), "SetosaLearner") + self.assertEqual(view.model().index(0, 0).data(), "setosa") def _retrieve_scores(self): w = self.widget @@ -339,13 +369,17 @@ def test_scores_constant(self): list(zip(*self.scores_table_values + [list("yyyn")])) ) - self.assertTupleEqual( - self._test_scores( - table, table[:3], ConstantLearner(), - OWTestAndScore.TestOnTest, None - ), - (None, 1, 1, 1, 1) + scores = self._test_scores( + table, table[:3], ConstantLearner(), + OWTestAndScore.TestOnTest, None ) + self.assertTupleEqual(scores[1:], (1, 1, 1, 1)) + + # Sklearn, we love you. + if parse_version(sklearn_version) < parse_version("1.6dev"): + self.assertIsNone(scores[0]) + else: + self.assertTrue(np.isnan(scores[0])) def test_scores_log_reg_overfitted(self): table = Table.from_list( @@ -356,7 +390,7 @@ def test_scores_log_reg_overfitted(self): self.assertTupleEqual(self._test_scores( table, table, LogisticRegressionLearner(), OWTestAndScore.TestOnTest, None), - (1, 1, 1, 1, 1)) + (1, 1, 1, 1, 1)) def test_scores_log_reg_bad(self): table_train = Table.from_list( @@ -371,7 +405,7 @@ def test_scores_log_reg_bad(self): self.assertTupleEqual(self._test_scores( table_train, table_test, LogisticRegressionLearner(), OWTestAndScore.TestOnTest, None), - (0, 0, 0, 0, 0)) + (0, 0, 0, 0, 0)) def test_scores_log_reg_bad2(self): table_train = Table.from_list( @@ -495,24 +529,21 @@ def test_comparison_requires_cv(self): rbs[OWTestAndScore.KFold].click() self.get_output(self.widget.Outputs.evaluations_results, wait=5000) self.assertIsNotNone(w.comparison_table.cellWidget(0, 1)) - self.assertTrue(w.modcompbox.isEnabled()) - self.assertTrue(w.comparison_table.isEnabled()) + self.assertTrue(w.compbox.isEnabled()) baycomp.two_on_single.assert_called() baycomp.two_on_single.reset_mock() rbs[OWTestAndScore.LeaveOneOut].click() self.get_output(self.widget.Outputs.evaluations_results, wait=5000) self.assertIsNone(w.comparison_table.cellWidget(0, 1)) - self.assertFalse(w.modcompbox.isEnabled()) - self.assertFalse(w.comparison_table.isEnabled()) + self.assertFalse(w.compbox.isEnabled()) baycomp.two_on_single.assert_not_called() baycomp.two_on_single.reset_mock() rbs[OWTestAndScore.KFold].click() self.get_output(self.widget.Outputs.evaluations_results, wait=5000) self.assertIsNotNone(w.comparison_table.cellWidget(0, 1)) - self.assertTrue(w.modcompbox.isEnabled()) - self.assertTrue(w.comparison_table.isEnabled()) + self.assertTrue(w.compbox.isEnabled()) baycomp.two_on_single.assert_called() baycomp.two_on_single.reset_mock() @@ -598,7 +629,7 @@ def test_comparison_binary_score(self): w = self.widget self._set_three_majorities() self._set_comparison_score("F1") - f1mock = Mock(wraps=scoring.F1) + f1mock = Mock(wraps=scoring.F1.compute_score) iris = Table("iris") with patch.object(scoring.F1, "compute_score", f1mock): @@ -698,13 +729,65 @@ def test_copy_to_clipboard(self): selection_model = view.selectionModel() selection_model.select(model.index(0, 0), selection_model.Select | selection_model.Rows) - self.widget.copy_to_clipboard() clipboard_text = QApplication.clipboard().text() + # Tests appear to register additional scorers, so we clip the list + # to what we know to be there and visible + clipboard_text = "\t".join(clipboard_text.split("\t")[:6]).strip() view_text = "\t".join([str(model.data(model.index(0, i))) - for i in (0, 3, 4, 5, 6, 7)]) + "\r\n" + for i in (0, 3, 4, 5, 6, 7)]).strip() self.assertEqual(clipboard_text, view_text) + def test_multi_target_input(self): + class NewScorer(Score): + class_types = ( + ContinuousVariable, + DiscreteVariable, + ) + + @staticmethod + def is_compatible(domain: Domain) -> bool: + return True + + def compute_score(self, results): + return [0.75] + + domain = Domain([ContinuousVariable('var1')], + class_vars=[ + ContinuousVariable('c1'), + DiscreteVariable('c2', values=('no', 'yes')) + ]) + data = Table.from_list(domain, [[1, 5, 0], [2, 10, 1], [2, 10, 1]]) + + mock_model = Mock(spec=Model, return_value=np.asarray([0.2, 0.1, 0.2])) + mock_model.name = 'Mockery' + mock_model.domain = domain + mock_learner = Mock(spec=Learner, return_value=mock_model) + mock_learner.name = 'Mockery' + + widget = self.create_widget(OWTestAndScore) + widget.resampling = OWTestAndScore.TestOnTrain + self.send_signal(widget.Inputs.train_data, data) + self.send_signal(widget.Inputs.learner, MajorityLearner(), 0) + self.send_signal(widget.Inputs.learner, mock_learner, 1) + _ = self.get_output(widget.Outputs.evaluations_results, wait=5000) + self.assertTrue(len(widget.scorers) == 1) + self.assertTrue(NewScorer in widget.scorers) + self.assertTrue(len(widget._successful_slots()) == 1) + + def test_multiple_targets_pls(self): + housing = Table("housing") + class_vars = [housing.domain.class_var, housing.domain.attributes[0]] + domain = Domain(housing.domain.attributes[1:], class_vars=class_vars) + multiple_targets_data = housing.transform(domain) + + self.widget.error = Mock() + self.send_signal(self.widget.Inputs.train_data, multiple_targets_data) + self.send_signal(self.widget.Inputs.learner, PLSRegressionLearner()) + self.wait_until_finished() + self.assertIn("Multiple targets are not supported.", + self.widget.error.call_args[0][0]) + class TestHelpers(unittest.TestCase): def test_results_one_vs_rest(self): diff --git a/Orange/widgets/evaluate/tests/test_utils.py b/Orange/widgets/evaluate/tests/test_utils.py index a5998f56cfb..cd0aa7788cd 100644 --- a/Orange/widgets/evaluate/tests/test_utils.py +++ b/Orange/widgets/evaluate/tests/test_utils.py @@ -2,6 +2,8 @@ import unittest import collections +from itertools import count +from unittest.mock import patch import numpy as np @@ -9,20 +11,57 @@ from AnyQt.QtGui import QStandardItem from AnyQt.QtCore import QPoint, Qt -from Orange.widgets.evaluate.utils import ScoreTable +import Orange +from Orange.evaluation.scoring import Score, AUC, CA, F1, Specificity +from Orange.widgets.evaluate.utils import ScoreTable, usable_scorers from Orange.widgets.tests.base import GuiTest +from Orange.data import Table, DiscreteVariable, ContinuousVariable +from Orange.evaluation import scoring + + +class TestUsableScorers(unittest.TestCase): + def setUp(self): + self.iris = Table("iris") + self.housing = Table("housing") + self.registered_scorers = set(scoring.Score.registry.values()) + + def validate_scorer_candidates(self, scorers, class_type): + # scorer candidates are (a proper) subset of registered scorers + self.assertTrue(set(scorers) < self.registered_scorers) + # all scorers are adequate + self.assertTrue(all(class_type in scorer.class_types + for scorer in scorers)) + # scorers are sorted + self.assertTrue(all(s1.priority <= s2.priority + for s1, s2 in zip(scorers, scorers[1:]))) + + def test_usable_scores(self): + self.validate_scorer_candidates( + usable_scorers(self.iris.domain), class_type=DiscreteVariable) + self.validate_scorer_candidates( + usable_scorers(self.housing.domain), class_type=ContinuousVariable) class TestScoreTable(GuiTest): - def test_show_column_chooser(self): - score_table = ScoreTable(None) - view = score_table.view - all, shown = "MABDEFG", "ABDF" - header = view.horizontalHeader() - score_table.shown_scores = set(shown) - score_table.model.setHorizontalHeaderLabels(list(all)) - score_table._update_shown_columns() + def setUp(self): + class NewScore(Score): + name = "new score" + + self.NewScore = NewScore # pylint: disable=invalid-name + self.orig_hints = ScoreTable.show_score_hints + hints = ScoreTable.show_score_hints = self.orig_hints.default.copy() + hints.update(dict(F1=True, CA=False, AUC=True, Recall=True, + Specificity=False, NewScore=True)) + self.score_table = ScoreTable(None) + self.score_table.update_header([F1, CA, AUC, Specificity, NewScore]) + + def tearDown(self): + ScoreTable.show_score_hints = self.orig_hints + del Score.registry["NewScore"] + + def test_show_column_chooser(self): + hints = ScoreTable.show_score_hints actions = collections.OrderedDict() menu_add_action = QMenu.addAction @@ -32,46 +71,40 @@ def addAction(menu, a): return action def execmenu(*_): - self.assertEqual(list(actions), list(all)[1:]) - for name, action in actions.items(): - self.assertEqual(action.isChecked(), name in shown) - actions["E"].triggered.emit(True) - self.assertEqual(score_table.shown_scores, set("ABDEF")) - actions["B"].triggered.emit(False) - self.assertEqual(score_table.shown_scores, set("ADEF")) - for i, name in enumerate(all): - self.assertEqual(name == "M" or name in "ADEF", - not header.isSectionHidden(i), - msg="error in section {}({})".format(i, name)) + # pylint: disable=unsubscriptable-object,unsupported-assignment-operation + scorers = [F1, CA, AUC, Specificity, self.NewScore] + self.assertEqual(list(actions)[3:], ['F1', + 'Classification accuracy (CA)', + 'Area under ROC curve (AUC)', + 'Specificity (Spec)', + 'new score']) + header = self.score_table.view.horizontalHeader() + for i, action, scorer in zip(count(), list(actions.values())[3:], scorers): + self.assertEqual(action.isChecked(), + hints[scorer.__name__], + msg=f"error in section {scorer.name}") + self.assertEqual(header.isSectionHidden(3 + i), + not hints[scorer.__name__], + msg=f"error in section {scorer.name}") + actions["Classification accuracy (CA)"].triggered.emit(True) + hints["CA"] = True + for k, v in hints.items(): + self.assertEqual(self.score_table.show_score_hints[k], v, + msg=f"error at {k}") + actions["Area under ROC curve (AUC)"].triggered.emit(False) + hints["AUC"] = False + for k, v in hints.items(): + self.assertEqual(self.score_table.show_score_hints[k], v, + msg=f"error at {k}") # We must patch `QMenu.exec` because the Qt would otherwise (invisibly) # show the popup and wait for the user. # Assertions are made within `menuexec` since they check the # instances of `QAction`, which are invalid (destroyed by Qt?) after # `menuexec` finishes. - with unittest.mock.patch("AnyQt.QtWidgets.QMenu.addAction", addAction), \ - unittest.mock.patch("AnyQt.QtWidgets.QMenu.exec", execmenu): - score_table.show_column_chooser(QPoint(0, 0)) - - def test_update_shown_columns(self): - score_table = ScoreTable(None) - view = score_table.view - all, shown = "MABDEFG", "ABDF" - header = view.horizontalHeader() - score_table.shown_scores = set(shown) - score_table.model.setHorizontalHeaderLabels(list(all)) - score_table._update_shown_columns() - for i, name in enumerate(all): - self.assertEqual(name == "M" or name in shown, - not header.isSectionHidden(i), - msg="error in section {}({})".format(i, name)) - - score_table.shown_scores = set() - score_table._update_shown_columns() - for i, name in enumerate(all): - self.assertEqual(i == 0, - not header.isSectionHidden(i), - msg="error in section {}({})".format(i, name)) + with patch("AnyQt.QtWidgets.QMenu.addAction", addAction), \ + patch("AnyQt.QtWidgets.QMenu.exec", execmenu): + self.score_table.view.horizontalHeader().show_column_chooser(QPoint(0, 0)) def test_sorting(self): def order(n=5): @@ -115,6 +148,15 @@ def order(n=5): model.sort(2, Qt.DescendingOrder) self.assertEqual(order(3), "DEC") + def test_shown_scores_backward_compatibility(self): + self.assertEqual(self.score_table.shown_scores, + {"F1", "AUC", "new score"}) + + def test_migration(self): + settings = dict(foo=False, shown_scores={"Sensitivity"}) + ScoreTable.migrate_to_show_scores_hints(settings) + self.assertTrue(settings["show_score_hints"]["Sensitivity"]) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/evaluate/utils.py b/Orange/widgets/evaluate/utils.py index b22dbfdea23..552531f5c9b 100644 --- a/Orange/widgets/evaluate/utils.py +++ b/Orange/widgets/evaluate/utils.py @@ -1,18 +1,21 @@ import warnings -from functools import partial -from itertools import chain +from operator import attrgetter +from typing import Union, Dict, List import numpy as np +from sklearn.exceptions import UndefinedMetricWarning from AnyQt.QtWidgets import QHeaderView, QStyledItemDelegate, QMenu, \ - QApplication -from AnyQt.QtGui import QStandardItemModel, QStandardItem, QClipboard + QApplication, QToolButton +from AnyQt.QtGui import QStandardItemModel, QStandardItem, QClipboard, QColor from AnyQt.QtCore import Qt, QSize, QObject, pyqtSignal as Signal, \ QSortFilterProxyModel -from sklearn.exceptions import UndefinedMetricWarning -from Orange.data import Variable, DiscreteVariable, ContinuousVariable +from orangewidget.gui import OrangeUserRole + +from Orange.data import Domain, Variable from Orange.evaluation import scoring +from Orange.evaluation.scoring import Score from Orange.widgets import gui from Orange.widgets.utils.tableview import table_selection_to_mime_data from Orange.widgets.gui import OWComponent @@ -28,10 +31,7 @@ def anynan(a): if results is None: return None - if results.data is None: - error_group.invalid_results( - "Results do not include information on test data.") - elif not results.data.domain.has_discrete_class: + elif not results.domain.has_discrete_class: error_group.invalid_results( "Categorical target variable is required.") elif not results.actual.size: @@ -47,6 +47,28 @@ def anynan(a): return results +def check_can_calibrate(results, selection, require_binary=True): + assert results is not None + + problems = [ + msg for condition, msg in ( + (results.folds is not None and len(results.folds) > 1, + "each training data sample produces a different model"), + (results.models is None, + "test results do not contain stored models - try testing " + "on separate data or on training data"), + (len(selection) != 1, + "select a single model - the widget can output only one"), + (require_binary and len(results.domain.class_var.values) != 2, + "cannot calibrate non-binary models")) + if condition] + + if len(problems) == 1: + return problems[0] + else: + return "".join(f"\n - {problem}" for problem in problems) + + def results_for_preview(data_name=""): from Orange.data import Table from Orange.evaluation import CrossValidation @@ -67,25 +89,22 @@ def results_for_preview(data_name=""): return results -BUILTIN_SCORERS_ORDER = { - DiscreteVariable: ("AUC", "CA", "F1", "Precision", "Recall"), - ContinuousVariable: ("MSE", "RMSE", "MAE", "R2")} - - def learner_name(learner): """Return the value of `learner.name` if it exists, or the learner's type name otherwise""" return getattr(learner, "name", type(learner).__name__) -def usable_scorers(target: Variable): - order = {name: i - for i, name in enumerate(BUILTIN_SCORERS_ORDER[type(target)])} +def usable_scorers(domain_or_var: Union[Variable, Domain]): + if domain_or_var is None: + return [] + # 'abstract' is retrieved from __dict__ to avoid inheriting - usable = (cls for cls in scoring.Score.registry.values() - if cls.is_scalar and not cls.__dict__.get("abstract") - and isinstance(target, cls.class_types)) - return sorted(usable, key=lambda cls: order.get(cls.name, 99)) + candidates = [ + scorer for scorer in scoring.Score.registry.values() + if scorer.is_scalar and not scorer.__dict__.get("abstract") + and scorer.is_compatible(domain_or_var) and scorer.class_types] + return sorted(candidates, key=attrgetter("priority")) def scorer_caller(scorer, ovr_results, target=None): @@ -108,7 +127,7 @@ class ScoreModel(QSortFilterProxyModel): def lessThan(self, left, right): def is_bad(x): return not isinstance(x, (int, float, str)) \ - or isinstance(x, float) and np.isnan(x) + or isinstance(x, float) and bool(np.isnan(x)) left = left.data() right = right.data() @@ -127,15 +146,103 @@ def is_bad(x): return left.upper() < right.upper() # otherwise, compare numbers - return left < right + return bool(left < right) + + +DEFAULT_HINTS = {"Model_": True, "Train_": False, "Test_": False} + + +class PersistentMenu(QMenu): + def mouseReleaseEvent(self, e): + action = self.activeAction() + if action: + action.setEnabled(False) + super().mouseReleaseEvent(e) + action.setEnabled(True) + action.trigger() + else: + super().mouseReleaseEvent(e) + + +class SelectableColumnsHeader(QHeaderView): + SelectMenuRole = next(OrangeUserRole) + ShownHintRole = next(OrangeUserRole) + sectionVisibleChanged = Signal(int, bool) + + def __init__(self, shown_columns_hints, *args, **kwargs): + super().__init__(Qt.Horizontal, *args, **kwargs) + self.show_column_hints = shown_columns_hints + self.button = QToolButton(self) + self.button.setArrowType(Qt.DownArrow) + self.button.setFixedSize(24, 12) + col = self.button.palette().color(self.button.backgroundRole()) + self.button.setStyleSheet( + f"border: none; background-color: {col.name(QColor.NameFormat.HexRgb)}") + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.show_column_chooser) + self.button.clicked.connect(self._on_button_clicked) + + def showEvent(self, e): + self._set_pos() + self.button.show() + super().showEvent(e) + + def resizeEvent(self, e): + self._set_pos() + super().resizeEvent(e) + + def _set_pos(self): + w, h = self.button.width(), self.button.height() + vw, vh = self.viewport().width(), self.viewport().height() + self.button.setGeometry(vw - w, (vh - h) // 2, w, h) + + def __data(self, section, role): + return self.model().headerData(section, Qt.Horizontal, role) + def show_column_chooser(self, pos): + # pylint: disable=unsubscriptable-object, unsupported-assignment-operation + menu = PersistentMenu() + for section in range(self.count()): + name, enabled = self.__data(section, self.SelectMenuRole) + hint_id = self.__data(section, self.ShownHintRole) + action = menu.addAction(name) + action.setDisabled(not enabled) + action.setCheckable(True) + action.setChecked(self.show_column_hints[hint_id]) + + @action.triggered.connect # pylint: disable=cell-var-from-loop + def update(checked, q=hint_id, section=section): + self.show_column_hints[q] = checked + self.setSectionHidden(section, not checked) + self.sectionVisibleChanged.emit(section, checked) + self.resizeSections(self.ResizeToContents) + + pos.setY(self.viewport().height()) + menu.exec(self.mapToGlobal(pos)) + + def _on_button_clicked(self): + self.show_column_chooser(self.button.pos()) + + def update_shown_columns(self): + for section in range(self.count()): + hint_id = self.__data(section, self.ShownHintRole) + self.setSectionHidden(section, not self.show_column_hints[hint_id]) -class ScoreTable(OWComponent, QObject): - shown_scores = \ - Setting(set(chain(*BUILTIN_SCORERS_ORDER.values()))) +class ScoreTable(OWComponent, QObject): + show_score_hints: Dict[str, bool] = Setting(DEFAULT_HINTS) shownScoresChanged = Signal() + # backwards compatibility + @property + def shown_scores(self): + # pylint: disable=unsubscriptable-object + column_names = { + self.model.horizontalHeaderItem(col).data(Qt.DisplayRole) + for col in range(1, self.model.columnCount())} + return column_names & {score.name for score in Score.registry.values() + if self.show_score_hints[score.__name__]} + class ItemDelegate(QStyledItemDelegate): def sizeHint(self, *args): size = super().sizeHint(*args) @@ -158,62 +265,56 @@ def __init__(self, master): header.setSectionResizeMode(QHeaderView.ResizeToContents) header.setDefaultAlignment(Qt.AlignCenter) header.setStretchLastSection(False) - header.setContextMenuPolicy(Qt.CustomContextMenu) - header.customContextMenuRequested.connect(self.show_column_chooser) + + for score in Score.registry.values(): + self.show_score_hints.setdefault(score.__name__, score.default_visible) self.model = QStandardItemModel(master) - self.model.setHorizontalHeaderLabels(["Method"]) + header = SelectableColumnsHeader(self.show_score_hints) + header.setSectionsClickable(True) + self.view.setHorizontalHeader(header) self.sorted_model = ScoreModel() self.sorted_model.setSourceModel(self.model) self.view.setModel(self.sorted_model) self.view.setItemDelegate(self.ItemDelegate()) + header.sectionVisibleChanged.connect(self.shownScoresChanged.emit) + self.sorted_model.dataChanged.connect(self.view.resizeColumnsToContents) - def _column_names(self): - return (self.model.horizontalHeaderItem(section).data(Qt.DisplayRole) - for section in range(1, self.model.columnCount())) - - def show_column_chooser(self, pos): - # pylint doesn't know that self.shown_scores is a set, not a Setting - # pylint: disable=unsupported-membership-test - def update(col_name, checked): - if checked: - self.shown_scores.add(col_name) - else: - self.shown_scores.remove(col_name) - self._update_shown_columns() - - menu = QMenu() - header = self.view.horizontalHeader() - for col_name in self._column_names(): - action = menu.addAction(col_name) - action.setCheckable(True) - action.setChecked(col_name in self.shown_scores) - action.triggered.connect(partial(update, col_name)) - menu.exec(header.mapToGlobal(pos)) - - def _update_shown_columns(self): - # pylint doesn't know that self.shown_scores is a set, not a Setting - # pylint: disable=unsupported-membership-test - header = self.view.horizontalHeader() - for section, col_name in enumerate(self._column_names(), start=1): - header.setSectionHidden(section, col_name not in self.shown_scores) - self.view.resizeColumnsToContents() - self.shownScoresChanged.emit() - - def update_header(self, scorers): - # Set the correct horizontal header labels on the results_model. + def update_header(self, scorers: List[Score]): self.model.setColumnCount(3 + len(scorers)) - self.model.setHorizontalHeaderItem(0, QStandardItem("Model")) - self.model.setHorizontalHeaderItem(1, QStandardItem("Train time [s]")) - self.model.setHorizontalHeaderItem(2, QStandardItem("Test time [s]")) + SelectMenuRole = SelectableColumnsHeader.SelectMenuRole + ShownHintRole = SelectableColumnsHeader.ShownHintRole + for i, name, long_name, id_, in ((0, "Model", "Model", "Model_"), + (1, "Train", "Train time [s]", "Train_"), + (2, "Test", "Test time [s]", "Test_")): + item = QStandardItem(name) + item.setData((long_name, i != 0), SelectMenuRole) + item.setData(id_, ShownHintRole) + item.setToolTip(long_name) + self.model.setHorizontalHeaderItem(i, item) for col, score in enumerate(scorers, start=3): item = QStandardItem(score.name) + name = score.long_name + if name != score.name: + name += f" ({score.name})" + item.setData((name, True), SelectMenuRole) + item.setData(score.__name__, ShownHintRole) item.setToolTip(score.long_name) self.model.setHorizontalHeaderItem(col, item) - self._update_shown_columns() + + self.view.horizontalHeader().update_shown_columns() + self.view.resizeColumnsToContents() def copy_selection_to_clipboard(self): mime = table_selection_to_mime_data(self.view) QApplication.clipboard().setMimeData( mime, QClipboard.Clipboard ) + + @staticmethod + def migrate_to_show_scores_hints(settings): + # Migration cannot disable anything because it can't know which score + # have been present when the setting was created. + settings["show_score_hints"] = DEFAULT_HINTS.copy() + settings["show_score_hints"].update( + dict.fromkeys(settings["shown_scores"], True)) diff --git a/Orange/widgets/gui.py b/Orange/widgets/gui.py index 0ccc4af333c..91ba5fb8c36 100644 --- a/Orange/widgets/gui.py +++ b/Orange/widgets/gui.py @@ -1,16 +1,17 @@ """ Wrappers for controls used in widgets """ -import math - import logging import sys import warnings import weakref from collections.abc import Sequence +import math +import numpy as np + from AnyQt import QtWidgets, QtCore, QtGui -from AnyQt.QtCore import Qt, QSize, QItemSelection +from AnyQt.QtCore import Qt, QSize, QItemSelection, QSortFilterProxyModel from AnyQt.QtGui import QColor, QWheelEvent from AnyQt.QtWidgets import QWidget, QListView, QComboBox @@ -25,6 +26,7 @@ indentedBox, widgetLabel, label, spin, doubleSpin, checkBox, lineEdit, button, toolButton, radioButtons, radioButtonsInBox, appendRadioButton, hSlider, labeledSlider, valueSlider, auto_commit, auto_send, auto_apply, + deferred, # ItemDataRole's BarRatioRole, BarBrushRole, SortOrderRole, LinkRole, @@ -39,7 +41,8 @@ ControlledCallback, ControlledCallFront, ValueCallback, connectControl, is_macstyle ) - +from orangewidget.utils.itemmodels import PyTableModel +from orangewidget.utils.listview import ListViewFilter try: # Some Orange widgets might expect this here @@ -49,11 +52,10 @@ pass # Neither WebKit nor WebEngine are available import Orange.data -from Orange.widgets.utils import getdeepattr +from Orange.widgets.utils import getdeepattr, vartype from Orange.data import \ ContinuousVariable, StringVariable, TimeVariable, DiscreteVariable, \ Variable, Value -from Orange.widgets.utils import vartype __all__ = [ # Re-exported @@ -77,7 +79,7 @@ "createAttributePixmap", "attributeIconDict", "attributeItem", "listView", "ListViewWithSizeHint", "listBox", "OrangeListBox", "TableValueRole", "TableClassValueRole", "TableDistribution", - "TableVariable", "TableBarItem", "palette_combo_box" + "TableVariable", "TableBarItem", "palette_combo_box", "BarRatioTableModel" ] @@ -191,10 +193,15 @@ def listView(widget, master, value=None, model=None, box=None, callback=None, else: bg = widget view = viewType(preferred_size=sizeHint) - view.setModel(model) + if isinstance(view, ListViewFilter): + view.model().setSourceModel(model) + signal = view.sigSelectionChanged + else: + view.setModel(model) + signal = view.selectionModel().selectionChanged if value is not None: connectControl(master, value, callback, - view.selectionModel().selectionChanged, + signal, CallFrontListView(view), CallBackListView(model, view, master, value)) misc.setdefault('uniformItemSizes', True) @@ -466,12 +473,22 @@ def remove(self, item): super().remove(item) -def comboBox(*args, **kwargs): - if "valueType" in kwargs: - del kwargs["valueType"] +def comboBox(widget, master, value, box=None, label=None, labelWidth=None, + orientation=Qt.Vertical, items=(), callback=None, + sendSelectedValue=None, emptyString=None, editable=False, + contentsLength=None, searchable=False, *, model=None, + tooltips=None, **misc): + if "valueType" in misc: + del misc["valueType"] warnings.warn("Argument 'valueType' is deprecated and ignored", DeprecationWarning) - return gui_comboBox(*args, **kwargs) + return gui_comboBox( + widget, master, value, box, label, labelWidth, orientation, items, + callback, sendSelectedValue, emptyString, editable, + contentsLength, searchable, model=model, tooltips=tooltips, **misc) + + +comboBox.__doc__ = gui_comboBox.__doc__ class CallBackListView(ControlledCallback): @@ -484,16 +501,19 @@ def __init__(self, model, view, widget, attribute): def __call__(self, *_): # This must be imported locally to avoid circular imports from Orange.widgets.utils.itemmodels import PyListModel - values = [i.row() - for i in self.view.selectionModel().selection().indexes()] - if values: - # FIXME: irrespective of PyListModel check, this might/should always - # callback with values! - if isinstance(self.model, PyListModel): - values = [self.model[i] for i in values] - if self.view.selectionMode() == self.view.SingleSelection: - values = values[0] - self.acyclic_setattr(values) + + selection = self.view.selectionModel().selection() + if isinstance(self.view, ListViewFilter): + selection = self.view.selection + values = [i.row() for i in selection.indexes()] + + # set attribute's values + if isinstance(self.model, PyListModel): + values = [self.model[i] for i in values] + if self.view.selectionMode() == self.view.SingleSelection: + assert len(values) <= 1 + values = values[0] if values else None + self.acyclic_setattr(values) class CallBackListBox: @@ -524,6 +544,8 @@ class CallFrontListView(ControlledCallFront): def action(self, values): view = self.control model = view.model() + if isinstance(view.model(), QSortFilterProxyModel): + model = model.sourceModel() sel_model = view.selectionModel() if not isinstance(values, Sequence): @@ -533,7 +555,7 @@ def action(self, values): for value in values: index = None if not isinstance(value, int): - if isinstance(value, Variable): + if value is None or isinstance(value, Variable): search_role = TableVariable else: search_role = Qt.DisplayRole @@ -546,6 +568,8 @@ def action(self, values): index = value if index is not None: selection.select(model.index(index), model.index(index)) + if isinstance(view.model(), QSortFilterProxyModel): + selection = view.model().mapSelectionFromSource(selection) sel_model.select(selection, sel_model.ClearAndSelect) @@ -649,7 +673,8 @@ def __init__(self, *args, **kwargs): self.horizontalScrollBar().setSingleStep(20) def wheelEvent(self, event: QWheelEvent): - if event.source() == Qt.MouseEventNotSynthesized and \ + if hasattr(event, "source") and \ + event.source() == Qt.MouseEventNotSynthesized and \ (event.modifiers() & Qt.ShiftModifier and sys.platform == 'darwin' or event.modifiers() & Qt.AltModifier and sys.platform != 'darwin'): new_event = QWheelEvent( @@ -661,3 +686,73 @@ def wheelEvent(self, event: QWheelEvent): super().wheelEvent(new_event) else: super().wheelEvent(event) + + +class BarRatioTableModel(PyTableModel): + """A model for displaying python tables. + Adds a BarRatioRole that returns Data, normalized between the extremes. + NaNs are listed last when sorting.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._extremes = {} + + def data(self, index, role=Qt.DisplayRole): + if role == BarRatioRole and index.isValid(): + value = super().data(index, Qt.EditRole) + if not isinstance(value, float): + return None + vmin, vmax = self._extremes.get(index.column(), (-np.inf, np.inf)) + value = (value - vmin) / ((vmax - vmin) or 1) + return value + + if role == Qt.DisplayRole and index.column() != 0: + role = Qt.EditRole + + value = super().data(index, role) + + # Display nothing for non-existent attr value counts in column 1 + if role == Qt.EditRole \ + and index.column() == 1 and np.isnan(value): + return '' + + return value + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if role == Qt.InitialSortOrderRole: + return Qt.DescendingOrder if section > 0 else Qt.AscendingOrder + return super().headerData(section, orientation, role) + + def setExtremesFrom(self, column, values): + """Set extremes for column's ratio bars from values""" + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", ".*All-NaN slice encountered.*", RuntimeWarning) + vmin = np.nanmin(values) + if np.isnan(vmin): + raise TypeError + except TypeError: + vmin, vmax = -np.inf, np.inf + else: + vmax = np.nanmax(values) + self._extremes[column] = (vmin, vmax) + + def resetSorting(self, yes_reset=False): + # pylint: disable=arguments-differ + """We don't want to invalidate our sort proxy model everytime we + wrap a new list. Our proxymodel only invalidates explicitly + (i.e. when new data is set)""" + if yes_reset: + super().resetSorting() + + def _argsortData(self, data, order): + if data.dtype not in (float, int): + data = np.char.lower(data) + indices = np.argsort(data, kind='mergesort') + if order == Qt.DescendingOrder: + indices = indices[::-1] + if data.dtype == float: + # Always sort NaNs last + return np.roll(indices, -np.isnan(data).sum()) + return indices diff --git a/Orange/widgets/model/__init__.py b/Orange/widgets/model/__init__.py index d43d24dd36d..0b5c7fb63fb 100644 --- a/Orange/widgets/model/__init__.py +++ b/Orange/widgets/model/__init__.py @@ -1,11 +1,20 @@ -"""Learners""" +""" +====== +Models +====== -NAME = 'Model' +Classifiers and regressors. -DESCRIPTION = 'Prediction.' +""" -BACKGROUND = '#FAC1D9' +NAME = "Model" -ICON = 'icons/Category-Model.svg' +ID = "orange.widgets.model" + +DESCRIPTION = "Prediction" + +BACKGROUND = "#FAC1D9" + +ICON = "icons/Category-Model.svg" PRIORITY = 4 diff --git a/Orange/widgets/model/icons/AdaBoost.svg b/Orange/widgets/model/icons/AdaBoost-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/AdaBoost.svg rename to Orange/widgets/model/icons/AdaBoost-symbolic.svg diff --git a/Orange/widgets/model/icons/CN2RuleInduction.svg b/Orange/widgets/model/icons/CN2RuleInduction-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/CN2RuleInduction.svg rename to Orange/widgets/model/icons/CN2RuleInduction-symbolic.svg diff --git a/Orange/widgets/model/icons/CalibratedLearner.svg b/Orange/widgets/model/icons/CalibratedLearner-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/CalibratedLearner.svg rename to Orange/widgets/model/icons/CalibratedLearner-symbolic.svg diff --git a/Orange/widgets/model/icons/Constant.svg b/Orange/widgets/model/icons/Constant-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/Constant.svg rename to Orange/widgets/model/icons/Constant-symbolic.svg diff --git a/Orange/widgets/model/icons/CurveFit-symbolic.svg b/Orange/widgets/model/icons/CurveFit-symbolic.svg new file mode 100644 index 00000000000..97fa93f4055 --- /dev/null +++ b/Orange/widgets/model/icons/CurveFit-symbolic.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/model/icons/GradientBoosting.svg b/Orange/widgets/model/icons/GradientBoosting-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/GradientBoosting.svg rename to Orange/widgets/model/icons/GradientBoosting-symbolic.svg diff --git a/Orange/widgets/model/icons/KNN.svg b/Orange/widgets/model/icons/KNN-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/KNN.svg rename to Orange/widgets/model/icons/KNN-symbolic.svg diff --git a/Orange/widgets/model/icons/LinearRegression.svg b/Orange/widgets/model/icons/LinearRegression-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/LinearRegression.svg rename to Orange/widgets/model/icons/LinearRegression-symbolic.svg diff --git a/Orange/widgets/model/icons/LoadModel.svg b/Orange/widgets/model/icons/LoadModel-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/LoadModel.svg rename to Orange/widgets/model/icons/LoadModel-symbolic.svg diff --git a/Orange/widgets/model/icons/LogisticRegression.svg b/Orange/widgets/model/icons/LogisticRegression-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/LogisticRegression.svg rename to Orange/widgets/model/icons/LogisticRegression-symbolic.svg diff --git a/Orange/widgets/model/icons/NN.svg b/Orange/widgets/model/icons/NN-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/NN.svg rename to Orange/widgets/model/icons/NN-symbolic.svg diff --git a/Orange/widgets/model/icons/NaiveBayes.svg b/Orange/widgets/model/icons/NaiveBayes-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/NaiveBayes.svg rename to Orange/widgets/model/icons/NaiveBayes-symbolic.svg diff --git a/Orange/widgets/model/icons/PLS-symbolic.svg b/Orange/widgets/model/icons/PLS-symbolic.svg new file mode 100644 index 00000000000..a2aee45c5e1 --- /dev/null +++ b/Orange/widgets/model/icons/PLS-symbolic.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/model/icons/RandomForest.svg b/Orange/widgets/model/icons/RandomForest-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/RandomForest.svg rename to Orange/widgets/model/icons/RandomForest-symbolic.svg diff --git a/Orange/widgets/model/icons/SGD.svg b/Orange/widgets/model/icons/SGD-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/SGD.svg rename to Orange/widgets/model/icons/SGD-symbolic.svg diff --git a/Orange/widgets/model/icons/SVM.svg b/Orange/widgets/model/icons/SVM-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/SVM.svg rename to Orange/widgets/model/icons/SVM-symbolic.svg diff --git a/Orange/widgets/model/icons/SaveModel.svg b/Orange/widgets/model/icons/SaveModel-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/SaveModel.svg rename to Orange/widgets/model/icons/SaveModel-symbolic.svg diff --git a/Orange/widgets/model/icons/ScoringSheet-symbolic.svg b/Orange/widgets/model/icons/ScoringSheet-symbolic.svg new file mode 100644 index 00000000000..10e9d15958a --- /dev/null +++ b/Orange/widgets/model/icons/ScoringSheet-symbolic.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Orange/widgets/model/icons/Stacking.svg b/Orange/widgets/model/icons/Stacking-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/Stacking.svg rename to Orange/widgets/model/icons/Stacking-symbolic.svg diff --git a/Orange/widgets/model/icons/Tree.svg b/Orange/widgets/model/icons/Tree-symbolic.svg similarity index 100% rename from Orange/widgets/model/icons/Tree.svg rename to Orange/widgets/model/icons/Tree-symbolic.svg diff --git a/Orange/widgets/model/owadaboost.py b/Orange/widgets/model/owadaboost.py index 50ecc0f9c9b..2e9bff37e21 100644 --- a/Orange/widgets/model/owadaboost.py +++ b/Orange/widgets/model/owadaboost.py @@ -1,4 +1,5 @@ from AnyQt.QtCore import Qt +from AnyQt.QtWidgets import QFormLayout, QLabel from Orange.base import Learner from Orange.data import Table @@ -14,27 +15,24 @@ class OWAdaBoost(OWBaseLearner): name = "AdaBoost" description = "An ensemble meta-algorithm that combines weak learners " \ "and adapts to the 'hardness' of each training sample. " - icon = "icons/AdaBoost.svg" + icon = "icons/AdaBoost-symbolic.svg" replaces = [ "Orange.widgets.classify.owadaboost.OWAdaBoostClassification", "Orange.widgets.regression.owadaboostregression.OWAdaBoostRegression", ] priority = 80 - keywords = ["boost"] + keywords = "adaboost, boost" LEARNER = SklAdaBoostLearner class Inputs(OWBaseLearner.Inputs): learner = Input("Learner", Learner) - #: Algorithms for classification problems - algorithms = ["SAMME", "SAMME.R"] #: Losses for regression problems losses = ["Linear", "Square", "Exponential"] n_estimators = Setting(50) learning_rate = Setting(1.) - algorithm_index = Setting(1) loss_index = Setting(0) use_random_seed = Setting(False) random_seed = Setting(0) @@ -46,46 +44,45 @@ class Error(OWBaseLearner.Error): def add_main_layout(self): # this is part of init, pylint: disable=attribute-defined-outside-init - box = gui.widgetBox(self.controlArea, "Parameters") + grid = QFormLayout() + gui.widgetBox(self.controlArea, box=True, orientation=grid) self.base_estimator = self.DEFAULT_BASE_ESTIMATOR - self.base_label = gui.label( - box, self, "Base estimator: " + self.base_estimator.name.title()) + self.base_label = QLabel(self.base_estimator.name.title()) + grid.addRow("Base estimator:", self.base_label) self.n_estimators_spin = gui.spin( - box, self, "n_estimators", 1, 10000, label="Number of estimators:", - alignment=Qt.AlignRight, controlWidth=80, + None, self, "n_estimators", 1, 10000, + controlWidth=80, alignment=Qt.AlignRight, callback=self.settings_changed) + grid.addRow("Number of estimators:", self.n_estimators_spin) + self.learning_rate_spin = gui.doubleSpin( - box, self, "learning_rate", 1e-5, 1.0, 1e-5, - label="Learning rate:", decimals=5, alignment=Qt.AlignRight, - controlWidth=80, callback=self.settings_changed) + None, self, "learning_rate", 1e-5, 1.0, 1e-5, decimals=5, + alignment=Qt.AlignRight, + callback=self.settings_changed) + grid.addRow("Learning rate:", self.learning_rate_spin) + + self.reg_algorithm_combo = gui.comboBox( + None, self, "loss_index", items=self.losses, + callback=self.settings_changed) + grid.addRow("Loss (regression):", self.reg_algorithm_combo) + + box = gui.widgetBox(self.controlArea, box="Reproducibility") self.random_seed_spin = gui.spin( box, self, "random_seed", 0, 2 ** 31 - 1, controlWidth=80, label="Fixed seed for random generator:", alignment=Qt.AlignRight, callback=self.settings_changed, checked="use_random_seed", checkCallback=self.settings_changed) - # Algorithms - box = gui.widgetBox(self.controlArea, "Boosting method") - self.cls_algorithm_combo = gui.comboBox( - box, self, "algorithm_index", label="Classification algorithm:", - items=self.algorithms, - orientation=Qt.Horizontal, callback=self.settings_changed) - self.reg_algorithm_combo = gui.comboBox( - box, self, "loss_index", label="Regression loss function:", - items=self.losses, - orientation=Qt.Horizontal, callback=self.settings_changed) - def create_learner(self): if self.base_estimator is None: return None return self.LEARNER( - base_estimator=self.base_estimator, + estimator=self.base_estimator, n_estimators=self.n_estimators, learning_rate=self.learning_rate, random_state=self.random_seed, preprocessors=self.preprocessors, - algorithm=self.algorithms[self.algorithm_index], loss=self.losses[self.loss_index].lower()) @Inputs.learner @@ -97,19 +94,15 @@ def set_base_learner(self, learner): # Clear the error and reset to default base learner self.Error.no_weight_support() self.base_estimator = None - self.base_label.setText("Base estimator: INVALID") + self.base_label.setText("INVALID") else: self.base_estimator = learner or self.DEFAULT_BASE_ESTIMATOR - self.base_label.setText( - "Base estimator: %s" % self.base_estimator.name.title()) - if self.auto_apply: - self.apply() + self.base_label.setText(self.base_estimator.name.title()) + self.learner = self.model = None def get_learner_parameters(self): return (("Base estimator", self.base_estimator), ("Number of estimators", self.n_estimators), - ("Algorithm (classification)", self.algorithms[ - self.algorithm_index].capitalize()), ("Loss (regression)", self.losses[ self.loss_index].capitalize())) diff --git a/Orange/widgets/model/owcalibratedlearner.py b/Orange/widgets/model/owcalibratedlearner.py index 0edf3184797..8351d1e69c0 100644 --- a/Orange/widgets/model/owcalibratedlearner.py +++ b/Orange/widgets/model/owcalibratedlearner.py @@ -1,3 +1,5 @@ +import copy + from Orange.classification import CalibratedLearner, ThresholdLearner, \ NaiveBayesLearner from Orange.data import Table @@ -13,9 +15,9 @@ class OWCalibratedLearner(OWBaseLearner): name = "Calibrated Learner" description = "Wraps another learner with probability calibration and " \ "decision threshold optimization" - icon = "icons/CalibratedLearner.svg" + icon = "icons/CalibratedLearner-symbolic.svg" priority = 20 - keywords = ["calibration", "threshold"] + keywords = "calibrated learner, calibration, threshold" LEARNER = CalibratedLearner @@ -62,27 +64,23 @@ def add_main_layout(self): def set_learner(self, learner): self.base_learner = learner self._set_default_name() - self.unconditional_apply() + self.learner = self.model = None def _set_default_name(self): if self.base_learner is None: - self.name = "Calibrated learner" + self.set_default_learner_name("") else: - self.name = " + ".join(part for part in ( + name = " + ".join(part for part in ( self.base_learner.name.title(), self.CalibrationShort[self.calibration], self.ThresholdShort[self.threshold]) if part) - self.controls.learner_name.setPlaceholderText(self.name) + self.set_default_learner_name(name) def calibration_options_changed(self): self._set_default_name() self.apply() def create_learner(self): - class IdentityWrapper(Learner): - def fit_storage(self, data): - return self.base_learner.fit_storage(data) - if self.base_learner is None: return None learner = self.base_learner @@ -92,10 +90,11 @@ def fit_storage(self, data): if self.threshold != self.NoThresholdOptimization: learner = ThresholdLearner(learner, self.ThresholdMap[self.threshold]) + if learner is self.base_learner: + learner = copy.deepcopy(learner) if self.preprocessors: - if learner is self.base_learner: - learner = IdentityWrapper() learner.preprocessors = (self.preprocessors, ) + assert learner is not self.base_learner return learner def get_learner_parameters(self): diff --git a/Orange/widgets/model/owconstant.py b/Orange/widgets/model/owconstant.py index ec8dd71917e..27ec4a93c16 100644 --- a/Orange/widgets/model/owconstant.py +++ b/Orange/widgets/model/owconstant.py @@ -8,13 +8,13 @@ class OWConstant(OWBaseLearner): name = "Constant" description = "Predict the most frequent class or mean value " \ "from the training set." - icon = "icons/Constant.svg" + icon = "icons/Constant-symbolic.svg" replaces = [ "Orange.widgets.classify.owmajority.OWMajority", "Orange.widgets.regression.owmean.OWMean", ] priority = 10 - keywords = ["majority", "mean"] + keywords = "constant, majority, mean" LEARNER = ConstantLearner diff --git a/Orange/widgets/model/owcurvefit.py b/Orange/widgets/model/owcurvefit.py new file mode 100644 index 00000000000..81d8fd0e2dc --- /dev/null +++ b/Orange/widgets/model/owcurvefit.py @@ -0,0 +1,523 @@ +import ast +from itertools import chain +from typing import Optional, List, Tuple, Any, Mapping + +import numpy as np + +from AnyQt.QtCore import Signal +from AnyQt.QtWidgets import QSizePolicy, QWidget, QGridLayout, QLabel, \ + QLineEdit, QVBoxLayout, QPushButton, QDoubleSpinBox, QCheckBox + +from orangewidget.utils.combobox import ComboBoxSearch +from Orange.data import Table, ContinuousVariable +from Orange.data.util import sanitized_name +from Orange.preprocess import Preprocess +from Orange.regression import CurveFitLearner +from Orange.widgets import gui +from Orange.widgets.data.owfeatureconstructor import validate_exp +from Orange.widgets.settings import Setting +from Orange.widgets.utils.itemmodels import DomainModel, PyListModel, \ + PyListModelTooltip +from Orange.widgets.utils.owlearnerwidget import OWBaseLearner +from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.widget import Output, Msg + +FUNCTIONS = {k: v for k, v in np.__dict__.items() if k in + ("isclose", "inf", "nan", "arccos", "arccosh", "arcsin", + "arcsinh", "arctan", "arctan2", "arctanh", "ceil", "copysign", + "cos", "cosh", "degrees", "e", "exp", "expm1", "fabs", "floor", + "fmod", "gcd", "hypot", "isfinite", "isinf", "isnan", "ldexp", + "log", "log10", "log1p", "log2", "pi", "power", "radians", + "remainder", "sin", "sinh", "sqrt", "tan", "tanh", "trunc", + "round", "abs", "any", "all")} + + +class Parameter: + def __init__(self, name: str, initial: float = 1, use_lower: bool = False, + lower=0, use_upper: bool = False, upper: float = 100): + self.name = name + self.initial = initial + self.use_lower = use_lower + self.lower = lower + self.use_upper = use_upper + self.upper = upper + + def to_tuple(self) -> Tuple[str, float, bool, float, bool, float]: + return (self.name, self.initial, self.use_lower, + self.lower, self.use_upper, self.upper) + + def __repr__(self) -> str: + return f"Parameter(name={self.name}, initial={self.initial}, " \ + f"use_lower={self.use_lower}, lower={self.lower}, " \ + f"use_upper={self.use_upper}, upper={self.upper})" + + +class ParametersWidget(QWidget): + REMOVE, NAME, INITIAL, LOWER, LOWER_SPIN, UPPER, UPPER_SPIN = range(7) + sigDataChanged = Signal(list) + + def __init__(self, parent: QWidget): + super().__init__(parent) + self.__data: List[Parameter] = [] + self.__labels: List[QLabel] = None + self.__layout: QGridLayout = None + self.__controls: List[ + Tuple[QPushButton, QLineEdit, QDoubleSpinBox, QCheckBox, + QDoubleSpinBox, QCheckBox, QDoubleSpinBox]] = [] + self._setup_gui() + + @property + def _remove_buttons(self) -> List[QPushButton]: + return [controls[self.REMOVE] for controls in self.__controls] + + @property + def _name_edits(self) -> List[QLineEdit]: + return [controls[self.NAME] for controls in self.__controls] + + @property + def _init_spins(self) -> List[QDoubleSpinBox]: + return [controls[self.INITIAL] for controls in self.__controls] + + @property + def _lower_checks(self) -> List[QCheckBox]: + return [controls[self.LOWER] for controls in self.__controls] + + @property + def _lower_spins(self) -> List[QDoubleSpinBox]: + return [controls[self.LOWER_SPIN] for controls in self.__controls] + + @property + def _upper_checks(self) -> List[QCheckBox]: + return [controls[self.UPPER] for controls in self.__controls] + + @property + def _upper_spins(self) -> List[QDoubleSpinBox]: + return [controls[self.UPPER_SPIN] for controls in self.__controls] + + def _setup_gui(self): + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.setLayout(layout) + + box = gui.vBox(self, box=False) + self.__layout = QGridLayout() + box.layout().addLayout(self.__layout) + + self.__labels = [QLabel("Name"), QLabel("Initial value"), + QLabel("Lower bound"), QLabel("Upper bound")] + self.__layout.addWidget(self.__labels[0], 0, self.NAME) + self.__layout.addWidget(self.__labels[1], 0, self.INITIAL) + self.__layout.addWidget(self.__labels[2], 0, self.LOWER, 1, 2) + self.__layout.addWidget(self.__labels[3], 0, self.UPPER, 1, 2) + self._set_labels_visible(False) + + button_box = gui.hBox(box) + gui.rubber(button_box) + gui.button( + button_box, self, "+", callback=self.__on_add_button_clicked, + width=34, autoDefault=False, enabled=True, + sizePolicy=(QSizePolicy.Maximum, QSizePolicy.Maximum) + ) + + def __on_add_button_clicked(self): + self._add_row() + self.sigDataChanged.emit(self.__data) + + def _add_row(self, parameter: Optional[Parameter] = None): + row_id = len(self.__controls) + if parameter is None: + parameter = Parameter(f"p{row_id + 1}") + + edit = QLineEdit(text=parameter.name) + edit.setFixedWidth(60) + edit.textChanged.connect(self.__on_text_changed) + + button = gui.button( + None, self, "×", callback=self.__on_remove_button_clicked, + autoDefault=False, width=34, + sizePolicy=(QSizePolicy.Maximum, + QSizePolicy.Maximum) + ) + kwargs = {"minimum": -2147483647, "maximum": 2147483647} + init_spin = QDoubleSpinBox(decimals=4, **kwargs) + lower_spin = QDoubleSpinBox(**kwargs) + upper_spin = QDoubleSpinBox(**kwargs) + init_spin.setValue(parameter.initial) + lower_spin.setValue(parameter.lower) + upper_spin.setValue(parameter.upper) + lower_check = QCheckBox(checked=bool(parameter.use_lower)) + upper_check = QCheckBox(checked=bool(parameter.use_upper)) + + lower_spin.setEnabled(lower_check.isChecked()) + upper_spin.setEnabled(upper_check.isChecked()) + + init_spin.valueChanged.connect(self.__on_init_spin_changed) + lower_spin.valueChanged.connect(self.__on_lower_spin_changed) + upper_spin.valueChanged.connect(self.__on_upper_spin_changed) + lower_check.stateChanged.connect(self.__on_lower_check_changed) + upper_check.stateChanged.connect(self.__on_upper_check_changed) + + controls = (button, edit, init_spin, lower_check, + lower_spin, upper_check, upper_spin) + n_rows = self.__layout.rowCount() + for i, control in enumerate(controls): + self.__layout.addWidget(control, n_rows, i) + + self.__data.append(parameter) + self.__controls.append(controls) + self._set_labels_visible(True) + + def __on_text_changed(self): + line_edit: QLineEdit = self.sender() + row_id = self._name_edits.index(line_edit) + self.__data[row_id].name = line_edit.text() + self.sigDataChanged.emit(self.__data) + + def __on_init_spin_changed(self): + spin: QDoubleSpinBox = self.sender() + row_id = self._init_spins.index(spin) + self.__data[row_id].initial = spin.value() + self.sigDataChanged.emit(self.__data) + + def __on_lower_check_changed(self): + check: QCheckBox = self.sender() + row_id = self._lower_checks.index(check) + self.__data[row_id].use_lower = check.isChecked() + self.sigDataChanged.emit(self.__data) + self._lower_spins[row_id].setEnabled(check.isChecked()) + + def __on_lower_spin_changed(self): + spin: QDoubleSpinBox = self.sender() + row_id = self._lower_spins.index(spin) + self.__data[row_id].lower = spin.value() + self.sigDataChanged.emit(self.__data) + + def __on_upper_check_changed(self): + check: QCheckBox = self.sender() + row_id = self._upper_checks.index(check) + self.__data[row_id].use_upper = check.isChecked() + self.sigDataChanged.emit(self.__data) + self._upper_spins[row_id].setEnabled(check.isChecked()) + + def __on_upper_spin_changed(self): + spin: QDoubleSpinBox = self.sender() + row_id = self._upper_spins.index(spin) + self.__data[row_id].upper = spin.value() + self.sigDataChanged.emit(self.__data) + + def __on_remove_button_clicked(self): + index = self._remove_buttons.index(self.sender()) + self._remove_row(index) + self.sigDataChanged.emit(self.__data) + + def _remove_row(self, row_index: int): + assert len(self.__controls) > row_index + for col_index in range(len(self.__controls[row_index])): + widget = self.__controls[row_index][col_index] + if widget is not None: + self.__layout.removeWidget(widget) + widget.deleteLater() + del self.__controls[row_index] + del self.__data[row_index] + if len(self.__controls) == 0: + self._set_labels_visible(False) + + def _set_labels_visible(self, visible: bool): + for label in self.__labels: + label.setVisible(visible) + + def _remove_rows(self): + for row in range(len(self.__controls) - 1, -1, -1): + self._remove_row(row) + self.__data.clear() + self.__controls.clear() + + def clear_all(self): + self._remove_rows() + + def set_data(self, parameters: List[Parameter]): + self._remove_rows() + for param in parameters: + self._add_row(param) + + +class OWCurveFit(OWBaseLearner): + name = "Curve Fit" + description = "Fit a function to data." + icon = "icons/CurveFit-symbolic.svg" + priority = 90 + keywords = "curve fit, function" + + class Outputs(OWBaseLearner.Outputs): + coefficients = Output("Coefficients", Table, explicit=True) + + class Warning(OWBaseLearner.Warning): + duplicate_parameter = Msg("Duplicated parameter name.") + unused_parameter = Msg("Unused parameter '{}' in " + "'Parameters' declaration.") + data_missing = Msg("Provide data on the input.") + + class Error(OWBaseLearner.Error): + invalid_exp = Msg("Invalid expression.") + no_parameter = Msg("Missing a fitting parameter.\n" + "Use 'Feature Constructor' widget instead.") + unknown_parameter = Msg("Unknown parameter '{}'.\n" + "Declare the parameter in 'Parameters' box") + parameter_in_attrs = Msg("Some parameters and features have the same " + "name '{}'.") + + LEARNER = CurveFitLearner + supports_sparse = False + + parameters: Mapping[str, Tuple[Any, ...]] = Setting({}, schema_only=True) + expression: str = Setting("", schema_only=True) + + FEATURE_PLACEHOLDER = "Select Feature" + PARAM_PLACEHOLDER = "Select Parameter" + FUNCTION_PLACEHOLDER = "Select Function" + + _feature: Optional[ContinuousVariable] = None + _parameter: str = PARAM_PLACEHOLDER + _function: str = FUNCTION_PLACEHOLDER + + def __init__(self, *args, **kwargs): + self.__pp_data: Optional[Table] = None + self.__param_widget: ParametersWidget = None + self.__expression_edit: QLineEdit = None + self.__feature_combo: ComboBoxSearch = None + self.__parameter_combo: ComboBoxSearch = None + self.__function_combo: ComboBoxSearch = None + + self.__feature_model = DomainModel( + order=DomainModel.ATTRIBUTES, + placeholder=self.FEATURE_PLACEHOLDER, + separators=False, valid_types=ContinuousVariable + ) + self.__param_model = PyListModel([self.PARAM_PLACEHOLDER]) + + self.__pending_parameters = self.parameters + self.__pending_expression = self.expression + + super().__init__(*args, **kwargs) + + self.Warning.data_missing() + + def add_main_layout(self): + box = gui.vBox(self.controlArea, "Parameters") + self.__param_widget = ParametersWidget(self) + self.__param_widget.sigDataChanged.connect( + self.__on_parameters_changed) + box.layout().addWidget(self.__param_widget) + + function_box = gui.vBox(self.controlArea, box="Expression") + self.__expression_edit = gui.lineEdit( + function_box, self, "expression", + placeholderText="Expression...", callback=self.settings_changed + ) + hbox = gui.hBox(function_box) + combo_options = dict(sendSelectedValue=True, searchable=True, + contentsLength=13) + self.__feature_combo = gui.comboBox( + hbox, self, "_feature", model=self.__feature_model, + callback=self.__on_feature_added, **combo_options + ) + self.__parameter_combo = gui.comboBox( + hbox, self, "_parameter", model=self.__param_model, + callback=self.__on_parameter_added, **combo_options + ) + sorted_funcs = sorted(FUNCTIONS) + function_model = PyListModelTooltip( + chain([self.FUNCTION_PLACEHOLDER], sorted_funcs), + [""] + [FUNCTIONS[f].__doc__ for f in sorted_funcs], + parent=self + ) + self.__function_combo = gui.comboBox( + hbox, self, "_function", model=function_model, + callback=self.__on_function_added, **combo_options + ) + + def __on_parameters_changed(self, parameters: List[Parameter]): + self.parameters = params = {p.name: p.to_tuple() for p in parameters} + self.__param_model[:] = chain([self.PARAM_PLACEHOLDER], params) + self.settings_changed() + self.Error.parameter_in_attrs.clear() + self.Warning.duplicate_parameter.clear() + if len(self.parameters) != len(parameters): + self.Warning.duplicate_parameter() + names = [f.name for f in self.__feature_model[1:]] + forbidden = [p.name for p in parameters if p.name in names] + if forbidden: + self.Error.parameter_in_attrs(forbidden[0]) + + def __on_feature_added(self): + index = self.__feature_combo.currentIndex() + if index > 0: + self.__insert_into_expression(sanitized_name(self._feature.name)) + self.__feature_combo.setCurrentIndex(0) + self.settings_changed() + + def __on_parameter_added(self): + index = self.__parameter_combo.currentIndex() + if index > 0: + self.__insert_into_expression(sanitized_name(self._parameter)) + self.__parameter_combo.setCurrentIndex(0) + self.settings_changed() + + def __on_function_added(self): + index = self.__function_combo.currentIndex() + if index > 0: + if not callable(FUNCTIONS[self._function]): # e, pi, inf, nan + self.__insert_into_expression(self._function) + elif self._function in [ + "arctan2", "copysign", "fmod", "gcd", "hypot", + "isclose", "ldexp", "power", "remainder" + ]: + self.__insert_into_expression(self._function + "(,)", 2) + else: + self.__insert_into_expression(self._function + "()", 1) + self.__function_combo.setCurrentIndex(0) + self.settings_changed() + + def __insert_into_expression(self, what: str, offset=0): + pos = self.__expression_edit.cursorPosition() + text = self.__expression_edit.text() + self.__expression_edit.setText(text[:pos] + what + text[pos:]) + self.__expression_edit.setCursorPosition(pos + len(what) - offset) + self.__expression_edit.setFocus() + + @OWBaseLearner.Inputs.data + def set_data(self, data: Optional[Table]): + self.Warning.data_missing(shown=not bool(data)) + self.learner = None + super().set_data(data) + + def set_preprocessor(self, preprocessor: Preprocess): + self.preprocessors = preprocessor + feature_names_changed = False + if self.data and self.__pp_data: + pp_data = preprocess(self.data, preprocessor) + feature_names_changed = \ + set(a.name for a in pp_data.domain.attributes) != \ + set(a.name for a in self.__pp_data.domain.attributes) + if feature_names_changed: + self.expression = "" + + def handleNewSignals(self): + self.__preprocess_data() + self.__init_models() + self.__set_pending() + super().handleNewSignals() + + def __preprocess_data(self): + self.__pp_data = preprocess(self.data, self.preprocessors) + + def __init_models(self): + domain = self.__pp_data.domain if self.__pp_data else None + self.__feature_model.set_domain(domain) + self._feature = self.__feature_model[0] + + def __set_pending(self): + if self.__pending_parameters: + parameters = [Parameter(*p) for p in + self.__pending_parameters.values()] + self.__param_widget.set_data(parameters) + self.__on_parameters_changed(parameters) + self.__pending_parameters = [] + + if self.__pending_expression: + self.expression = self.__pending_expression + self.__pending_expression = "" + + def create_learner(self) -> Optional[CurveFitLearner]: + self.Error.invalid_exp.clear() + self.Error.no_parameter.clear() + self.Error.unknown_parameter.clear() + self.Warning.unused_parameter.clear() + expression = self.expression.strip() + if not self.__pp_data or not expression: + return None + + if not self.__validate_expression(expression): + self.Error.invalid_exp() + return None + + p0, bounds = {}, {} + for name in self.parameters: + param = Parameter(*self.parameters[name]) + p0[name] = param.initial + bounds[name] = (param.lower if param.use_lower else -np.inf, + param.upper if param.use_upper else np.inf) + + learner = self.LEARNER( + expression, + available_feature_names=[a.name for a in self.__feature_model[1:]], + functions=FUNCTIONS, + sanitizer=sanitized_name, + p0=p0, + bounds=bounds, + preprocessors=self.preprocessors + ) + + params_names = learner.parameters_names + if not params_names: + self.Error.no_parameter() + return None + unknown = [p for p in params_names if p not in self.parameters] + if unknown: + self.Error.unknown_parameter(unknown[0]) + return None + unused = [p for p in self.parameters if p not in params_names] + if unused: + self.Warning.unused_parameter(unused[0]) + + return learner + + def get_learner_parameters(self) -> Tuple[Tuple[str, Any]]: + return (("Expression", self.expression),) + + def update_model(self): + super().update_model() + coefficients = None + if self.model is not None: + coefficients = self.model.coefficients + self.Outputs.coefficients.send(coefficients) + + def check_data(self): + learner_existed = self.learner is not None + if self.data: + data = preprocess(self.data, self.preprocessors) + dom = data.domain + cont_attrs = [a for a in dom.attributes if a.is_continuous] + if len(cont_attrs) == 0: + self.Error.data_error("Data has no continuous features.") + elif not self.learner: + # create dummy learner in order to check data + self.learner = self.LEARNER(lambda: 1, [], []) + # parent's check_data() needs learner instantiated + self.valid_data = super().check_data() + if not learner_existed: + self.valid_data = False + self.learner = None + return self.valid_data + + @staticmethod + def __validate_expression(expression: str): + try: + tree = ast.parse(expression, mode="eval") + valid = validate_exp(tree) + # pylint: disable=broad-except + except Exception: + return False + return valid + + +def preprocess(data: Optional[Table], preprocessor: Optional[Preprocess]) \ + -> Optional[Table]: + if not data or not preprocessor: + return data + return preprocessor(data) + + +if __name__ == "__main__": # pragma: no cover + WidgetPreview(OWCurveFit).run(Table("housing")) diff --git a/Orange/widgets/model/owgradientboosting.py b/Orange/widgets/model/owgradientboosting.py index a7dda04d7e5..131b8b4754e 100644 --- a/Orange/widgets/model/owgradientboosting.py +++ b/Orange/widgets/model/owgradientboosting.py @@ -97,7 +97,7 @@ def get_arguments(self) -> Dict: return { "n_estimators": self.n_estimators, "learning_rate": self.learning_rate, - "random_state": 0 if self.random_state else randint(1, 1e6), + "random_state": 0 if self.random_state else randint(1, 1000000), "max_depth": self.max_depth, } @@ -287,10 +287,9 @@ class XGBRFLearnerEditor(XGBBaseEditor): class OWGradientBoosting(OWBaseLearner): name = "Gradient Boosting" description = "Predict using gradient boosting on decision trees." - icon = "icons/GradientBoosting.svg" + icon = "icons/GradientBoosting-symbolic.svg" priority = 45 - keywords = ["catboost", "gradient", "boost", "tree", "forest", - "xgb", "gb", "extreme"] + keywords = "gradient boosting, catboost, gradient, boost, tree, forest, xgb, gb, extreme" LEARNER: Learner = GBLearner editor: BaseEditor = None diff --git a/Orange/widgets/model/owknn.py b/Orange/widgets/model/owknn.py index e7207d13fc1..37ea19d59a4 100644 --- a/Orange/widgets/model/owknn.py +++ b/Orange/widgets/model/owknn.py @@ -11,20 +11,22 @@ class OWKNNLearner(OWBaseLearner): name = "kNN" description = "Predict according to the nearest training instances." - icon = "icons/KNN.svg" + icon = "icons/KNN-symbolic.svg" replaces = [ "Orange.widgets.classify.owknn.OWKNNLearner", "Orange.widgets.regression.owknnregression.OWKNNRegression", ] priority = 20 - keywords = ["k nearest", "knearest", "neighbor", "neighbour"] + keywords = "knn, k nearest, knearest, neighbor, neighbour" LEARNER = KNNLearner weights = ["uniform", "distance"] - metrics = ["euclidean", "manhattan", "chebyshev", "mahalanobis"] + metrics = ["euclidean", "manhattan", "chebyshev", "mahalanobis", "cosine"] + + weights_options = ["Uniform", "By Distances"] + metrics_options = ["Euclidean", "Manhattan", "Chebyshev", "Mahalanobis", "Cosine"] - learner_name = Setting("kNN") n_neighbors = Setting(5) metric_index = Setting(0) weight_index = Setting(0) @@ -38,24 +40,26 @@ def add_main_layout(self): controlWidth=80) self.metrics_combo = gui.comboBox( box, self, "metric_index", orientation=Qt.Horizontal, - label="Metric:", items=[i.capitalize() for i in self.metrics], + label="Metric:", items=self.metrics_options, callback=self.settings_changed) self.weights_combo = gui.comboBox( box, self, "weight_index", orientation=Qt.Horizontal, - label="Weight:", items=[i.capitalize() for i in self.weights], + label="Weight:", items=self.weights_options, callback=self.settings_changed) def create_learner(self): return self.LEARNER( n_neighbors=self.n_neighbors, + # false positive, pylint: disable=invalid-sequence-index metric=self.metrics[self.metric_index], weights=self.weights[self.weight_index], preprocessors=self.preprocessors) def get_learner_parameters(self): return (("Number of neighbours", self.n_neighbors), - ("Metric", self.metrics[self.metric_index].capitalize()), - ("Weight", self.weights[self.weight_index].capitalize())) + ("Metric", self.metrics_options[self.metric_index]), + # false positive, pylint: disable=invalid-sequence-index + ("Weight", self.weights_options[self.weight_index])) if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/model/owlinearregression.py b/Orange/widgets/model/owlinearregression.py index 5103a9baf84..1d872edc0d6 100644 --- a/Orange/widgets/model/owlinearregression.py +++ b/Orange/widgets/model/owlinearregression.py @@ -18,12 +18,12 @@ class OWLinearRegression(OWBaseLearner): name = "Linear Regression" description = "A linear regression algorithm with optional L1 (LASSO), " \ "L2 (ridge) or L1L2 (elastic net) regularization." - icon = "icons/LinearRegression.svg" + icon = "icons/LinearRegression-symbolic.svg" replaces = [ "Orange.widgets.regression.owlinearregression.OWLinearRegression", ] priority = 60 - keywords = ["ridge", "lasso", "elastic net"] + keywords = "linear regression, ridge, lasso, elastic net" LEARNER = LinearRegressionLearner @@ -79,7 +79,7 @@ def add_main_layout(self): gui.widgetLabel(box5, "L1") self.l2_ratio_slider = gui.hSlider( box5, self, "l2_ratio", minValue=0.01, maxValue=0.99, - intOnly=False, ticks=0.1, createLabel=False, width=120, + intOnly=False, createLabel=False, width=120, step=0.01, callback=self._l2_ratio_changed) gui.widgetLabel(box5, "L2") self.l2_ratio_label = gui.widgetLabel( @@ -94,9 +94,6 @@ def add_main_layout(self): self.controls.alpha_index.setEnabled(self.reg_type != self.OLS) self.l2_ratio_slider.setEnabled(self.reg_type == self.Elastic) - def handleNewSignals(self): - self.apply() - def _intercept_changed(self): self.apply() diff --git a/Orange/widgets/model/owloadmodel.py b/Orange/widgets/model/owloadmodel.py index dd6edabc189..9ed47937388 100644 --- a/Orange/widgets/model/owloadmodel.py +++ b/Orange/widgets/model/owloadmodel.py @@ -1,25 +1,29 @@ import os import pickle +from typing import Any, Dict from AnyQt.QtWidgets import QSizePolicy, QStyle, QFileDialog -from AnyQt.QtCore import QTimer +from AnyQt.QtCore import QTimer, QUrl + +from orangewidget.workflow.drophandler import SingleFileDropHandler from Orange.base import Model from Orange.widgets import widget, gui from Orange.widgets.model import owsavemodel -from Orange.widgets.utils.filedialogs import RecentPathsWComboMixin +from Orange.widgets.utils.filedialogs import RecentPathsWComboMixin, RecentPath, \ + stored_recent_paths_prepend, OWUrlDropBase from Orange.widgets.utils import stdpaths from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Msg, Output -class OWLoadModel(widget.OWWidget, RecentPathsWComboMixin): +class OWLoadModel(OWUrlDropBase, RecentPathsWComboMixin): name = "Load Model" description = "Load a model from an input file." priority = 3050 replaces = ["Orange.widgets.classify.owloadclassifier.OWLoadClassifier"] - icon = "icons/LoadModel.svg" - keywords = ["file", "open", "model"] + icon = "icons/LoadModel-symbolic.svg" + keywords = "load model, file, open, model" class Outputs: model = Output("Model", Model) @@ -60,7 +64,7 @@ def __init__(self): def browse_file(self): start_file = self.last_path() or stdpaths.Documents filename, _ = QFileDialog.getOpenFileName( - self, 'Open Distance File', start_file, self.FILTER) + self, 'Open Model File', start_file, self.FILTER) if not filename: return self.add_path(filename) @@ -87,6 +91,29 @@ def open_file(self): else: self.Outputs.model.send(model) + def canDropUrl(self, url: QUrl) -> bool: + if url.isLocalFile(): + return OWLoadModelDropHandler().canDropFile(url.toLocalFile()) + else: + return False + + def handleDroppedUrl(self, url: QUrl) -> None: + if url.isLocalFile(): + self.add_path(url.toLocalFile()) + self.open_file() + + +class OWLoadModelDropHandler(SingleFileDropHandler): + WIDGET = OWLoadModel + + def canDropFile(self, path: str) -> bool: + return path.endswith(".pkcls") + + def parametersFromFile(self, path: str) -> Dict[str, Any]: + r = RecentPath(os.path.abspath(path), None, None, + os.path.basename(path)) + return {"recent_paths": stored_recent_paths_prepend(self.WIDGET, r)} + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWLoadModel).run() diff --git a/Orange/widgets/model/owlogisticregression.py b/Orange/widgets/model/owlogisticregression.py index 6bbb8e2092e..e3c7e178fac 100644 --- a/Orange/widgets/model/owlogisticregression.py +++ b/Orange/widgets/model/owlogisticregression.py @@ -2,6 +2,8 @@ import numpy as np from AnyQt.QtCore import Qt +from orangewidget.report import bool_str + from Orange.data import Table, Domain, ContinuousVariable, StringVariable from Orange.classification.logistic_regression import LogisticRegressionLearner from Orange.widgets import settings, gui @@ -11,23 +13,23 @@ from Orange.widgets.widget import Msg - class OWLogisticRegression(OWBaseLearner): name = "Logistic Regression" description = "The logistic regression classification algorithm with " \ "LASSO (L1) or ridge (L2) regularization." - icon = "icons/LogisticRegression.svg" + icon = "icons/LogisticRegression-symbolic.svg" replaces = [ "Orange.widgets.classify.owlogisticregression.OWLogisticRegression", ] priority = 60 - keywords = [] + keywords = "logistic regression" LEARNER = LogisticRegressionLearner class Outputs(OWBaseLearner.Outputs): coefficients = Output("Coefficients", Table, explicit=True) + settings_version = 2 penalty_type = settings.Setting(1) C_index = settings.Setting(61) class_weight = settings.Setting(False) @@ -39,14 +41,15 @@ class Outputs(OWBaseLearner.Outputs): [x / 10 for x in range(9, 2, -1)], [x / 100 for x in range(20, 2, -1)], [x / 1000 for x in range(20, 0, -1)])) + strength_C = C_s[61] dual = False tol = 0.0001 fit_intercept = True intercept_scaling = 1.0 max_iter = 10000 - penalty_types = ("Lasso (L1)", "Ridge (L2)") - penalty_types_short = ["l1", "l2"] + penalty_types = ("Lasso (L1)", "Ridge (L2)", "None") + penalty_types_short = ["l1", "l2", None] class Warning(OWBaseLearner.Warning): class_weights_used = Msg("Weighting by class may decrease performance.") @@ -57,7 +60,8 @@ def add_main_layout(self): self.penalty_combo = gui.comboBox( box, self, "penalty_type", label="Regularization type: ", items=self.penalty_types, orientation=Qt.Horizontal, - callback=self.settings_changed) + callback=self._penalty_type_changed) + self.c_box = box = gui.widgetBox(box) gui.widgetLabel(box, "Strength:") box2 = gui.hBox(gui.indentedBox(box)) gui.widgetLabel(box2, "Weak").setStyleSheet("margin-top:6px") @@ -80,10 +84,23 @@ def add_main_layout(self): ) def set_c(self): - # called from init, pylint: disable=attribute-defined-outside-init self.strength_C = self.C_s[self.C_index] - fmt = "C={}" if self.strength_C >= 1 else "C={:.3f}" - self.c_label.setText(fmt.format(self.strength_C)) + penalty = self.penalty_types_short[self.penalty_type] + enable_c = penalty is not None + self.c_box.setEnabled(enable_c) + if enable_c: + fmt = "C={}" if self.strength_C >= 1 else "C={:.3f}" + self.c_label.setText(fmt.format(self.strength_C)) + else: + self.c_label.setText("N/A") + + def set_penalty(self, penalty): + self.penalty_type = self.penalty_types_short.index(penalty) + self._penalty_type_changed() + + def _penalty_type_changed(self): + self.set_c() + self.settings_changed() def create_learner(self): self.Warning.class_weights_used.clear() @@ -93,11 +110,15 @@ def create_learner(self): self.Warning.class_weights_used() else: class_weight = None + if penalty is None: + C = 1.0 + else: + C = self.strength_C return self.LEARNER( penalty=penalty, dual=self.dual, tol=self.tol, - C=self.strength_C, + C=C, class_weight=class_weight, fit_intercept=self.fit_intercept, intercept_scaling=self.intercept_scaling, @@ -114,9 +135,9 @@ def update_model(self): self.Outputs.coefficients.send(coef_table) def get_learner_parameters(self): - return (("Regularization", "{}, C={}, class weights={}".format( + return (("Regularization", "{}, C={}, class weights: {}".format( self.penalty_types[self.penalty_type], self.C_s[self.C_index], - self.class_weight)),) + bool_str(self.class_weight))),) def create_coef_table(classifier): diff --git a/Orange/widgets/model/ownaivebayes.py b/Orange/widgets/model/ownaivebayes.py index c58f24f6500..13da69ed4c5 100644 --- a/Orange/widgets/model/ownaivebayes.py +++ b/Orange/widgets/model/ownaivebayes.py @@ -11,12 +11,12 @@ class OWNaiveBayes(OWBaseLearner): name = "Naive Bayes" description = "A fast and simple probabilistic classifier based on " \ "Bayes' theorem with the assumption of feature independence." - icon = "icons/NaiveBayes.svg" + icon = "icons/NaiveBayes-symbolic.svg" replaces = [ "Orange.widgets.classify.ownaivebayes.OWNaiveBayes", ] priority = 70 - keywords = [] + keywords = "naive bayes" LEARNER = NaiveBayesLearner diff --git a/Orange/widgets/model/owneuralnetwork.py b/Orange/widgets/model/owneuralnetwork.py index ed43519623d..d8365327dc0 100644 --- a/Orange/widgets/model/owneuralnetwork.py +++ b/Orange/widgets/model/owneuralnetwork.py @@ -11,9 +11,12 @@ from AnyQt.QtCore import Qt, QThread, QObject from AnyQt.QtCore import pyqtSlot as Slot, pyqtSignal as Signal +from orangewidget.report import bool_str + from Orange.data import Table from Orange.modelling import NNLearner from Orange.widgets import gui +from Orange.widgets.widget import Msg from Orange.widgets.settings import Setting from Orange.widgets.utils.owlearnerwidget import OWBaseLearner @@ -65,9 +68,9 @@ class OWNNLearner(OWBaseLearner): name = "Neural Network" description = "A multi-layer perceptron (MLP) algorithm with " \ "backpropagation." - icon = "icons/NN.svg" + icon = "icons/NN-symbolic.svg" priority = 90 - keywords = ["mlp"] + keywords = "neural network, mlp" LEARNER = NNLearner @@ -76,16 +79,15 @@ class OWNNLearner(OWBaseLearner): solver = ["lbfgs", "sgd", "adam"] solv_lbl = ["L-BFGS-B", "SGD", "Adam"] - learner_name = Setting("Neural Network") hidden_layers_input = Setting("100,") activation_index = Setting(3) solver_index = Setting(2) max_iterations = Setting(200) - alpha_index = Setting(0) + alpha_index = Setting(1) replicable = Setting(True) - settings_version = 1 + settings_version = 2 - alphas = list(chain([x / 10000 for x in range(1, 10)], + alphas = list(chain([0], [x / 10000 for x in range(1, 10)], [x / 1000 for x in range(1, 10)], [x / 100 for x in range(1, 10)], [x / 10 for x in range(1, 10)], @@ -94,6 +96,11 @@ class OWNNLearner(OWBaseLearner): range(100, 200, 10), range(100, 1001, 50))) + class Warning(OWBaseLearner.Warning): + no_layers = Msg("ANN without hidden layers is equivalent to logistic " + "regression with worse fitting.\nWe recommend using " + "logistic regression.") + def add_main_layout(self): # this is part of init, pylint: disable=attribute-defined-outside-init form = QFormLayout() @@ -181,13 +188,13 @@ def get_learner_parameters(self): ("Solver", self.solv_lbl[self.solver_index]), ("Alpha", self.alpha), ("Max iterations", self.max_iterations), - ("Replicable training", self.replicable)) + ("Replicable training", bool_str(self.replicable))) def get_hidden_layers(self): + self.Warning.no_layers.clear() layers = tuple(map(int, re.findall(r'\d+', self.hidden_layers_input))) if not layers: - layers = (10,) - self.hidden_layers_input = "10," + self.Warning.no_layers() return layers def update_model(self): @@ -274,7 +281,7 @@ def _task_finished(self, f): self.model = None self.show_fitting_failed(ex) else: - self.model.name = self.learner_name + self.model.name = self.effective_learner_name() self.model.instances = self.data self.model.skl_model.orange_callback = None # remove unpicklable callback self.Outputs.model.send(self.model) @@ -306,6 +313,8 @@ def migrate_settings(cls, settings, version): if alpha is not None: settings["alpha_index"] = \ np.argmin(np.abs(np.array(cls.alphas) - alpha)) + elif version < 2: + settings["alpha_index"] = settings.get("alpha_index", 0) + 1 if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/model/owpls.py b/Orange/widgets/model/owpls.py new file mode 100644 index 00000000000..76134b792b9 --- /dev/null +++ b/Orange/widgets/model/owpls.py @@ -0,0 +1,159 @@ +import numpy as np +from AnyQt.QtCore import Qt +import scipy.sparse as sp + +from Orange.data import Table, Domain, ContinuousVariable, StringVariable, \ + DiscreteVariable +from Orange.regression import PLSRegressionLearner +from Orange.widgets import gui +from Orange.widgets.settings import Setting +from Orange.widgets.utils.owlearnerwidget import OWBaseLearner +from Orange.widgets.utils.signals import Output +from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.widget import Msg + + +class OWPLS(OWBaseLearner): + name = 'PLS' + description = "Partial Least Squares Regression widget for multivariate data analysis" + icon = "icons/PLS-symbolic.svg" + priority = 85 + keywords = "partial least squares" + + LEARNER = PLSRegressionLearner + + class Outputs(OWBaseLearner.Outputs): + coefsdata = Output("Coefficients and Loadings", Table, explicit=True) + data = Output("Data with Scores", Table) + components = Output("Components", Table) + + class Warning(OWBaseLearner.Warning): + sparse_data = Msg( + 'Sparse input data: default preprocessing is to scale it.') + + n_components = Setting(2) + max_iter = Setting(500) + scale = Setting(True) + + def add_main_layout(self): + optimization_box = gui.vBox( + self.controlArea, "Optimization Parameters") + gui.spin( + optimization_box, self, "n_components", 1, 50, 1, + label="Components: ", + alignment=Qt.AlignRight, controlWidth=100, + callback=self.settings_changed) + gui.spin( + optimization_box, self, "max_iter", 5, 1000000, 50, + label="Iteration limit: ", + alignment=Qt.AlignRight, controlWidth=100, + callback=self.settings_changed, + checkCallback=self.settings_changed) + gui.checkBox(optimization_box, self, "scale", + "Scale features and target", + callback=self.settings_changed) + + def update_model(self): + super().update_model() + coef_table = None + data = None + components = None + if self.model is not None: + coef_table = self._create_output_coeffs_loadings() + data = self._create_output_data() + components = self.model.components() + self.Outputs.coefsdata.send(coef_table) + self.Outputs.data.send(data) + self.Outputs.components.send(components) + + def _create_output_coeffs_loadings(self) -> Table: + intercept = self.model.intercept.T[None, :] + coefficients = self.model.coefficients + _, y_loadings = self.model.loadings + x_rotations, _ = self.model.rotations + + n_targets, n_features = coefficients.shape + n_components = x_rotations.shape[1] + + names = [f"coef ({v.name})" for v in self.model.domain.class_vars] + names += [f"coef * X_sd ({v.name})" for v in self.model.domain.class_vars] + names += [f"w*c {i + 1}" for i in range(n_components)] + domain = Domain( + [ContinuousVariable(n) for n in names], + metas=[StringVariable("Variable name"), + DiscreteVariable("Variable role", ("Feature", "Target"))] + ) + + data = self.model.data_to_model_domain(self.data) + X_features = np.hstack((coefficients.T, + (coefficients * np.std(data.X, axis=0)).T, + x_rotations)) + X_targets = np.hstack((np.full((n_targets, n_targets), np.nan), + np.full((n_targets, n_targets), np.nan), + y_loadings)) + + coeffs = coefficients * np.mean(data.X, axis=0) + X_intercepts = np.hstack((intercept - coeffs.sum(), + intercept, + np.full((1, n_components), np.nan))) + X = np.vstack((X_features, X_targets, X_intercepts)) + + variables = self.model.domain.variables + M = np.array([[v.name for v in variables] + ["intercept"], + [0] * n_features + [1] * n_targets + [np.nan]], + dtype=object).T + + table = Table.from_numpy(domain, X=X, metas=M) + table.name = "Coefficients and Loadings" + return table + + def _create_output_data(self) -> Table: + projection = self.model.project(self.data) + normal_probs = self.model.residuals_normal_probability(self.data) + dmodx = self.model.dmodx(self.data) + data_domain = self.data.domain + proj_domain = projection.domain + nprobs_domain = normal_probs.domain + dmodx_domain = dmodx.domain + metas = data_domain.metas + proj_domain.attributes + proj_domain.metas + \ + nprobs_domain.attributes + dmodx_domain.attributes + domain = Domain(data_domain.attributes, data_domain.class_vars, metas) + data: Table = self.data.transform(domain) + with data.unlocked(data.metas): + data.metas[:, -2 * len(self.data.domain.class_vars) - 1: -1] = \ + normal_probs.X + data.metas[:, -1] = dmodx.X[:, 0] + return data + + @OWBaseLearner.Inputs.data + def set_data(self, data): + # reimplemented completely because the base learner does not + # allow multiclass + + self.Warning.sparse_data.clear() + + self.Error.data_error.clear() + self.data = data + + if data is not None and data.domain.class_var is None and not data.domain.class_vars: + self.Error.data_error( + "Data has no target variable.\n" + "Select one with the Select Columns widget.") + self.data = None + + # invalidate the model so that handleNewSignals will update it + self.model = None + + if self.data and sp.issparse(self.data.X): + self.Warning.sparse_data() + + def create_learner(self): + common_args = {'preprocessors': self.preprocessors} + return PLSRegressionLearner(n_components=self.n_components, + scale=self.scale, + max_iter=self.max_iter, + **common_args) + + +if __name__ == "__main__": # pragma: no cover + WidgetPreview(OWPLS).run(Table("housing")) diff --git a/Orange/widgets/model/owrandomforest.py b/Orange/widgets/model/owrandomforest.py index 456202fb0bf..083c0c5a9aa 100644 --- a/Orange/widgets/model/owrandomforest.py +++ b/Orange/widgets/model/owrandomforest.py @@ -11,13 +11,13 @@ class OWRandomForest(OWBaseLearner): name = "Random Forest" description = "Predict using an ensemble of decision trees." - icon = "icons/RandomForest.svg" + icon = "icons/RandomForest-symbolic.svg" replaces = [ "Orange.widgets.classify.owrandomforest.OWRandomForest", "Orange.widgets.regression.owrandomforestregression.OWRandomForestRegression", ] priority = 40 - keywords = [] + keywords = "random forest" LEARNER = RandomForestLearner @@ -46,7 +46,7 @@ def add_main_layout(self): alignment=Qt.AlignRight, label="Number of trees: ", callback=self.settings_changed) self.max_features_spin = gui.spin( - box, self, "max_features", 2, 50, controlWidth=80, + box, self, "max_features", 1, 50, controlWidth=80, label="Number of attributes considered at each split: ", callback=self.settings_changed, checked="use_max_features", checkCallback=self.settings_changed, alignment=Qt.AlignRight,) diff --git a/Orange/widgets/model/owrules.py b/Orange/widgets/model/owrules.py index d905a9ac806..e88a5188c93 100644 --- a/Orange/widgets/model/owrules.py +++ b/Orange/widgets/model/owrules.py @@ -73,6 +73,7 @@ def __init__(self, preprocessors, base_rules, params): # bottom-level search procedure (search strategy) self.rule_finder.search_strategy.constrain_continuous = True + self.rule_finder.search_strategy.restrict_equality = params["Restrict to equality"] # bottom-level search procedure (search heuristics) evaluation_measure = params["Evaluation measure"] @@ -210,12 +211,12 @@ def fit_storage(self, data): class OWRuleLearner(OWBaseLearner): name = "CN2 Rule Induction" description = "Induce rules from data using CN2 algorithm." - icon = "icons/CN2RuleInduction.svg" + icon = "icons/CN2RuleInduction-symbolic.svg" replaces = [ "Orange.widgets.classify.owrules.OWRuleLearner", ] priority = 19 - keywords = [] + keywords = "cn2 rule induction" LEARNER = CustomRuleLearner supports_sparse = False @@ -225,11 +226,11 @@ class OWRuleLearner(OWBaseLearner): storage_measures = ["entropy", "laplace", "wracc"] # default parameter values - learner_name = Setting("CN2 rule inducer") rule_ordering = Setting(0) covering_algorithm = Setting(0) gamma = Setting(0.7) evaluation_measure = Setting(0) + restrict_equality = Setting(False) beam_width = Setting(5) min_covered_examples = Setting(1) max_rule_length = Setting(5) @@ -267,7 +268,7 @@ def add_main_layout(self): widget=insert_gamma_box, master=self, value="gamma", minv=0.0, maxv=1.0, step=0.01, label="γ:", orientation=Qt.Horizontal, callback=self.settings_changed, alignment=Qt.AlignRight, - enabled=self.storage_covers[self.covering_algorithm] == "weighted") + enabled=self.covering_algorithm == 1) # bottom-level search procedure (search bias) middle_box = gui.vBox(widget=self.controlArea, box="Rule search") @@ -313,9 +314,14 @@ def add_main_layout(self): alignment=Qt.AlignRight, controlWidth=80, checked="checked_parent_alpha") + gui.checkBox( + widget=bottom_box, master=self, value="restrict_equality", + label="Restrict operator for categorical values to equality", + callback=self.settings_changed, + ) + def settings_changed(self, *args, **kwargs): - self.gamma_spin.setDisabled( - self.storage_covers[self.covering_algorithm] != "weighted") + self.gamma_spin.setDisabled(self.covering_algorithm == 0) super().settings_changed(*args, **kwargs) def update_model(self): @@ -330,7 +336,7 @@ def update_model(self): except MemoryError: self.Error.out_of_memory() else: - self.model.name = self.learner_name + self.model.name = self.effective_learner_name() self.model.instances = self.data self.valid_data = True self.Outputs.model.send(self.model) @@ -351,6 +357,7 @@ def get_learner_parameters(self): ("Covering algorithm", self.storage_covers[self.covering_algorithm]), ("Gamma", self.gamma), ("Evaluation measure", self.storage_measures[self.evaluation_measure]), + ("Restrict to equality", self.restrict_equality), ("Beam width", self.beam_width), ("Minimum rule coverage", self.min_covered_examples), ("Maximum rule length", self.max_rule_length), diff --git a/Orange/widgets/model/owsavemodel.py b/Orange/widgets/model/owsavemodel.py index 73cfac0ca88..522227b5750 100644 --- a/Orange/widgets/model/owsavemodel.py +++ b/Orange/widgets/model/owsavemodel.py @@ -9,10 +9,10 @@ class OWSaveModel(OWSaveBase): name = "Save Model" description = "Save a trained model to an output file." - icon = "icons/SaveModel.svg" + icon = "icons/SaveModel-symbolic.svg" replaces = ["Orange.widgets.classify.owsaveclassifier.OWSaveClassifier"] priority = 3000 - keywords = [] + keywords = "save model, save" class Inputs: model = Input("Model", Model) diff --git a/Orange/widgets/model/owscoringsheet.py b/Orange/widgets/model/owscoringsheet.py new file mode 100644 index 00000000000..9b5de1b6b47 --- /dev/null +++ b/Orange/widgets/model/owscoringsheet.py @@ -0,0 +1,205 @@ +from AnyQt.QtCore import Qt + +from Orange.data import Table +from Orange.base import Model +from Orange.widgets.utils.owlearnerwidget import OWBaseLearner +from Orange.widgets.utils.concurrent import TaskState, ConcurrentWidgetMixin +from Orange.widgets.widget import Msg +from Orange.widgets import gui +from Orange.widgets.settings import Setting + +from Orange.classification.scoringsheet import ScoringSheetLearner + + +class ScoringSheetRunner: + @staticmethod + def run(learner: ScoringSheetLearner, data: Table, state: TaskState) -> Model: + if data is None: + return None + state.set_status("Learning...") + model = learner(data) + return model + + +class OWScoringSheet(OWBaseLearner, ConcurrentWidgetMixin): + name = "Scoring Sheet" + description = "A fast and explainable classifier." + icon = "icons/ScoringSheet-symbolic.svg" + replaces = ["orangecontrib.prototypes.widgets.owscoringsheet.OWScoringSheet"] + priority = 75 + keywords = "scoring sheet" + + LEARNER = ScoringSheetLearner + + class Inputs(OWBaseLearner.Inputs): + pass + + class Outputs(OWBaseLearner.Outputs): + pass + + # Preprocessing + num_attr_after_selection = Setting(20) + + # Scoring Sheet Settings + num_decision_params = Setting(5) + max_points_per_param = Setting(5) + custom_features_checkbox = Setting(False) + num_input_features = Setting(1) + + # Warning messages + class Information(OWBaseLearner.Information): + custom_num_of_input_features = Msg( + "If the number of input features used is too low for the number of decision \n" + "parameters, the number of decision parameters will be adjusted to fit the model." + ) + + def __init__(self): + ConcurrentWidgetMixin.__init__(self) + OWBaseLearner.__init__(self) + + def add_main_layout(self): + box = gui.vBox(self.controlArea, "Preprocessing") + + self.num_attr_after_selection_spin = gui.spin( + box, + self, + "num_attr_after_selection", + minv=1, + maxv=100, + step=1, + label="Number of Attributes After Feature Selection:", + orientation=Qt.Horizontal, + alignment=Qt.AlignRight, + callback=self.settings_changed, + controlWidth=45, + ) + + box = gui.vBox(self.controlArea, "Model Parameters") + + gui.spin( + box, + self, + "num_decision_params", + minv=1, + maxv=50, + step=1, + label="Maximum Number of Decision Parameters:", + orientation=Qt.Horizontal, + alignment=Qt.AlignRight, + callback=self.settings_changed, + controlWidth=45, + ) + + gui.spin( + box, + self, + "max_points_per_param", + minv=1, + maxv=100, + step=1, + label="Maximum Points per Decision Parameter:", + orientation=Qt.Horizontal, + alignment=Qt.AlignRight, + callback=self.settings_changed, + controlWidth=45, + ) + + gui.checkBox( + box, + self, + "custom_features_checkbox", + label="Custom number of input features", + callback=[self.settings_changed, self.custom_input_features], + ) + + self.custom_features = gui.spin( + box, + self, + "num_input_features", + minv=1, + maxv=50, + step=1, + label="Number of Input Features Used:", + orientation=Qt.Horizontal, + alignment=Qt.AlignRight, + callback=self.settings_changed, + controlWidth=45, + ) + + self.custom_input_features() + + def custom_input_features(self): + self.custom_features.setEnabled(self.custom_features_checkbox) + if self.custom_features_checkbox: + self.Information.custom_num_of_input_features() + else: + self.Information.custom_num_of_input_features.clear() + self.apply() + + @Inputs.data + def set_data(self, data): + self.cancel() + super().set_data(data) + + @Inputs.preprocessor + def set_preprocessor(self, preprocessor): + self.cancel() + super().set_preprocessor(preprocessor) + + # Enable or disable the spin box based on whether a preprocessor is set + self.num_attr_after_selection_spin.setEnabled(preprocessor is None) + if preprocessor: + self.Information.ignored_preprocessors() + else: + self.Information.ignored_preprocessors.clear() + + def create_learner(self): + return self.LEARNER( + num_attr_after_selection=self.num_attr_after_selection, + num_decision_params=self.num_decision_params, + max_points_per_param=self.max_points_per_param, + num_input_features=( + self.num_input_features if self.custom_features_checkbox else None + ), + preprocessors=self.preprocessors, + ) + + def update_model(self): + self.cancel() + self.show_fitting_failed(None) + self.model = None + if self.data is not None: + self.start(ScoringSheetRunner.run, self.learner, self.data) + else: + self.Outputs.model.send(None) + + def get_learner_parameters(self): + return ( + self.num_decision_params, + self.max_points_per_param, + self.num_input_features, + ) + + def on_partial_result(self, _): + pass + + def on_done(self, result: Model): + assert isinstance(result, Model) or result is None + self.model = result + self.Outputs.model.send(result) + + def on_exception(self, ex): + self.cancel() + self.Outputs.model.send(None) + if isinstance(ex, BaseException): + self.show_fitting_failed(ex) + + def onDeleteWidget(self): + self.shutdown() + super().onDeleteWidget() + + +if __name__ == "__main__": + from Orange.widgets.utils.widgetpreview import WidgetPreview + + WidgetPreview(OWScoringSheet).run() diff --git a/Orange/widgets/model/owsgd.py b/Orange/widgets/model/owsgd.py index f12326ce7fd..c673c994c69 100644 --- a/Orange/widgets/model/owsgd.py +++ b/Orange/widgets/model/owsgd.py @@ -1,7 +1,7 @@ from collections import OrderedDict from AnyQt.QtCore import Qt -from AnyQt.QtWidgets import QHBoxLayout, QVBoxLayout, QGridLayout, QLabel, QWidget +from AnyQt.QtWidgets import QHBoxLayout, QGridLayout, QLabel, QWidget from Orange.widgets.report import bool_str from Orange.data import ContinuousVariable, StringVariable, Domain, Table @@ -20,12 +20,12 @@ class OWSGD(OWBaseLearner): name = 'Stochastic Gradient Descent' description = 'Minimize an objective function using a stochastic ' \ 'approximation of gradient descent.' - icon = "icons/SGD.svg" + icon = "icons/SGD-symbolic.svg" replaces = [ "Orange.widgets.regression.owsgdregression.OWSGDRegression", ] priority = 90 - keywords = ["sgd"] + keywords = "stochastic gradient descent, sgd" settings_version = 2 @@ -35,21 +35,21 @@ class Outputs(OWBaseLearner.Outputs): coefficients = Output("Coefficients", Table, explicit=True) reg_losses = ( - ('Squared Loss', 'squared_loss'), + ('Squared Loss', 'squared_error'), ('Huber', 'huber'), ('ε insensitive', 'epsilon_insensitive'), ('Squared ε insensitive', 'squared_epsilon_insensitive')) cls_losses = ( ('Hinge', 'hinge'), - ('Logistic regression', 'log'), + ('Logistic regression', 'log_loss'), ('Modified Huber', 'modified_huber'), ('Squared Hinge', 'squared_hinge'), ('Perceptron', 'perceptron')) + reg_losses #: Regularization methods penalties = ( - ('None', 'none'), + ('None', None), ('Lasso (L1)', 'l1'), ('Ridge (L2)', 'l2'), ('Elastic Net', 'elasticnet')) @@ -59,7 +59,6 @@ class Outputs(OWBaseLearner.Outputs): ('Optimal', 'optimal'), ('Inverse scaling', 'invscaling')) - learner_name = Setting('SGD') #: Loss function index for classification problems cls_loss_function_index = Setting(0) #: Epsilon loss function parameter for classification problems diff --git a/Orange/widgets/model/owstack.py b/Orange/widgets/model/owstack.py index a91fd5a85e1..f3bb1613635 100644 --- a/Orange/widgets/model/owstack.py +++ b/Orange/widgets/model/owstack.py @@ -1,29 +1,30 @@ -from collections import OrderedDict +from typing import List from Orange.base import Learner from Orange.data import Table from Orange.ensembles.stack import StackedFitter from Orange.widgets.settings import Setting from Orange.widgets.utils.owlearnerwidget import OWBaseLearner -from Orange.widgets.widget import Input +from Orange.widgets.widget import Input, MultiInput class OWStackedLearner(OWBaseLearner): name = "Stacking" description = "Stack multiple models." - icon = "icons/Stacking.svg" + icon = "icons/Stacking-symbolic.svg" priority = 100 + keywords = "stacking, ensemble" LEARNER = StackedFitter learner_name = Setting("Stack") class Inputs(OWBaseLearner.Inputs): - learners = Input("Learners", Learner, multiple=True) + learners = MultiInput("Learners", Learner, filter_none=True) aggregate = Input("Aggregate", Learner) def __init__(self): - self.learners = OrderedDict() + self.learners: List[Learner] = [] self.aggregate = None super().__init__() @@ -31,27 +32,39 @@ def add_main_layout(self): pass @Inputs.learners - def set_learners(self, learner, id): # pylint: disable=redefined-builtin - if id in self.learners and learner is None: - del self.learners[id] - elif learner is not None: - self.learners[id] = learner - self.apply() + def set_learner(self, index: int, learner: Learner): + self.learners[index] = learner + self._invalidate() + + @Inputs.learners.insert + def insert_learner(self, index, learner): + self.learners.insert(index, learner) + self._invalidate() + + @Inputs.learners.remove + def remove_learner(self, index): + self.learners.pop(index) + self._invalidate() @Inputs.aggregate def set_aggregate(self, aggregate): self.aggregate = aggregate - self.apply() + self._invalidate() + + def _invalidate(self): + self.learner = self.model = None + # ... and handleNewSignals will do the rest def create_learner(self): if not self.learners: return None - return self.LEARNER( - tuple(self.learners.values()), aggregate=self.aggregate, - preprocessors=self.preprocessors) + params = {"preprocessors": self.preprocessors} + if self.aggregate: + params["aggregate"] = self.aggregate + return self.LEARNER(tuple(self.learners), **params) def get_learner_parameters(self): - return (("Base learners", [l.name for l in self.learners.values()]), + return (("Base learners", [l.name for l in self.learners]), ("Aggregator", self.aggregate.name if self.aggregate else 'default')) diff --git a/Orange/widgets/model/owsvm.py b/Orange/widgets/model/owsvm.py index 06d5567e3c4..092276f1cfa 100644 --- a/Orange/widgets/model/owsvm.py +++ b/Orange/widgets/model/owsvm.py @@ -18,13 +18,13 @@ class OWSVM(OWBaseLearner): name = 'SVM' description = "Support Vector Machines map inputs to higher-dimensional " \ "feature spaces." - icon = "icons/SVM.svg" + icon = "icons/SVM-symbolic.svg" replaces = [ "Orange.widgets.classify.owsvmclassification.OWSVMClassification", "Orange.widgets.regression.owsvmregression.OWSVMRegression", ] priority = 50 - keywords = ["support vector machines"] + keywords = "svm, support vector machines" LEARNER = SVMLearner @@ -35,6 +35,8 @@ class Outputs(OWBaseLearner.Outputs): class Warning(OWBaseLearner.Warning): sparse_data = Msg('Input data is sparse, default preprocessing is to scale it.') + settings_version = 2 + #: Different types of SVMs SVM, Nu_SVM = range(2) #: SVM type @@ -156,8 +158,8 @@ def _add_kernel_box(self): gamma.setSpecialValueText(self._default_gamma) coef0 = gui.doubleSpin( inbox, self, "coef0", 0.0, 10.0, 0.01, label=" c: ", **common) - degree = gui.doubleSpin( - inbox, self, "degree", 0.0, 10.0, 0.5, label=" d: ", **common) + degree = gui.spin( + inbox, self, "degree", 0, 10, 1, label=" d: ", **common) self._kernel_params = [gamma, coef0, degree] gui.rubber(parambox) @@ -177,7 +179,7 @@ def _add_optimization_box(self): alignment=Qt.AlignRight, controlWidth=100, callback=self.settings_changed) self.max_iter_spin = gui.spin( - self.optimization_box, self, "max_iter", 5, 1e6, 50, + self.optimization_box, self, "max_iter", 5, 1000000, 50, label="Iteration limit: ", checked="limit_iter", alignment=Qt.AlignRight, controlWidth=100, callback=self.settings_changed, @@ -206,6 +208,7 @@ def _on_kernel_changed(self): self._show_right_kernel() self.settings_changed() + @OWBaseLearner.Inputs.data def set_data(self, data): self.Warning.sparse_data.clear() super().set_data(data) @@ -254,6 +257,12 @@ def _report_kernel_parameters(self, items): items["Kernel"] = "Sigmoid, tanh({g:.4} x⋅y + {c:.4})".format( g=gamma, c=self.coef0) + @classmethod + def migrate_settings(cls, settings, version): + if version < 2: + if "degree" in settings: + settings["degree"] = int(settings["degree"]) + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWSVM).run(Table("iris")) diff --git a/Orange/widgets/model/owtree.py b/Orange/widgets/model/owtree.py index 4be7fc86d6b..34ab75c89f6 100644 --- a/Orange/widgets/model/owtree.py +++ b/Orange/widgets/model/owtree.py @@ -8,6 +8,7 @@ from Orange.modelling.tree import TreeLearner from Orange.widgets import gui from Orange.widgets.settings import Setting +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.owlearnerwidget import OWBaseLearner from Orange.widgets.utils.widgetpreview import WidgetPreview @@ -16,7 +17,7 @@ class OWTreeLearner(OWBaseLearner): """Tree algorithm with forward pruning.""" name = "Tree" description = "A tree algorithm with forward pruning." - icon = "icons/Tree.svg" + icon = "icons/Tree-symbolic.svg" replaces = [ "Orange.widgets.classify.owclassificationtree.OWClassificationTree", "Orange.widgets.regression.owregressiontree.OWRegressionTree", @@ -24,7 +25,7 @@ class OWTreeLearner(OWBaseLearner): "Orange.widgets.regression.owregressiontree.OWTreeLearner", ] priority = 30 - keywords = ["Classification Tree"] + keywords = "tree, classification tree" LEARNER = TreeLearner @@ -91,11 +92,14 @@ def get_learner_parameters(self): from Orange.widgets.report import plural_w items = OrderedDict() items["Pruning"] = ", ".join(s for s, c in ( - (plural_w("at least {number} instance{s} in leaves", - self.min_leaf), self.limit_min_leaf), - (plural_w("at least {number} instance{s} in internal nodes", - self.min_internal), self.limit_min_internal), - ("maximum depth {}".format(self.max_depth), self.limit_depth) + (f'at least {self.min_leaf} ' + f'{pl(self.min_leaf, "instance")} in leaves', + self.limit_min_leaf), + (f'at least {self.min_internal} ' + f'{pl(self.min_internal, "instance")} in internal nodes', + self.limit_min_internal), + (f'maximum depth {self.max_depth}', + self.limit_depth) ) if c) or "None" if self.limit_majority: items["Splitting"] = "Stop splitting when majority reaches %d%% " \ diff --git a/Orange/widgets/model/tests/test_owadaboost.py b/Orange/widgets/model/tests/test_owadaboost.py index 13ef04e501d..0a1e875d2bd 100644 --- a/Orange/widgets/model/tests/test_owadaboost.py +++ b/Orange/widgets/model/tests/test_owadaboost.py @@ -14,9 +14,6 @@ def setUp(self): OWAdaBoost, stored_settings={"auto_apply": False}) self.init() self.parameters = [ - ParameterMapping('algorithm', self.widget.cls_algorithm_combo, - self.widget.algorithms, - problem_type="classification"), ParameterMapping('loss', self.widget.reg_algorithm_combo, [x.lower() for x in self.widget.losses], problem_type="regression"), diff --git a/Orange/widgets/model/tests/test_owcalibratedlearner.py b/Orange/widgets/model/tests/test_owcalibratedlearner.py index 400d483a592..2058d73db6f 100644 --- a/Orange/widgets/model/tests/test_owcalibratedlearner.py +++ b/Orange/widgets/model/tests/test_owcalibratedlearner.py @@ -9,6 +9,7 @@ from Orange.widgets.model.owcalibratedlearner import OWCalibratedLearner from Orange.widgets.tests.base import WidgetTest, WidgetLearnerTestMixin, \ datasets +from Orange.widgets.tests.utils import qbuttongroup_emit_clicked class TestOWCalibratedLearner(WidgetTest, WidgetLearnerTestMixin): @@ -28,10 +29,10 @@ def setUp(self): def test_output_learner(self): """Check if learner is on output after apply""" # Overridden to change the output type in the last test - initial = self.get_output("Learner") + initial = self.get_output(self.widget.Outputs.learner) self.assertIsNotNone(initial, "Does not initialize the learner output") - self.widget.apply_button.button.click() - newlearner = self.get_output("Learner") + self.click_apply() + newlearner = self.get_output(self.widget.Outputs.learner) self.assertIsNot(initial, newlearner, "Does not send a new learner instance on `Apply`.") self.assertIsNotNone(newlearner) @@ -43,10 +44,10 @@ def test_output_model(self): """Check if model is on output after sending data and apply""" # Overridden to change the output type in the last two test self.assertIsNone(self.get_output(self.widget.Outputs.model)) - self.widget.apply_button.button.click() + self.click_apply() self.assertIsNone(self.get_output(self.widget.Outputs.model)) - self.send_signal('Data', self.data) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, self.data) + self.click_apply() self.wait_until_stop_blocking() model = self.get_output(self.widget.Outputs.model) self.assertIsNotNone(model) @@ -94,7 +95,7 @@ def test_create_learner(self): widget.calibration = widget.NoCalibration widget.threshold = widget.NoThresholdOptimization learner = self.widget.create_learner() - self.assertIs(learner, self.widget.base_learner) + self.assertIsNot(learner, self.widget.base_learner) widget.calibration = widget.SigmoidCalibration widget.threshold = widget.OptimizeF1 @@ -140,19 +141,19 @@ def test_name_changes(self): widget.calibration = widget.IsotonicCalibration widget.threshold = widget.OptimizeCA - widget.controls.calibration.group.buttonClicked[int].emit( - widget.IsotonicCalibration) + qbuttongroup_emit_clicked(widget.controls.calibration.group, + widget.IsotonicCalibration) learner = self.get_output(widget.Outputs.learner) self.assertEqual(learner.name, "Foo + Isotonic + CA") widget.calibration = widget.NoCalibration widget.threshold = widget.OptimizeCA - widget.controls.calibration.group.buttonClicked[int].emit( - widget.NoCalibration) + qbuttongroup_emit_clicked(widget.controls.calibration.group, + widget.NoCalibration) learner = self.get_output(widget.Outputs.learner) self.assertEqual(learner.name, "Foo + CA") self.send_signal(widget.Inputs.base_learner, None) self.assertEqual(widget.controls.learner_name.placeholderText(), - "Calibrated learner") + "Calibrated Learner") diff --git a/Orange/widgets/model/tests/test_owcurvefit.py b/Orange/widgets/model/tests/test_owcurvefit.py new file mode 100644 index 00000000000..4a4a2f829f5 --- /dev/null +++ b/Orange/widgets/model/tests/test_owcurvefit.py @@ -0,0 +1,551 @@ +# pylint: disable=missing-docstring, protected-access +import pickle +import unittest + +import numpy as np +from AnyQt.QtWidgets import QCheckBox, QLineEdit, QPushButton, QDoubleSpinBox + +from Orange.data import Table, Domain, ContinuousVariable +from Orange.preprocess import Discretize, Continuize +from Orange.regression import CurveFitLearner +from Orange.regression.curvefit import CurveFitModel +from Orange.widgets.model.owcurvefit import OWCurveFit, ParametersWidget, \ + Parameter, FUNCTIONS +from Orange.widgets.tests.base import WidgetTest, WidgetLearnerTestMixin +from Orange.widgets.tests.utils import simulate + + +class TestFunctions(unittest.TestCase): + def test_functions(self): + a = np.full(5, 2) + for f in FUNCTIONS: + func = getattr(np, f) + if isinstance(func, float): + pass + elif f in ["any", "all"]: + self.assertTrue(func(a)) + elif f in ["arctan2", "copysign", "fmod", "gcd", "hypot", + "isclose", "ldexp", "power", "remainder"]: + self.assertIsInstance(func(a, 2), np.ndarray) + self.assertEqual(func(a, 2).shape, (5,)) + else: + self.assertIsInstance(func(a), np.ndarray) + self.assertEqual(func(a).shape, (5,)) + + +class TestParameter(unittest.TestCase): + def test_to_tuple(self): + args = ("foo", 2, True, 10, False, 50) + par = Parameter(*args) + self.assertEqual(par.to_tuple(), args) + + def test_repr(self): + args = ("foo", 2, True, 10, False, 50) + par = Parameter(*args) + str_par = "Parameter(name=foo, initial=2, use_lower=True, " \ + "lower=10, use_upper=False, upper=50)" + self.assertEqual(str(par), str_par) + + +class TestParametersWidget(WidgetTest): + def setUp(self): + self._widget = ParametersWidget(None) + + def test_init(self): + layout = self._widget._ParametersWidget__layout + self.assertEqual(layout.rowCount(), 1) + + def test_add_row(self): + self._widget._add_row() + + controls = self._widget._ParametersWidget__controls[0] + self.assertIsInstance(controls[0], QPushButton) + self.assertIsInstance(controls[1], QLineEdit) + self.assertIsInstance(controls[2], QDoubleSpinBox) + self.assertIsInstance(controls[3], QCheckBox) + self.assertIsInstance(controls[4], QDoubleSpinBox) + self.assertIsInstance(controls[5], QCheckBox) + self.assertIsInstance(controls[6], QDoubleSpinBox) + + self.assertEqual(controls[1].text(), "p1") + self.assertEqual(controls[2].value(), 1) + self.assertFalse(controls[3].isChecked()) + self.assertFalse(controls[4].isEnabled()) + self.assertEqual(controls[4].value(), 0) + self.assertFalse(controls[5].isChecked()) + self.assertFalse(controls[6].isEnabled()) + self.assertEqual(controls[6].value(), 100) + + data: Parameter = self._widget._ParametersWidget__data[0] + self.assertEqual(data.name, "p1") + self.assertEqual(data.initial, 1) + self.assertFalse(data.use_lower) + self.assertEqual(data.lower, 0) + self.assertFalse(data.use_upper) + self.assertEqual(data.upper, 100) + + def test_remove(self): + n = 5 + for _ in range(n): + self._widget._add_row() + self.assertEqual(len(self._widget._ParametersWidget__data), n) + + k = 2 + for _ in range(k): + button = self._widget._ParametersWidget__controls[0][0] + button.click() + + self.assertEqual(len(self._widget._ParametersWidget__data), n - k) + + def test_add_row_with_data(self): + param = Parameter("a", 3, True, 2, False, 4) + self._widget._add_row(param) + + controls = self._widget._ParametersWidget__controls[0] + self.assertEqual(controls[1].text(), "a") + self.assertEqual(controls[2].value(), 3) + self.assertTrue(controls[3].isChecked()) + self.assertEqual(controls[4].value(), 2) + self.assertTrue(controls[4].isEnabled()) + self.assertFalse(controls[5].isChecked()) + self.assertEqual(controls[6].value(), 4) + self.assertFalse(controls[6].isEnabled()) + + data = self._widget._ParametersWidget__data[0] + self.assertEqual(data.name, "a") + self.assertEqual(data.initial, 3) + self.assertTrue(data.use_lower) + self.assertEqual(data.lower, 2) + self.assertFalse(data.use_upper) + self.assertEqual(data.upper, 4) + + def test_set_data(self): + data = [Parameter("a", 4, True, -2, True, 5), + Parameter("b", 2, True, 0, False, 11)] + self._widget.set_data(data) + self.assertEqual(len(self._widget._ParametersWidget__controls), 2) + + controls = self._widget._ParametersWidget__controls + self.assertEqual(controls[0][1].text(), "a") + self.assertEqual(controls[0][2].value(), 4) + self.assertTrue(controls[0][3].isChecked()) + self.assertEqual(controls[0][4].value(), -2) + self.assertTrue(controls[0][5].isChecked()) + self.assertEqual(controls[0][6].value(), 5) + self.assertEqual(controls[1][1].text(), "b") + self.assertEqual(controls[1][2].value(), 2) + self.assertTrue(controls[1][3].isChecked()) + self.assertEqual(controls[1][4].value(), 0) + self.assertFalse(controls[1][5].isChecked()) + self.assertEqual(controls[1][6].value(), 11) + + data = self._widget._ParametersWidget__data + self.assertEqual(data[0].name, "a") + self.assertEqual(data[0].initial, 4) + self.assertTrue(data[0].use_lower) + self.assertEqual(data[0].lower, -2) + self.assertTrue(data[0].use_upper) + self.assertEqual(data[0].upper, 5) + self.assertEqual(data[1].name, "b") + self.assertEqual(data[1].initial, 2) + self.assertTrue(data[1].use_lower) + self.assertEqual(data[1].lower, 0) + self.assertFalse(data[1].use_upper) + self.assertEqual(data[1].upper, 11) + + def test_reset_data(self): + self._widget.set_data([Parameter("a", 1, True, 2, True, 5)]) + self._widget.set_data([Parameter("a", 1, True, 3, True, 6)]) + self.assertEqual(len(self._widget._ParametersWidget__controls), 1) + self.assertEqual(len(self._widget._ParametersWidget__data), 1) + + def test_clear_all(self): + self._widget.set_data([Parameter("a", 1, True, 2, True, 5)]) + self._widget.clear_all() + self.assertEqual(len(self._widget._ParametersWidget__controls), 0) + self.assertEqual(len(self._widget._ParametersWidget__data), 0) + + +class TestOWCurveFit(WidgetTest, WidgetLearnerTestMixin): + def setUp(self): + self.widget = self.create_widget(OWCurveFit, + stored_settings={"auto_apply": False}) + self.housing = Table("housing") + self.init() + self.__add_button = \ + self.widget._OWCurveFit__param_widget.findChildren(QPushButton)[0] + + def __init_widget(self, data=None, widget=None): + if data is None: + data = self.housing + if widget is None: + widget = self.widget + self.send_signal(widget.Inputs.data, data, widget=widget) + add_button = \ + widget._OWCurveFit__param_widget.findChildren(QPushButton)[0] + add_button.click() + widget._OWCurveFit__expression_edit.setText("p1 + ") + simulate.combobox_activate_index(widget.controls._feature, 1) + widget.apply_button.button.click() + + def test_input_data_learner_adequacy(self): # overwritten + for inadequate in self.inadequate_dataset: + self.__init_widget(inadequate) + self.wait_until_stop_blocking() + self.assertTrue(self.widget.Error.data_error.is_shown()) + for valid in self.valid_datasets: + self.__init_widget(valid) + self.wait_until_stop_blocking() + self.assertFalse(self.widget.Error.data_error.is_shown()) + + def test_input_data_missing(self): + self.assertTrue(self.widget.Warning.data_missing.is_shown()) + self.send_signal(self.widget.Inputs.data, self.housing) + self.assertFalse(self.widget.Warning.data_missing.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertTrue(self.widget.Warning.data_missing.is_shown()) + + def test_input_preprocessor(self): + self.__init_widget() + super().test_input_preprocessor() + + def test_input_preprocessors(self): + self.__init_widget() + super().test_input_preprocessors() + + def test_output_learner(self): + self.__init_widget() + super().test_output_learner() + + def test_output_model(self): # overwritten + self.assertIsNone(self.get_output(self.widget.Outputs.model)) + self.click_apply() + self.assertIsNone(self.get_output(self.widget.Outputs.model)) + + self.__init_widget() + self.wait_until_stop_blocking() + model = self.get_output(self.widget.Outputs.model) + self.assertIsNotNone(model) + self.assertIsInstance(model, self.widget.LEARNER.__returns__) + self.assertIsInstance(model, self.model_class) + + def test_output_learner_name(self): + self.__init_widget() + super().test_output_learner_name() + + def test_output_model_name(self): # overwritten + new_name = "Model Name" + self.widget.name_line_edit.setText(new_name) + self.__init_widget() + self.wait_until_stop_blocking() + model_name = self.get_output(self.widget.Outputs.model).name + self.assertEqual(model_name, new_name) + + def test_output_model_picklable(self): # overwritten + self.__init_widget() + self.wait_until_stop_blocking() + model = self.get_output(self.widget.Outputs.model) + self.assertIsNotNone(model) + pickle.dumps(model) + + def test_output_coefficients(self): + self.__init_widget() + coefficients = self.get_output(self.widget.Outputs.coefficients) + self.assertTrue("coef" in coefficients.domain) + self.assertTrue("name" in coefficients.domain) + + def test_output_mixed_features(self): + self.__init_widget(self.data) + learner = self.get_output(self.widget.Outputs.learner) + self.assertIsInstance(learner, CurveFitLearner) + model = self.get_output(self.widget.Outputs.model) + self.assertIsInstance(model, CurveFitModel) + coef = self.get_output(self.widget.Outputs.coefficients) + self.assertTrue("coef" in coef.domain) + self.assertTrue("name" in coef.domain) + + def test_discrete_features(self): + combo = self.widget.controls._feature + model = combo.model() + disc_housing = Discretize()(self.housing) + self.send_signal(self.widget.Inputs.data, disc_housing) + self.assertEqual(model.rowCount(), 1) + self.assertTrue(self.widget.Error.data_error.is_shown()) + + continuizer = Continuize() + self.send_signal(self.widget.Inputs.preprocessor, continuizer) + self.assertGreater(model.rowCount(), 1) + self.assertFalse(self.widget.Error.data_error.is_shown()) + + self.send_signal(self.widget.Inputs.preprocessor, None) + self.assertEqual(model.rowCount(), 1) + self.assertTrue(self.widget.Error.data_error.is_shown()) + + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(model.rowCount(), 1) + self.assertFalse(self.widget.Error.data_error.is_shown()) + + def test_features_combo(self): + combo = self.widget.controls._feature + model = combo.model() + self.assertEqual(model.rowCount(), 1) + self.assertEqual(combo.currentText(), "Select Feature") + + self.send_signal(self.widget.Inputs.data, self.housing) + self.assertEqual(model.rowCount(), 14) + self.assertEqual(combo.currentText(), "Select Feature") + simulate.combobox_activate_index(combo, 1) + self.assertEqual(self.widget._OWCurveFit__expression_edit.text(), + "CRIM") + + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(model.rowCount(), 1) + self.assertEqual(combo.currentText(), "Select Feature") + + def test_parameters_combo(self): + combo = self.widget.controls._parameter + model = combo.model() + + self.send_signal(self.widget.Inputs.data, self.housing) + self.assertEqual(model.rowCount(), 1) + self.assertEqual(combo.currentText(), "Select Parameter") + self.__add_button.click() + self.assertEqual(model.rowCount(), 2) + self.assertEqual(combo.currentText(), "Select Parameter") + simulate.combobox_activate_index(combo, 1) + self.assertEqual(self.widget._OWCurveFit__expression_edit.text(), "p1") + + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(model.rowCount(), 2) + self.assertEqual(combo.currentText(), "Select Parameter") + + def test_function_combo(self): + combo = self.widget.controls._function + model = combo.model() + self.assertEqual(model.rowCount(), 46) + self.assertEqual(combo.currentText(), "Select Function") + + self.send_signal(self.widget.Inputs.data, self.housing) + self.assertEqual(model.rowCount(), 46) + self.assertEqual(combo.currentText(), "Select Function") + simulate.combobox_activate_index(combo, 1) + self.assertEqual(self.widget._OWCurveFit__expression_edit.text(), + "abs()") + + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(model.rowCount(), 46) + self.assertEqual(combo.currentText(), "Select Function") + + def test_expression(self): + feature_combo = self.widget.controls._feature + function_combo = self.widget.controls._function + insert = self.widget._OWCurveFit__insert_into_expression + for f in FUNCTIONS: + self.__init_widget() + insert(" + ") + simulate.combobox_activate_item(function_combo, f) + if isinstance(getattr(np, f), float): + insert(" + ") + simulate.combobox_activate_index(feature_combo, 1) + elif f == "gcd": + simulate.combobox_activate_index(feature_combo, 1) + self.widget._OWCurveFit__expression_edit.cursorForward(0, 1) + insert("2") + elif f in ["arctan2", "copysign", "fmod", "gcd", "hypot", + "isclose", "ldexp", "power", "remainder"]: + simulate.combobox_activate_index(feature_combo, 1) + self.widget._OWCurveFit__expression_edit.cursorForward(0, 1) + insert("2") + else: + simulate.combobox_activate_index(feature_combo, 1) + self.click_apply() + + self.assertIsNotNone(self.get_output(self.widget.Outputs.learner)) + self.assertFalse(self.widget.Error.no_parameter.is_shown()) + self.assertFalse(self.widget.Error.invalid_exp.is_shown()) + model = self.get_output(self.widget.Outputs.model) + coefficients = self.get_output(self.widget.Outputs.coefficients) + if f in ["inf", "nan", "arccos", "arccosh", "arcsin", "arctanh"]: + # These functions produce objective function values with NaN. + # Different implementations of optimization that scipy uses get + # different results: one stops optimization immediately with some + # results, the other stops when maximum number of iterations + # (and errors with fitting_failed). The optimization implementation + # can be different even with same scipy version (in my case, between + # pypi and conda-forge package). Thus, we skip these. + continue + if f == "gcd": + self.assertTrue(self.widget.Error.fitting_failed.is_shown()) + self.assertIsNone(model) + self.assertIsNone(coefficients) + else: + self.assertIsNotNone(model) + self.assertIsNotNone(coefficients) + self.assertFalse(self.widget.Error.fitting_failed.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertIsNone(self.get_output(self.widget.Outputs.learner)) + self.assertIsNone(self.get_output(self.widget.Outputs.model)) + coefficients = self.get_output(self.widget.Outputs.coefficients) + self.assertIsNone(coefficients) + + def test_sanitized_expression(self): + data = Table("heart_disease") + attrs = data.domain.attributes + domain = Domain(attrs[1:4], attrs[4]) + data = data.transform(domain) + self.__init_widget(data) + self.assertEqual(self.widget.expression, "p1 + rest_SBP") + self.assertIsNotNone(self.get_output(self.widget.Outputs.model)) + + def test_discrete_expression(self): + data = Table("heart_disease") + attrs = data.domain.attributes + domain = Domain(attrs[1:4], attrs[4]) + data = data.transform(domain) + self.send_signal(self.widget.Inputs.preprocessor, Continuize()) + self.__init_widget(data) + self.assertEqual(self.widget.expression, "p1 + gender_female") + self.assertIsNotNone(self.get_output(self.widget.Outputs.model)) + + def test_invalid_expression(self): + self.__init_widget() + self.assertFalse(self.widget.Error.invalid_exp.is_shown()) + self.widget._OWCurveFit__insert_into_expression(" + ") + self.click_apply() + self.assertTrue(self.widget.Error.invalid_exp.is_shown()) + self.widget._OWCurveFit__insert_into_expression(" 2 ") + self.click_apply() + self.assertFalse(self.widget.Error.invalid_exp.is_shown()) + self.widget._OWCurveFit__insert_into_expression(" + ") + self.click_apply() + self.assertTrue(self.widget.Error.invalid_exp.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Error.invalid_exp.is_shown()) + + def test_duplicated_parameter_name(self): + self.send_signal(self.widget.Inputs.data, self.housing) + self.__add_button.click() + self.__add_button.click() + param_controls = \ + self.widget._OWCurveFit__param_widget._ParametersWidget__controls + param_controls[1][1].setText("p1") + self.assertTrue(self.widget.Warning.duplicate_parameter.is_shown()) + param_controls[1][1].setText("p2") + self.assertFalse(self.widget.Warning.duplicate_parameter.is_shown()) + param_controls[1][1].setText("p1") + self.assertTrue(self.widget.Warning.duplicate_parameter.is_shown()) + self.send_signal(self.widget.Inputs.data, None) + self.assertTrue(self.widget.Warning.duplicate_parameter.is_shown()) + + def test_parameter_name_in_features(self): + domain = Domain([ContinuousVariable("p1")], + ContinuousVariable("cls")) + data = Table.from_numpy(domain, np.zeros((10, 1)), np.ones((10,))) + self.send_signal(self.widget.Inputs.data, data) + self.__add_button.click() + self.assertTrue(self.widget.Error.parameter_in_attrs.is_shown()) + param_controls = \ + self.widget._OWCurveFit__param_widget._ParametersWidget__controls + param_controls[0][1].setText("a") + self.assertFalse(self.widget.Error.parameter_in_attrs.is_shown()) + + def test_no_parameter(self): + self.send_signal(self.widget.Inputs.data, self.housing) + self.widget._OWCurveFit__expression_edit.setText("LSTAT + 1") + self.click_apply() + self.assertTrue(self.widget.Error.no_parameter.is_shown()) + self.widget._OWCurveFit__expression_edit.setText("LSTAT + a") + self.click_apply() + self.assertFalse(self.widget.Error.no_parameter.is_shown()) + + def test_unused_parameter(self): + self.send_signal(self.widget.Inputs.data, self.housing) + self.__add_button.click() + self.__add_button.click() + self.assertFalse(self.widget.Warning.unused_parameter.is_shown()) + + self.widget._OWCurveFit__expression_edit.setText("p1 + LSTAT + p2") + self.click_apply() + self.assertFalse(self.widget.Warning.unused_parameter.is_shown()) + + self.widget._OWCurveFit__expression_edit.setText("p1 + LSTAT") + self.click_apply() + self.assertTrue(self.widget.Warning.unused_parameter.is_shown()) + + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Warning.unused_parameter.is_shown()) + + def test_unknown_parameter(self): + self.send_signal(self.widget.Inputs.data, self.housing) + self.__add_button.click() + + self.widget._OWCurveFit__expression_edit.setText("p1 + LSTAT") + self.click_apply() + self.assertFalse(self.widget.Error.unknown_parameter.is_shown()) + + self.widget._OWCurveFit__expression_edit.setText("p2 + LSTAT") + self.click_apply() + self.assertTrue(self.widget.Error.unknown_parameter.is_shown()) + + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Error.unknown_parameter.is_shown()) + + def test_saved_parameters(self): + self.send_signal(self.widget.Inputs.data, self.housing) + self.__add_button.click() + self.assertEqual(self.widget.controls._parameter.model().rowCount(), 2) + param_controls = \ + self.widget._OWCurveFit__param_widget._ParametersWidget__controls + param_controls[0][1].setText("a") + param_controls[0][2].setValue(3) + param_controls[0][3].setChecked(True) + param_controls[0][4].setValue(-10) + param_controls[0][5].setChecked(True) + param_controls[0][6].setValue(10) + settings = self.widget.settingsHandler.pack_data(self.widget) + self.assertEqual( + settings["parameters"], + {"a": ("a", 3, True, -10, True, 10)} + ) + + widget = self.create_widget(OWCurveFit, stored_settings=settings) + self.send_signal(widget.Inputs.data, self.housing, widget=widget) + param_controls = \ + widget._OWCurveFit__param_widget._ParametersWidget__controls + self.assertEqual(param_controls[0][1].text(), "a") + self.assertEqual(param_controls[0][2].value(), 3) + self.assertEqual(param_controls[0][3].isChecked(), True) + self.assertEqual(param_controls[0][4].value(), -10) + self.assertEqual(param_controls[0][5].isChecked(), True) + self.assertEqual(param_controls[0][6].value(), 10) + self.assertEqual(widget.controls._parameter.model().rowCount(), 2) + + def test_saved_expression(self): + self.__init_widget() + exp1 = self.widget._OWCurveFit__expression_edit.text() + self.assertGreater(len(exp1), 0) + + settings = self.widget.settingsHandler.pack_data(self.widget) + widget = self.create_widget(OWCurveFit, stored_settings=settings) + self.__init_widget(widget=widget) + exp2 = widget._OWCurveFit__expression_edit.text() + self.assertEqual(exp1, exp2) + + def test_output(self): + self.send_signal(self.widget.Inputs.data, self.housing) + for _ in range(3): + self.__add_button.click() + exp = "p1 * exp(-p2 * LSTAT) + p3" + self.widget._OWCurveFit__expression_edit.setText(exp) + self.click_apply() + learner = self.get_output(self.widget.Outputs.learner) + self.assertIsInstance(learner, CurveFitLearner) + model = self.get_output(self.widget.Outputs.model) + self.assertIsInstance(model, CurveFitModel) + coef = self.get_output(self.widget.Outputs.coefficients) + self.assertTrue("coef" in coef.domain) + self.assertTrue("name" in coef.domain) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/model/tests/test_owgradientboosting.py b/Orange/widgets/model/tests/test_owgradientboosting.py index 53c7245fc25..a59de79be60 100644 --- a/Orange/widgets/model/tests/test_owgradientboosting.py +++ b/Orange/widgets/model/tests/test_owgradientboosting.py @@ -1,6 +1,8 @@ +import json import unittest -from unittest.mock import patch, Mock import sys +from typing import Type +from unittest.mock import patch, Mock from Orange.classification import GBClassifier @@ -27,13 +29,21 @@ CatGBRegressor = None from Orange.widgets.model.owgradientboosting import OWGradientBoosting, \ LearnerItemModel, GBLearnerEditor, XGBLearnerEditor, XGBRFLearnerEditor, \ - CatGBLearnerEditor + CatGBLearnerEditor, BaseEditor from Orange.widgets.settings import SettingProvider from Orange.widgets.tests.base import WidgetTest, ParameterMapping, \ WidgetLearnerTestMixin, datasets, simulate, GuiTest from Orange.widgets.widget import OWWidget +def get_tree_train_params(model): + ln = json.loads(model.skl_model.get_booster().save_config())["learner"] + try: + return ln["gradient_booster"]["tree_train_param"] + except KeyError: + return ln["gradient_booster"]["updater"]["grow_colmaker"]["train_param"] + + def create_parent(editor_class): class DummyWidget(OWWidget): name = "Mock" @@ -66,11 +76,22 @@ def test_missing_lib(self): self.assertFalse(model.item(1).isEnabled()) -class TestGBLearnerEditor(GuiTest): +class BaseEditorTest(GuiTest): + EditorClass: Type[BaseEditor] = None + def setUp(self): - editor_class = GBLearnerEditor + super().setUp() + editor_class = self.EditorClass self.widget = create_parent(editor_class) - self.editor = editor_class(self.widget) + self.editor = editor_class(self.widget) # pylint: disable=not-callable + + def tearDown(self) -> None: + self.widget.deleteLater() + super().tearDown() + + +class TestGBLearnerEditor(BaseEditorTest): + EditorClass = GBLearnerEditor def test_arguments(self): args = {"n_estimators": 100, "learning_rate": 0.1, "max_depth": 3, @@ -116,11 +137,8 @@ def test_default_parameters_reg(self): self.assertIsNone(params["random_state"]) -class TestXGBLearnerEditor(GuiTest): - def setUp(self): - editor_class = XGBLearnerEditor - self.widget = create_parent(editor_class) - self.editor = editor_class(self.widget) +class TestXGBLearnerEditor(BaseEditorTest): + EditorClass = XGBLearnerEditor def test_arguments(self): args = {"n_estimators": 100, "learning_rate": 0.3, "max_depth": 6, @@ -148,18 +166,17 @@ def test_default_parameters_cls(self): booster = XGBClassifier() model = booster(data) params = model.skl_model.get_params() + tp = get_tree_train_params(model) self.assertEqual(params["n_estimators"], self.editor.n_estimators) - self.assertEqual(round(params["learning_rate"], 1), - self.editor.learning_rate) - self.assertEqual(params["max_depth"], self.editor.max_depth) - self.assertEqual(params["reg_lambda"], self.editor.lambda_) - self.assertEqual(params["subsample"], self.editor.subsample) - self.assertEqual(params["colsample_bytree"], - self.editor.colsample_bytree) - self.assertEqual(params["colsample_bylevel"], - self.editor.colsample_bylevel) - self.assertEqual(params["colsample_bynode"], - self.editor.colsample_bynode) + self.assertEqual( + round(float(tp["learning_rate"]), 1), self.editor.learning_rate + ) + self.assertEqual(int(tp["max_depth"]), self.editor.max_depth) + self.assertEqual(float(tp["reg_lambda"]), self.editor.lambda_) + self.assertEqual(int(tp["subsample"]), self.editor.subsample) + self.assertEqual(int(tp["colsample_bytree"]), self.editor.colsample_bytree) + self.assertEqual(int(tp["colsample_bylevel"]), self.editor.colsample_bylevel) + self.assertEqual(int(tp["colsample_bynode"]), self.editor.colsample_bynode) @unittest.skipIf(XGBRegressor is None, "Missing 'xgboost' package") def test_default_parameters_reg(self): @@ -167,25 +184,21 @@ def test_default_parameters_reg(self): booster = XGBRegressor() model = booster(data) params = model.skl_model.get_params() + tp = get_tree_train_params(model) self.assertEqual(params["n_estimators"], self.editor.n_estimators) - self.assertEqual(round(params["learning_rate"], 1), - self.editor.learning_rate) - self.assertEqual(params["max_depth"], self.editor.max_depth) - self.assertEqual(params["reg_lambda"], self.editor.lambda_) - self.assertEqual(params["subsample"], self.editor.subsample) - self.assertEqual(params["colsample_bytree"], - self.editor.colsample_bytree) - self.assertEqual(params["colsample_bylevel"], - self.editor.colsample_bylevel) - self.assertEqual(params["colsample_bynode"], - self.editor.colsample_bynode) + self.assertEqual( + round(float(tp["learning_rate"]), 1), self.editor.learning_rate + ) + self.assertEqual(int(tp["max_depth"]), self.editor.max_depth) + self.assertEqual(float(tp["reg_lambda"]), self.editor.lambda_) + self.assertEqual(int(tp["subsample"]), self.editor.subsample) + self.assertEqual(int(tp["colsample_bytree"]), self.editor.colsample_bytree) + self.assertEqual(int(tp["colsample_bylevel"]), self.editor.colsample_bylevel) + self.assertEqual(int(tp["colsample_bynode"]), self.editor.colsample_bynode) -class TestXGBRFLearnerEditor(GuiTest): - def setUp(self): - editor_class = XGBRFLearnerEditor - self.widget = create_parent(editor_class) - self.editor = editor_class(self.widget) +class TestXGBRFLearnerEditor(BaseEditorTest): + EditorClass = XGBRFLearnerEditor def test_arguments(self): args = {"n_estimators": 100, "learning_rate": 0.3, "max_depth": 6, @@ -214,18 +227,17 @@ def test_default_parameters_cls(self): booster = XGBRFClassifier() model = booster(data) params = model.skl_model.get_params() + tp = get_tree_train_params(model) self.assertEqual(params["n_estimators"], self.editor.n_estimators) - self.assertEqual(round(params["learning_rate"], 1), - self.editor.learning_rate) - self.assertEqual(params["max_depth"], self.editor.max_depth) - self.assertEqual(params["reg_lambda"], self.editor.lambda_) - self.assertEqual(params["subsample"], self.editor.subsample) - self.assertEqual(params["colsample_bytree"], - self.editor.colsample_bytree) - self.assertEqual(params["colsample_bylevel"], - self.editor.colsample_bylevel) - self.assertEqual(params["colsample_bynode"], - self.editor.colsample_bynode) + self.assertEqual( + round(float(tp["learning_rate"]), 1), self.editor.learning_rate + ) + self.assertEqual(int(tp["max_depth"]), self.editor.max_depth) + self.assertEqual(float(tp["reg_lambda"]), self.editor.lambda_) + self.assertEqual(int(tp["subsample"]), self.editor.subsample) + self.assertEqual(int(tp["colsample_bytree"]), self.editor.colsample_bytree) + self.assertEqual(int(tp["colsample_bylevel"]), self.editor.colsample_bylevel) + self.assertEqual(int(tp["colsample_bynode"]), self.editor.colsample_bynode) @unittest.skipIf(XGBRFRegressor is None, "Missing 'xgboost' package") def test_default_parameters_reg(self): @@ -233,25 +245,21 @@ def test_default_parameters_reg(self): booster = XGBRFRegressor() model = booster(data) params = model.skl_model.get_params() + tp = get_tree_train_params(model) self.assertEqual(params["n_estimators"], self.editor.n_estimators) - self.assertEqual(round(params["learning_rate"], 1), - self.editor.learning_rate) - self.assertEqual(params["max_depth"], self.editor.max_depth) - self.assertEqual(params["reg_lambda"], self.editor.lambda_) - self.assertEqual(params["subsample"], self.editor.subsample) - self.assertEqual(params["colsample_bytree"], - self.editor.colsample_bytree) - self.assertEqual(params["colsample_bylevel"], - self.editor.colsample_bylevel) - self.assertEqual(params["colsample_bynode"], - self.editor.colsample_bynode) + self.assertEqual( + round(float(tp["learning_rate"]), 1), self.editor.learning_rate + ) + self.assertEqual(int(tp["max_depth"]), self.editor.max_depth) + self.assertEqual(float(tp["reg_lambda"]), self.editor.lambda_) + self.assertEqual(int(tp["subsample"]), self.editor.subsample) + self.assertEqual(int(tp["colsample_bytree"]), self.editor.colsample_bytree) + self.assertEqual(int(tp["colsample_bylevel"]), self.editor.colsample_bylevel) + self.assertEqual(int(tp["colsample_bynode"]), self.editor.colsample_bynode) -class TestCatGBLearnerEditor(GuiTest): - def setUp(self): - editor_class = CatGBLearnerEditor - self.widget = create_parent(editor_class) - self.editor = editor_class(self.widget) +class TestCatGBLearnerEditor(BaseEditorTest): + EditorClass = CatGBLearnerEditor def test_arguments(self): args = {"n_estimators": 100, "learning_rate": 0.3, "max_depth": 6, @@ -281,7 +289,7 @@ def test_default_parameters_cls(self): self.assertEqual(params["l2_leaf_reg"], self.editor.lambda_) self.assertEqual(params["rsm"], self.editor.colsample_bylevel) self.assertEqual(self.editor.learning_rate, 0.3) - self.assertEqual(round(params["learning_rate"], 3), 0.006) + # params["learning_rate"] is automatically defined so don't test it @unittest.skipIf(CatGBRegressor is None, "Missing 'catboost' package") def test_default_parameters_reg(self): @@ -295,8 +303,7 @@ def test_default_parameters_reg(self): self.assertEqual(params["l2_leaf_reg"], self.editor.lambda_) self.assertEqual(params["rsm"], self.editor.colsample_bylevel) self.assertEqual(self.editor.learning_rate, 0.3) - self.assertEqual(round(params["learning_rate"], 3), 0.035) - + # params["learning_rate"] is automatically defined so don't test it class TestOWGradientBoosting(WidgetTest, WidgetLearnerTestMixin): def setUp(self): @@ -347,7 +354,7 @@ def test_methods(self): if cls is None: continue simulate.combobox_activate_index(method_cb, i) - self.widget.apply_button.button.click() + self.click_apply() self.assertIsInstance(self.widget.learner, cls) def test_missing_lib(self): diff --git a/Orange/widgets/model/tests/test_owloadmodel.py b/Orange/widgets/model/tests/test_owloadmodel.py index e9528f03fa0..ea06fe809ff 100644 --- a/Orange/widgets/model/tests/test_owloadmodel.py +++ b/Orange/widgets/model/tests/test_owloadmodel.py @@ -8,11 +8,14 @@ import numpy as np +from AnyQt.QtCore import QMimeData, QUrl + from orangewidget.utils.filedialogs import RecentPath from Orange.data import Table from Orange.classification.naive_bayes import NaiveBayesLearner -from Orange.widgets.model.owloadmodel import OWLoadModel +from Orange.widgets.model.owloadmodel import OWLoadModel, OWLoadModelDropHandler from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.tests.utils import dragDrop class TestOWLoadModel(WidgetTest): @@ -21,6 +24,7 @@ class TestOWLoadModel(WidgetTest): event_data = None def setUp(self): + super().setUp() self.widget = self.create_widget(OWLoadModel) # type: OWLoadModel data = Table("iris") self.model = NaiveBayesLearner()(data) @@ -30,6 +34,7 @@ def setUp(self): def tearDown(self): os.remove(self.filename) + super().tearDown() def test_browse_file_opens_file(self): w = self.widget @@ -135,6 +140,35 @@ def test_open_moved_workflow(self, load): finally: os.remove(file_name) + def test_drop_file(self): + mime = QMimeData() + mime.setUrls([QUrl("https://example.com/a.html")]) + with patch.object(self.widget, "open_file") as r: + self.assertFalse(dragDrop(self.widget, mime)) + r.assert_not_called() + + mime.setUrls([QUrl.fromLocalFile("file.notsupported")]) + with patch.object(self.widget, "open_file") as r: + self.assertFalse(dragDrop(self.widget, mime)) + r.assert_not_called() + + mime.setUrls([QUrl.fromLocalFile(self.filename)]) + with patch.object(self.widget, "open_file") as r: + self.assertTrue(dragDrop(self.widget, mime)) + r.assert_called() + + +class TestOWLoadModelDropHandler(unittest.TestCase): + def test_canDropFile(self): + handler = OWLoadModelDropHandler() + self.assertTrue(handler.canDropFile("test.pkcls")) + self.assertFalse(handler.canDropFile("test.txt")) + + def test_parametersFromFile(self): + handler = OWLoadModelDropHandler() + res = handler.parametersFromFile("test.pkcls") + self.assertEqual(res["recent_paths"][0].basename, "test.pkcls") + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/model/tests/test_owlogisticregression.py b/Orange/widgets/model/tests/test_owlogisticregression.py index c8d37c7790e..88b03a68bf1 100644 --- a/Orange/widgets/model/tests/test_owlogisticregression.py +++ b/Orange/widgets/model/tests/test_owlogisticregression.py @@ -48,7 +48,7 @@ def setter(val): self.parameters = [ ParameterMapping('penalty', self.widget.penalty_combo, - self.widget.penalty_types_short), + self.widget.penalty_types_short[:2]), ParameterMapping('C', c_slider, values=[self.widget.C_s[0], self.widget.C_s[-1]], getter=lambda: self.widget.C_s[c_slider.value()], @@ -57,8 +57,8 @@ def setter(val): def test_output_coefficients(self): """Check if coefficients are on output after apply""" self.assertIsNone(self.get_output(self.widget.Outputs.coefficients)) - self.send_signal("Data", self.data) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, self.data) + self.click_apply() self.assertIsInstance(self.get_output(self.widget.Outputs.coefficients), Table) def test_domain_with_more_values_than_table(self): @@ -73,8 +73,8 @@ def test_domain_with_more_values_than_table(self): np.arange(120, 140, dtype=int)))] for case in cases: data = table[case, :] - self.send_signal("Data", data) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, data) + self.click_apply() def test_coefficients_one_value(self): """ @@ -93,8 +93,8 @@ def test_coefficients_one_value(self): [0., 1.], ["yes", "no"])) ) - self.send_signal("Data", table) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, table) + self.click_apply() coef = self.get_output(self.widget.Outputs.coefficients) self.assertEqual(coef.domain[0].name, "no") self.assertGreater(coef[2][0], 0.) @@ -105,20 +105,37 @@ def test_target_with_nan(self): GH-2392 """ table = Table("iris") - table.Y[:5] = np.NaN - self.send_signal("Data", table) - coef1 = self.get_output("Coefficients") + with table.unlocked(): + table.Y[:5] = np.nan + self.send_signal(self.widget.Inputs.data, table) + coef1 = self.get_output(self.widget.Outputs.coefficients) table = table[5:] - self.send_signal("Data", table) - coef2 = self.get_output("Coefficients") + self.send_signal(self.widget.Inputs.data, table) + coef2 = self.get_output(self.widget.Outputs.coefficients) self.assertTrue(np.array_equal(coef1, coef2)) def test_class_weights(self): table = Table("iris") - self.send_signal("Data", table) + self.send_signal(self.widget.Inputs.data, table) self.assertFalse(self.widget.class_weight) self.widget.controls.class_weight.setChecked(True) self.assertTrue(self.widget.class_weight) - self.widget.apply_button.button.click() + self.click_apply() self.assertEqual(self.widget.model.skl_model.class_weight, "balanced") self.assertTrue(self.widget.Warning.class_weights_used.is_shown()) + + def test_no_penalty(self): + self.widget.set_penalty(None) + self.click_apply() + lr = self.get_output(self.widget.Outputs.learner) + self.assertEqual(lr.penalty, None) + self.assertEqual(lr.C, 1.0) + self.assertEqual(self.widget.c_label.text(), "N/A") + self.assertFalse(self.widget.c_slider.isEnabledTo(self.widget)) + + self.widget.set_penalty("l2") + self.click_apply() + lr = self.get_output(self.widget.Outputs.learner) + self.assertEqual(lr.penalty, "l2") + self.assertEqual(self.widget.c_label.text(), "C=1") + self.assertTrue(self.widget.c_slider.isEnabledTo(self.widget)) diff --git a/Orange/widgets/model/tests/test_owneuralnetwork.py b/Orange/widgets/model/tests/test_owneuralnetwork.py index cb0631882ed..60a13b9cf52 100644 --- a/Orange/widgets/model/tests/test_owneuralnetwork.py +++ b/Orange/widgets/model/tests/test_owneuralnetwork.py @@ -15,9 +15,24 @@ def setUp(self): def test_migrate_setting(self): settings = dict(alpha=2.9) - OWNNLearner.migrate_settings(settings, None) + OWNNLearner.migrate_settings(settings, 0) self.assertEqual(OWNNLearner.alphas[settings["alpha_index"]], 3) settings = dict(alpha=103) - OWNNLearner.migrate_settings(settings, None) + OWNNLearner.migrate_settings(settings, 0) self.assertEqual(OWNNLearner.alphas[settings["alpha_index"]], 100) + + settings = dict(alpha_index=0) + OWNNLearner.migrate_settings(settings, version=1) + self.assertEqual(OWNNLearner.alphas[settings["alpha_index"]], 0.0001) + + def test_no_layer_warning(self): + self.assertFalse(self.widget.Warning.no_layers.is_shown()) + + self.widget.hidden_layers_input = "" + self.click_apply() + self.assertTrue(self.widget.Warning.no_layers.is_shown()) + + self.widget.hidden_layers_input = "10," + self.click_apply() + self.assertFalse(self.widget.Warning.no_layers.is_shown()) diff --git a/Orange/widgets/model/tests/test_owpls.py b/Orange/widgets/model/tests/test_owpls.py new file mode 100644 index 00000000000..c7dfa19ddec --- /dev/null +++ b/Orange/widgets/model/tests/test_owpls.py @@ -0,0 +1,165 @@ +import unittest +import numpy as np +from sklearn.cross_decomposition import PLSRegression + +from Orange.data import Table, Domain, StringVariable +from Orange.widgets.model.owpls import OWPLS +from Orange.widgets.tests.base import WidgetTest, WidgetLearnerTestMixin, \ + ParameterMapping + + +class TestOWPLS(WidgetTest, WidgetLearnerTestMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._data = Table("housing") + cls._data = cls._data.add_column(StringVariable("Foo"), + ["Bar"] * len(cls._data), + to_metas=True) + class_vars = [cls._data.domain.class_var, + cls._data.domain.attributes[0]] + domain = Domain(cls._data.domain.attributes[1:], class_vars, + cls._data.domain.metas) + cls._data_multi_target = cls._data.transform(domain) + + def setUp(self): + self.widget = self.create_widget(OWPLS, + stored_settings={"auto_apply": False}) + self.init() + self.parameters = [ + ParameterMapping('max_iter', self.widget.controls.max_iter), + ParameterMapping('n_components', self.widget.controls.n_components) + ] + + def test_coeffs_compare_sklearn(self): + self.send_signal(self.widget.Inputs.data, self._data) + coefsdata = self.get_output(self.widget.Outputs.coefsdata) + intercept = coefsdata.X[-1, 0] + coeffs = coefsdata.X[:-2, 0] + Y_orange = self._data.X @ coeffs + intercept + + pls = PLSRegression(n_components=2) + pls.fit(self._data.X, self._data.Y) + Y_sklearn = pls.predict(self._data.X) + + np.testing.assert_almost_equal(Y_sklearn, Y_orange) + + def test_output_coefsdata(self): + self.send_signal(self.widget.Inputs.data, self._data) + coefsdata = self.get_output(self.widget.Outputs.coefsdata) + self.assertEqual(coefsdata.name, "Coefficients and Loadings") + self.assertEqual(coefsdata.X.shape, (15, 4)) + self.assertEqual(coefsdata.Y.shape, (15, 0)) + self.assertEqual(coefsdata.metas.shape, (15, 2)) + + self.assertEqual(["coef (MEDV)", "coef * X_sd (MEDV)", "w*c 1", "w*c 2"], + [v.name for v in coefsdata.domain.attributes]) + self.assertEqual(["Variable name", "Variable role"], + [v.name for v in coefsdata.domain.metas]) + metas = [v.name for v in self._data.domain.variables] + ["intercept"] + self.assertTrue((coefsdata.metas[:, 0] == metas).all()) + self.assertTrue((coefsdata.metas[:-2, 1] == 0).all()) + self.assertTrue((coefsdata.metas[-2, 1] == 1)) + self.assertTrue(np.isnan(coefsdata.metas[-1, 1])) + self.assertAlmostEqual(coefsdata.X[0, 3], 0.012, 3) + self.assertAlmostEqual(coefsdata.X[13, 3], 0.389, 3) + self.assertAlmostEqual(coefsdata.X[-1, 0], 13.7, 1) + self.assertTrue(np.isnan(coefsdata.X[-1, 2:]).all()) + + def test_output_coefsdata_multi_target(self): + self.send_signal(self.widget.Inputs.data, self._data_multi_target) + coefsdata = self.get_output(self.widget.Outputs.coefsdata) + self.assertEqual(coefsdata.name, "Coefficients and Loadings") + self.assertEqual(coefsdata.X.shape, (15, 6)) + self.assertEqual(coefsdata.Y.shape, (15, 0)) + self.assertEqual(coefsdata.metas.shape, (15, 2)) + + attr_names = ["coef (MEDV)", "coef (CRIM)", "coef * X_sd (MEDV)", + "coef * X_sd (CRIM)", "w*c 1", "w*c 2"] + self.assertEqual(attr_names, + [v.name for v in coefsdata.domain.attributes]) + self.assertEqual(["Variable name", "Variable role"], + [v.name for v in coefsdata.domain.metas]) + metas = [v.name for v in self._data_multi_target.domain.variables] + metas += ["intercept"] + self.assertTrue((coefsdata.metas[:, 0] == metas).all()) + self.assertTrue((coefsdata.metas[:-3, 1] == 0).all()) + self.assertTrue((coefsdata.metas[-2:-1, 1] == 1).all()) + self.assertTrue(np.isnan(coefsdata.metas[-1, 1])) + self.assertAlmostEqual(coefsdata.X[0, 4], -0.198, 3) + self.assertAlmostEqual(coefsdata.X[12, 4], -0.288, 3) + self.assertAlmostEqual(coefsdata.X[13, 4], 0.243, 3) + self.assertAlmostEqual(coefsdata.X[-1, 0], 6.7, 1) + self.assertAlmostEqual(coefsdata.X[-1, 1], -12.2, 1) + self.assertTrue(np.isnan(coefsdata.X[-1, 4:]).all()) + + def test_output_data(self): + self.send_signal(self.widget.Inputs.data, self._data) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.X.shape, (506, 13)) + self.assertEqual(output.Y.shape, (506,)) + self.assertEqual(output.metas.shape, (506, 8)) + self.assertEqual([v.name for v in self._data.domain.variables], + [v.name for v in output.domain.variables]) + metas = ["PLS T1", "PLS T2", "PLS U1", "PLS U2", + "Sample Quantiles (MEDV)", "Theoretical Quantiles (MEDV)", + "DModX"] + self.assertEqual([v.name for v in self._data.domain.metas] + metas, + [v.name for v in output.domain.metas]) + + def test_output_data_multi_target(self): + self.send_signal(self.widget.Inputs.data, self._data_multi_target) + output = self.get_output(self.widget.Outputs.data) + self.assertEqual(output.X.shape, (506, 12)) + self.assertEqual(output.Y.shape, (506, 2)) + self.assertEqual(output.metas.shape, (506, 10)) + orig_domain = self._data_multi_target.domain + self.assertEqual([v.name for v in orig_domain.variables], + [v.name for v in output.domain.variables]) + metas = ["PLS T1", "PLS T2", "PLS U1", "PLS U2", + "Sample Quantiles (MEDV)", "Theoretical Quantiles (MEDV)", + "Sample Quantiles (CRIM)", "Theoretical Quantiles (CRIM)", + "DModX"] + self.assertEqual([v.name for v in orig_domain.metas] + metas, + [v.name for v in output.domain.metas]) + + def test_output_components(self): + self.send_signal(self.widget.Inputs.data, self._data) + components = self.get_output(self.widget.Outputs.components) + self.assertEqual(components.X.shape, (2, 13)) + self.assertEqual(components.Y.shape, (2,)) + self.assertEqual(components.metas.shape, (2, 1)) + + def test_output_components_multi_target(self): + self.send_signal(self.widget.Inputs.data, self._data_multi_target) + components = self.get_output(self.widget.Outputs.components) + self.assertEqual(components.X.shape, (2, 12)) + self.assertEqual(components.Y.shape, (2, 2)) + self.assertEqual(components.metas.shape, (2, 1)) + + def test_missing_target(self): + data = self._data[:5].copy() + data.Y[[0, 4]] = np.nan + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.data) + self.assertFalse(np.isnan(output.metas[:, 1:3].astype(float)).any()) + self.assertTrue(np.isnan(output.metas[0, 3:4].astype(float)).all()) + self.assertTrue(np.isnan(output.metas[4, 3:5].astype(float)).all()) + self.assertFalse(np.isnan(output.metas[1:4, 3:5].astype(float)).any()) + + with data.unlocked(data.Y): + data.Y[:] = np.nan + self.send_signal(self.widget.Inputs.data, data) + self.assertIsNone(self.get_output(self.widget.Outputs.data)) + + def test_scale(self): + self.widget.auto_apply = True + self.send_signal(self.widget.Inputs.data, self._data) + output1 = self.get_output(self.widget.Outputs.data) + self.widget.controls.scale.setChecked(False) + output2 = self.get_output(self.widget.Outputs.data) + self.assertTrue(abs(output1.metas[0, 1] - output2.metas[0, 1]) > 100) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/model/tests/test_owrandomforest.py b/Orange/widgets/model/tests/test_owrandomforest.py index 32d10e98dfe..003ea531e37 100644 --- a/Orange/widgets/model/tests/test_owrandomforest.py +++ b/Orange/widgets/model/tests/test_owrandomforest.py @@ -1,5 +1,8 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring +import unittest + +from AnyQt.QtCore import Qt from Orange.data import Table from Orange.widgets.model.owrandomforest import OWRandomForest from Orange.widgets.tests.base import ( @@ -26,8 +29,8 @@ def test_parameters_checked(self): """Check learner and model for various values of all parameters when all properties are checked """ - self.widget.max_features_spin[0].setCheckState(True) - self.widget.max_depth_spin[0].setCheckState(True) + self.widget.max_features_spin[0].setCheckState(Qt.Checked) + self.widget.max_depth_spin[0].setCheckState(Qt.Checked) self.parameters.extend([ ParameterMapping("max_features", self.widget.max_features_spin[1]), ParameterMapping("max_depth", self.widget.max_depth_spin[1])]) @@ -37,10 +40,10 @@ def test_parameters_unchecked(self): """Check learner and model for various values of all parameters when properties are not checked """ - self.widget.min_samples_split_spin[0].setCheckState(False) + self.widget.min_samples_split_spin[0].setCheckState(Qt.Unchecked) self.parameters = self.parameters[:1] self.parameters.extend([ - DefaultParameterMapping("max_features", "auto"), + DefaultParameterMapping("max_features", "sqrt"), DefaultParameterMapping("random_state", None), DefaultParameterMapping("max_depth", None), DefaultParameterMapping("min_samples_split", 2)]) @@ -48,10 +51,14 @@ def test_parameters_unchecked(self): def test_class_weights(self): table = Table("iris") - self.send_signal("Data", table) + self.send_signal(self.widget.Inputs.data, table) self.assertFalse(self.widget.class_weight) self.widget.controls.class_weight.setChecked(True) self.assertTrue(self.widget.class_weight) - self.widget.apply_button.button.click() + self.click_apply() self.assertEqual(self.widget.model.skl_model.class_weight, "balanced") self.assertTrue(self.widget.Warning.class_weights_used.is_shown()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/model/tests/test_owrulesclassification.py b/Orange/widgets/model/tests/test_owrulesclassification.py index bdec6d4fef4..dccb65adf3f 100644 --- a/Orange/widgets/model/tests/test_owrulesclassification.py +++ b/Orange/widgets/model/tests/test_owrulesclassification.py @@ -110,13 +110,14 @@ def test_alpha_double_spin_boxes(self): def test_sparse_data(self): data = Table("iris") - data.X = sparse.csr_matrix(data.X) + with data.unlocked(): + data.X = sparse.csr_matrix(data.X) self.assertTrue(sparse.issparse(data.X)) - self.send_signal("Data", data) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, data) + self.click_apply() self.assertTrue(self.widget.Error.sparse_not_supported.is_shown()) - self.send_signal("Data", None) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, None) + self.click_apply() self.assertFalse(self.widget.Error.sparse_not_supported.is_shown()) def test_out_of_memory(self): @@ -129,13 +130,13 @@ def test_out_of_memory(self): with unittest.mock.patch( "Orange.widgets.model.owrules.CustomRuleLearner.__call__", side_effect=MemoryError): - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) self.assertTrue(self.widget.Error.out_of_memory.is_shown()) - self.send_signal("Data", None) + self.send_signal(self.widget.Inputs.data, None) self.assertFalse(self.widget.Error.out_of_memory.is_shown()) def test_default_rule(self): data = Table("zoo") - self.send_signal("Data", data) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, data) + self.click_apply() self.assertEqual(sum(self.widget.model.rule_list[-1].curr_class_dist.tolist()), len(data)) diff --git a/Orange/widgets/model/tests/test_owscoringsheet.py b/Orange/widgets/model/tests/test_owscoringsheet.py new file mode 100644 index 00000000000..0a31c702a71 --- /dev/null +++ b/Orange/widgets/model/tests/test_owscoringsheet.py @@ -0,0 +1,145 @@ +import unittest +import numpy as np + +from orangewidget.tests.base import WidgetTest + +from Orange.data import Table +from Orange.preprocess import Impute + +from Orange.widgets.model.owscoringsheet import OWScoringSheet + + +class TestOWScoringSheet(WidgetTest): + def setUp(self): + self.widget = self.create_widget(OWScoringSheet) + self.heart = Table("heart_disease") + self.housing = Table("housing") + + def test_no_data_input(self): + self.assertIsNotNone(self.get_output(self.widget.Outputs.learner)) + self.assertIsNone(self.get_output(self.widget.Outputs.model)) + + def test_numerical_target_attribute(self): + self.send_signal(self.widget.Inputs.data, self.housing) + self.wait_until_finished() + self.assertTrue(self.widget.Error.fitting_failed.is_shown()) + + def test_settings_in_learner(self): + self.widget.num_attr_after_selection = 20 + self.widget.num_decision_params = 7 + self.widget.max_points_per_param = 8 + self.widget.custom_features_checkbox = True + self.widget.num_input_features = 4 + + self.widget.apply() + + self.send_signal(self.widget.Inputs.data, self.heart) + learner = self.get_output(self.widget.Outputs.learner) + + self.assertEqual(learner.num_decision_params, 7) + self.assertEqual(learner.max_points_per_param, 8) + self.assertEqual(learner.num_input_features, 4) + + def test_settings_in_model(self): + self.widget.num_attr_after_selection = 20 + self.widget.num_decision_params = 7 + self.widget.max_points_per_param = 8 + self.widget.custom_features_checkbox = True + self.widget.num_input_features = 4 + + self.widget.apply() + + self.send_signal(self.widget.Inputs.data, self.heart) + self.wait_until_finished() + model = self.get_output(self.widget.Outputs.model) + + coefficients = model.model.coefficients + non_zero_coefficients = [coef for coef in coefficients if coef != 0] + + self.assertEqual(len(coefficients), self.widget.num_attr_after_selection) + + self.assertEqual(len(non_zero_coefficients), self.widget.num_decision_params) + + self.assertLessEqual( + max(non_zero_coefficients, key=lambda x: abs(x)), + self.widget.max_points_per_param, + ) + + def test_model_reproducibility(self): + self.widget = self.create_widget(OWScoringSheet) + self.widget.num_attr_after_selection = 20 + self.widget.num_decision_params = 7 + self.widget.max_points_per_param = 8 + self.widget.custom_features_checkbox = True + self.widget.num_input_features = 4 + + self.widget.apply() + + self.send_signal(self.widget.Inputs.data, self.heart) + self.wait_until_finished() + model = self.get_output(self.widget.Outputs.model) + + coefficients = np.array( + [ + -8.0, 6.0, 0.0, 0.0, -3.0, 4.0, 0.0, -2.0, -1.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -6.0, 0.0, 0.0, 0.0, 0.0, + ] + ) + feature_names = [ + "major vessels colored=< 1", "chest pain=asymptomatic", "gender=female", + "gender=male", "thal=normal", "thal=reversable defect", "rest SBP=125 - 150", + "chest pain=non-anginal", "major vessels colored=1 - 2", "major vessels colored=2 - 3", + "chest pain=atypical ang", "chest pain=typical ang", "rest SBP=150 - 175", + "rest ECG=left vent hypertrophy", "rest ECG=normal", "ST by exercise=< 2", + "rest SBP=100 - 125", "exerc ind ang=0", "exerc ind ang=1", "age=40 - 60", + ] + intercept = 7.0 + multiplier = 3.4567159 + + np.testing.assert_equal(model.model.coefficients, coefficients) + self.assertEqual(model.model.featureNames, feature_names) + self.assertEqual(model.model.intercept, intercept) + self.assertAlmostEqual(model.model.multiplier, multiplier, places=5) + + def test_custom_number_input_features_information(self): + self.widget.custom_features_checkbox = True + self.widget.custom_input_features() + self.assertTrue(self.widget.Information.custom_num_of_input_features.is_shown()) + + self.widget.custom_features_checkbox = False + self.widget.custom_input_features() + self.assertFalse( + self.widget.Information.custom_num_of_input_features.is_shown() + ) + + def test_custom_preprocessors_information(self): + preprocessor = Impute() + self.send_signal(self.widget.Inputs.preprocessor, preprocessor) + self.assertTrue(self.widget.Information.ignored_preprocessors.is_shown()) + + self.send_signal(self.widget.Inputs.preprocessor, None) + self.assertFalse(self.widget.Information.ignored_preprocessors.is_shown()) + + def test_custom_preprocessors_spin_disabled(self): + preprocessor = Impute() + self.send_signal(self.widget.Inputs.preprocessor, preprocessor) + self.assertFalse(self.widget.num_attr_after_selection_spin.isEnabled()) + + def test_default_preprocessors_are_used(self): + learner = self.get_output(self.widget.Outputs.learner) + + self.assertIsNotNone(learner.preprocessors) + self.assertEqual(len(learner.preprocessors), 5) + + def test_custom_preprocessors_are_used(self): + preprocessor = Impute() + self.send_signal(self.widget.Inputs.preprocessor, preprocessor) + learner = self.get_output(self.widget.Outputs.learner) + + self.assertIsNotNone(learner.preprocessors) + self.assertEqual(len(learner.preprocessors), 1) + self.assertEqual(learner.preprocessors[0], preprocessor) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/model/tests/test_owstack.py b/Orange/widgets/model/tests/test_owstack.py index 9df4a71f7f8..cf703a284d0 100644 --- a/Orange/widgets/model/tests/test_owstack.py +++ b/Orange/widgets/model/tests/test_owstack.py @@ -15,19 +15,19 @@ def setUp(self): def test_input_data(self): """Check widget's data with data on the input""" self.assertEqual(self.widget.data, None) - self.send_signal("Data", self.data) + self.send_signal(self.widget.Inputs.data, self.data) self.assertEqual(self.widget.data, self.data) self.wait_until_stop_blocking() def test_output_learner(self): """Check if learner is on output after apply""" self.assertIsNone(self.get_output(self.widget.Outputs.model)) - self.send_signal("Learners", LogisticRegressionLearner(), 0) - self.widget.apply_button.button.click() - initial = self.get_output("Learner") + self.send_signal(self.widget.Inputs.learners, LogisticRegressionLearner(), 0) + self.widget.apply_button.button.clicked.emit() + initial = self.get_output(self.widget.Outputs.learner) self.assertIsNotNone(initial, "Does not initialize the learner output") - self.widget.apply_button.button.click() - newlearner = self.get_output("Learner") + self.widget.apply_button.button.clicked.emit() + newlearner = self.get_output(self.widget.Outputs.learner) self.assertIsNot(initial, newlearner, "Does not send a new learner instance on `Apply`.") self.assertIsNotNone(newlearner) @@ -36,11 +36,11 @@ def test_output_learner(self): def test_output_model(self): """Check if model is on output after sending data and apply""" self.assertIsNone(self.get_output(self.widget.Outputs.model)) - self.send_signal("Learners", LogisticRegressionLearner(), 0) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.learners, LogisticRegressionLearner(), 0) + self.widget.apply_button.button.clicked.emit() self.assertIsNone(self.get_output(self.widget.Outputs.model)) - self.send_signal('Data', self.data) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, self.data) + self.widget.apply_button.button.clicked.emit() self.wait_until_stop_blocking() model = self.get_output(self.widget.Outputs.model) self.assertIsNotNone(model) diff --git a/Orange/widgets/model/tests/test_owsvm.py b/Orange/widgets/model/tests/test_owsvm.py index aad3424e71d..b165e47518b 100644 --- a/Orange/widgets/model/tests/test_owsvm.py +++ b/Orange/widgets/model/tests/test_owsvm.py @@ -2,6 +2,7 @@ # pylint: disable=missing-docstring from scipy.sparse import csr_matrix +from AnyQt.QtCore import Qt from Orange.widgets.model.owsvm import OWSVM from Orange.widgets.tests.base import ( WidgetTest, @@ -47,7 +48,7 @@ def test_parameters_unchecked(self): """Check learner and model for various values of all parameters when Iteration limit is not checked """ - self.widget.max_iter_spin[0].setCheckState(False) + self.widget.max_iter_spin[0].setCheckState(Qt.Unchecked) self.parameters[-1] = DefaultParameterMapping("max_iter", -1) self.test_parameters() @@ -94,8 +95,29 @@ def test_kernel_spins(self): def test_sparse_warning(self): """Check if the user is warned about sparse input""" data = Table("iris") - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) self.assertFalse(self.widget.Warning.sparse_data.is_shown()) - data.X = csr_matrix(data.X) - self.send_signal("Data", data) + + with data.unlocked(): + data.X = csr_matrix(data.X) + self.send_signal(self.widget.Inputs.data, data) self.assertTrue(self.widget.Warning.sparse_data.is_shown()) + + def test_change_degree(self): + data = Table("iris") + self.send_signal(self.widget.Inputs.data, data) + self.widget.kernel_box.buttons[1].click() + degree_spin = self.widget._kernel_params[2] # pylint: disable=protected-access + degree_spin.stepUp() + self.assertEqual(self.widget.degree, 4) + self.click_apply() + self.wait_until_stop_blocking() + self.assertFalse(self.widget.Error.fitting_failed.is_shown()) + + def test_migrate_degree(self): + settings = {} + OWSVM.migrate_settings(settings, 1) + + settings = {"degree": 4.0} + OWSVM.migrate_settings(settings, 1) + self.assertIsInstance(settings["degree"], int) diff --git a/Orange/widgets/model/tests/test_tree.py b/Orange/widgets/model/tests/test_tree.py index 2f3d919d39b..ac553fd0fcb 100644 --- a/Orange/widgets/model/tests/test_tree.py +++ b/Orange/widgets/model/tests/test_tree.py @@ -1,5 +1,6 @@ # pylint: disable=protected-access import numpy as np +from AnyQt.QtCore import Qt from Orange.base import Model from Orange.data import Table @@ -35,7 +36,7 @@ def test_parameters_unchecked(self): when pruning parameters are not checked """ for cb in self.checks: - cb.setCheckState(False) + cb.setCheckState(Qt.Unchecked) self.parameters = [DefaultParameterMapping(par.name, val) for par, val in zip(self.parameters, (None, 2, 1))] self.test_parameters() @@ -46,11 +47,11 @@ def test_sparse_data_classification(self): GH-2430 """ table1 = Table("iris") - self.send_signal("Data", table1) - model_dense = self.get_output("Model") + self.send_signal(self.widget.Inputs.data, table1) + model_dense = self.get_output(self.widget.Outputs.model) table2 = Table("iris").to_sparse() - self.send_signal("Data", table2) - model_sparse = self.get_output("Model") + self.send_signal(self.widget.Inputs.data, table2) + model_sparse = self.get_output(self.widget.Outputs.model) self.assertTrue(np.array_equal(model_dense._code, model_sparse._code)) self.assertTrue(np.array_equal(model_dense._values, model_sparse._values)) @@ -60,10 +61,10 @@ def test_sparse_data_regression(self): GH-2497 """ table1 = Table("housing") - self.send_signal("Data", table1) - model_dense = self.get_output("Model") + self.send_signal(self.widget.Inputs.data, table1) + model_dense = self.get_output(self.widget.Outputs.model) table2 = Table("housing").to_sparse() - self.send_signal("Data", table2) - model_sparse = self.get_output("Model") + self.send_signal(self.widget.Inputs.data, table2) + model_sparse = self.get_output(self.widget.Outputs.model) self.assertTrue(np.array_equal(model_dense._code, model_sparse._code)) self.assertTrue(np.array_equal(model_dense._values, model_sparse._values)) diff --git a/Orange/widgets/obsolete/__init__.py b/Orange/widgets/obsolete/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/obsolete/owtable.py b/Orange/widgets/obsolete/owtable.py new file mode 100644 index 00000000000..251fa06ec6c --- /dev/null +++ b/Orange/widgets/obsolete/owtable.py @@ -0,0 +1,526 @@ +import itertools +import concurrent.futures + +from collections import namedtuple +from typing import List, Optional + +import numpy +from scipy.sparse import issparse + +from AnyQt.QtWidgets import QTableView, QHeaderView, QApplication, QStyle +from AnyQt.QtGui import QColor, QClipboard +from AnyQt.QtCore import ( + Qt, QSize, QMetaObject, + QAbstractProxyModel, + QItemSelectionModel, QItemSelection, QItemSelectionRange, +) +from AnyQt.QtCore import pyqtSlot as Slot + +import Orange.data +from Orange.data.table import Table +from Orange.data.sql.table import SqlTable + +from Orange.widgets import gui +from Orange.widgets.data.utils.tableview import RichTableView +from Orange.widgets.settings import Setting +from Orange.widgets.data.utils.models import TableSliceProxy, RichTableModel +from Orange.widgets.utils.itemdelegates import TableDataDelegate +from Orange.widgets.utils.itemselectionmodel import ( + BlockSelectionModel, ranges, selection_blocks +) +from Orange.widgets.utils.tableview import table_selection_to_mime_data +from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.widget import OWWidget, MultiInput, Output, Msg +from Orange.widgets.utils.annotated_data import (create_annotated_table, + ANNOTATED_DATA_SIGNAL_NAME) +from Orange.widgets.utils.itemmodels import TableModel +from Orange.widgets.utils.state_summary import format_summary_details +from Orange.widgets.data.utils import tablesummary as tsummary + + +TableSlot = namedtuple("TableSlot", ["input_id", "table", "summary", "view"]) + + +class DataTableView(gui.HScrollStepMixin, RichTableView): + dataset: Table + input_slot: TableSlot + + +class TableBarItemDelegate(gui.TableBarItem, TableDataDelegate): + pass + + +class OWDataTable(OWWidget): + category = "Orange Obsolete" + replaces = ["Orange.widgets.data.owtable.OWDataTable"] + + name = "Data Table" + description = "View the dataset in a spreadsheet." + icon = "../data/icons/Table.svg" + priority = 50 + keywords = "_keywords" + + class Inputs: + data = MultiInput("Data", Table, auto_summary=False, filter_none=True) + + class Outputs: + selected_data = Output("Selected Data", Table, default=True) + annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table) + + class Warning(OWWidget.Warning): + multiple_inputs = Msg( + "Multiple Data inputs are deprecated.\n" + "This functionality will be removed soon.\n" + "Use multiple Tables instead.") + + buttons_area_orientation = Qt.Vertical + + show_distributions = Setting(False) + show_attribute_labels = Setting(True) + select_rows = Setting(True) + auto_commit = Setting(True) + + color_by_class = Setting(True) + selected_rows = Setting([], schema_only=True) + selected_cols = Setting([], schema_only=True) + + settings_version = 2 + + def __init__(self): + super().__init__() + self._inputs: List[TableSlot] = [] + self.__pending_selected_rows = self.selected_rows + self.selected_rows = None + self.__pending_selected_cols = self.selected_cols + self.selected_cols = None + + self.dist_color = QColor(220, 220, 220, 255) + + info_box = gui.vBox(self.controlArea, "Info") + self.info_text = gui.widgetLabel(info_box) + self._set_input_summary(None) + + box = gui.vBox(self.controlArea, "Variables") + self.c_show_attribute_labels = gui.checkBox( + box, self, "show_attribute_labels", + "Show variable labels (if present)", + callback=self._on_show_variable_labels_changed) + + gui.checkBox(box, self, "show_distributions", + 'Visualize numeric values', + callback=self._on_distribution_color_changed) + gui.checkBox(box, self, "color_by_class", 'Color by instance classes', + callback=self._on_distribution_color_changed) + + box = gui.vBox(self.controlArea, "Selection") + + gui.checkBox(box, self, "select_rows", "Select full rows", + callback=self._on_select_rows_changed) + + gui.rubber(self.controlArea) + + gui.button(self.buttonsArea, self, "Restore Original Order", + callback=self.restore_order, + tooltip="Show rows in the original order", + autoDefault=False, + attribute=Qt.WA_LayoutUsesWidgetRect) + gui.auto_send(self.buttonsArea, self, "auto_commit") + + # GUI with tabs + self.tabs = gui.tabWidget(self.mainArea) + self.tabs.currentChanged.connect(self._on_current_tab_changed) + + def copy_to_clipboard(self): + self.copy() + + def sizeHint(self): + return QSize(800, 500) + + def _create_table_view(self): + view = DataTableView() + view.setSortingEnabled(True) + view.setItemDelegate(TableDataDelegate(view)) + + if self.select_rows: + view.setSelectionBehavior(QTableView.SelectRows) + + header = view.horizontalHeader() + header.setSectionsMovable(True) + header.setSectionsClickable(True) + header.setSortIndicatorShown(True) + header.setSortIndicator(-1, Qt.AscendingOrder) + + # QHeaderView does not 'reset' the model sort column, + # because there is no guaranty (requirement) that the + # models understand the -1 sort column. + def sort_reset(index, order): + if view.model() is not None and index == -1: + view.model().sort(index, order) + header.sortIndicatorChanged.connect(sort_reset) + return view + + @Inputs.data + def set_dataset(self, index: int, data: Table): + """Set the input dataset.""" + datasetname = getattr(data, "name", "Data") + slot = self._inputs[index] + view = slot.view + # reset the (header) view state. + view.setModel(None) + view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) + assert self.tabs.indexOf(view) != -1 + self.tabs.setTabText(self.tabs.indexOf(view), datasetname) + view.dataset = data + slot = TableSlot(index, data, tsummary.table_summary(data), view) + view.input_slot = slot + self._inputs[index] = slot + self._setup_table_view(view, data) + self.tabs.setCurrentWidget(view) + self._set_multi_input_warning() + + @Inputs.data.insert + def insert_dataset(self, index: int, data: Table): + datasetname = getattr(data, "name", "Data") + view = self._create_table_view() + slot = TableSlot(None, data, tsummary.table_summary(data), view) + view.dataset = data + view.input_slot = slot + self._inputs.insert(index, slot) + self.tabs.insertTab(index, view, datasetname) + self._setup_table_view(view, data) + self.tabs.setCurrentWidget(view) + self._set_multi_input_warning() + + @Inputs.data.remove + def remove_dataset(self, index): + slot = self._inputs.pop(index) + view = slot.view + self.tabs.removeTab(self.tabs.indexOf(view)) + view.setModel(None) + view.hide() + view.deleteLater() + + current = self.tabs.currentWidget() + if current is not None: + self._set_input_summary(current.input_slot) + self._set_multi_input_warning() + + def _set_multi_input_warning(self): + self.Warning.multiple_inputs(shown=len(self._inputs) > 1) + + def handleNewSignals(self): + super().handleNewSignals() + self.tabs.tabBar().setVisible(self.tabs.count() > 1) + data: Optional[Table] = None + current = self.tabs.currentWidget() + slot = None + if current is not None: + data = current.dataset + slot = current.input_slot + + if slot and isinstance(slot.summary.len, concurrent.futures.Future): + def update(_): + QMetaObject.invokeMethod( + self, "_update_info", Qt.QueuedConnection) + slot.summary.len.add_done_callback(update) + self._set_input_summary(slot) + + if data is not None and self.__pending_selected_rows is not None: + self.selected_rows = self.__pending_selected_rows + self.__pending_selected_rows = None + else: + self.selected_rows = [] + + if data and self.__pending_selected_cols is not None: + self.selected_cols = self.__pending_selected_cols + self.__pending_selected_cols = None + else: + self.selected_cols = [] + + self.set_selection() + self.commit.now() + + def _setup_table_view(self, view, data): + """Setup the `view` (QTableView) with `data` (Orange.data.Table) + """ + datamodel = RichTableModel(data) + rowcount = len(data) + + if self.color_by_class and data.domain.has_discrete_class: + color_schema = [ + QColor(*c) for c in data.domain.class_var.colors] + else: + color_schema = None + if self.show_distributions: + view.setItemDelegate( + TableBarItemDelegate( + view, color=self.dist_color, color_schema=color_schema) + ) + else: + view.setItemDelegate(TableDataDelegate(view)) + + header = view.horizontalHeader() + header.sortIndicatorChanged.connect(self.update_selection) + + view.setModel(datamodel) + + vheader = view.verticalHeader() + option = view.viewOptions() + size = view.style().sizeFromContents( + QStyle.CT_ItemViewItem, option, + QSize(20, 20), view) + + vheader.setDefaultSectionSize(size.height() + 2) + vheader.setMinimumSectionSize(5) + vheader.setSectionResizeMode(QHeaderView.Fixed) + + # Limit the number of rows displayed in the QTableView + # (workaround for QTBUG-18490 / QTBUG-28631) + maxrows = (2 ** 31 - 1) // (vheader.defaultSectionSize() + 2) + if rowcount > maxrows: + sliceproxy = TableSliceProxy( + parent=view, rowSlice=slice(0, maxrows)) + sliceproxy.setSourceModel(datamodel) + # First reset the view (without this the header view retains + # it's state - at this point invalid/broken) + view.setModel(None) + view.setModel(sliceproxy) + + assert view.model().rowCount() <= maxrows + assert vheader.sectionSize(0) > 1 or datamodel.rowCount() == 0 + + # update the header (attribute names) + self._update_variable_labels(view) + + selmodel = BlockSelectionModel( + view.model(), parent=view, selectBlocks=not self.select_rows) + view.setSelectionModel(selmodel) + view.selectionFinished.connect(self.update_selection) + + def _set_input_summary(self, slot): + def format_summary(summary): + return summary.len + + summary, details = self.info.NoInput, "" + if slot: + summary = format_summary(slot.summary) + details = format_summary_details(slot.table) + self.info.set_input_summary(summary, details) + if slot is None: + summary = ["No data."] + else: + summary = tsummary.format_summary(slot.summary) + self.info_text.setText("\n".join(summary)) + + def _on_current_tab_changed(self, index): + """Update the status bar on current tab change""" + view = self.tabs.widget(index) + if view is not None and view.model() is not None: + self._set_input_summary(view.input_slot) + self.update_selection() + else: + self._set_input_summary(None) + + def _update_variable_labels(self, view): + "Update the variable labels visibility for `view`" + model = view.model() + if isinstance(model, TableSliceProxy): + model = model.sourceModel() + + if self.show_attribute_labels: + model.setRichHeaderFlags( + RichTableModel.Labels | RichTableModel.Name) + else: + model.setRichHeaderFlags(RichTableModel.Name) + + def _on_show_variable_labels_changed(self): + """The variable labels (var.attribues) visibility was changed.""" + for slot in self._inputs: + self._update_variable_labels(slot.view) + + def _on_distribution_color_changed(self): + for ti in range(self.tabs.count()): + widget = self.tabs.widget(ti) + model = widget.model() + while isinstance(model, QAbstractProxyModel): + model = model.sourceModel() + data = model.source + class_var = data.domain.class_var + if self.color_by_class and class_var and class_var.is_discrete: + color_schema = [QColor(*c) for c in class_var.colors] + else: + color_schema = None + if self.show_distributions: + delegate = TableBarItemDelegate(widget, color=self.dist_color, + color_schema=color_schema) + else: + delegate = TableDataDelegate(widget) + widget.setItemDelegate(delegate) + tab = self.tabs.currentWidget() + if tab: + tab.reset() + + def _on_select_rows_changed(self): + for slot in self._inputs: + selection_model = slot.view.selectionModel() + selection_model.setSelectBlocks(not self.select_rows) + if self.select_rows: + slot.view.setSelectionBehavior(QTableView.SelectRows) + # Expand the current selection to full row selection. + selection_model.select( + selection_model.selection(), + QItemSelectionModel.Select | QItemSelectionModel.Rows + ) + else: + slot.view.setSelectionBehavior(QTableView.SelectItems) + + def restore_order(self): + """Restore the original data order of the current view.""" + table = self.tabs.currentWidget() + if table is not None: + table.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) + + @Slot() + def _update_info(self): + current = self.tabs.currentWidget() + if current is not None and current.model() is not None: + self._set_input_summary(current.input_slot) + + def update_selection(self, *_): + self.commit.deferred() + + def set_selection(self): + if self.selected_rows and self.selected_cols: + view = self.tabs.currentWidget() + model = view.model() + if model.rowCount() <= self.selected_rows[-1] or \ + model.columnCount() <= self.selected_cols[-1]: + return + + selection = QItemSelection() + rowranges = list(ranges(self.selected_rows)) + colranges = list(ranges(self.selected_cols)) + + for rowstart, rowend in rowranges: + for colstart, colend in colranges: + selection.append( + QItemSelectionRange( + view.model().index(rowstart, colstart), + view.model().index(rowend - 1, colend - 1) + ) + ) + view.selectionModel().select( + selection, QItemSelectionModel.ClearAndSelect) + + @staticmethod + def get_selection(view): + """ + Return the selected row and column indices of the selection in view. + """ + selmodel = view.selectionModel() + + selection = selmodel.selection() + model = view.model() + # map through the proxies into input table. + while isinstance(model, QAbstractProxyModel): + selection = model.mapSelectionToSource(selection) + model = model.sourceModel() + + assert isinstance(selmodel, BlockSelectionModel) + assert isinstance(model, TableModel) + + row_spans, col_spans = selection_blocks(selection) + rows = list(itertools.chain.from_iterable(itertools.starmap(range, row_spans))) + cols = list(itertools.chain.from_iterable(itertools.starmap(range, col_spans))) + rows = numpy.array(rows, dtype=numpy.intp) + # map the rows through the applied sorting (if any) + rows = model.mapToSourceRows(rows) + rows = rows.tolist() + return rows, cols + + @staticmethod + def _get_model(view): + model = view.model() + while isinstance(model, QAbstractProxyModel): + model = model.sourceModel() + return model + + @gui.deferred + def commit(self): + """ + Commit/send the current selected row/column selection. + """ + selected_data = table = rowsel = None + view = self.tabs.currentWidget() + if view and view.model() is not None: + model = self._get_model(view) + table = model.source # The input data table + + # Selections of individual instances are not implemented + # for SqlTables + if isinstance(table, SqlTable): + self.Outputs.selected_data.send(selected_data) + self.Outputs.annotated_data.send(None) + return + + rowsel, colsel = self.get_selection(view) + self.selected_rows, self.selected_cols = rowsel, colsel + + domain = table.domain + + if len(colsel) < len(domain.variables) + len(domain.metas): + # only a subset of the columns is selected + allvars = domain.class_vars + domain.metas + domain.attributes + columns = [(c, model.headerData(c, Qt.Horizontal, + TableModel.DomainRole)) + for c in colsel] + assert all(role is not None for _, role in columns) + + def select_vars(role): + """select variables for role (TableModel.DomainRole)""" + return [allvars[c] for c, r in columns if r == role] + + attrs = select_vars(TableModel.Attribute) + if attrs and issparse(table.X): + # for sparse data you can only select all attributes + attrs = table.domain.attributes + class_vars = select_vars(TableModel.ClassVar) + metas = select_vars(TableModel.Meta) + domain = Orange.data.Domain(attrs, class_vars, metas) + + # Send all data by default + if not rowsel: + selected_data = table + else: + selected_data = table.from_table(domain, table, rowsel) + + self.Outputs.selected_data.send(selected_data) + self.Outputs.annotated_data.send(create_annotated_table(table, rowsel)) + + def copy(self): + """ + Copy current table selection to the clipboard. + """ + view = self.tabs.currentWidget() + if view is not None: + mime = table_selection_to_mime_data(view) + QApplication.clipboard().setMimeData( + mime, QClipboard.Clipboard + ) + + def send_report(self): + view = self.tabs.currentWidget() + if not view or not view.model(): + return + model = self._get_model(view) + self.report_data_brief(model.source) + self.report_table(view) + + +if __name__ == "__main__": # pragma: no cover + WidgetPreview(OWDataTable).run( + insert_dataset=[ + (0, Table("iris")), + (1, Table("brown-selected")), + (2, Table("housing")) + ]) diff --git a/Orange/widgets/obsolete/tests/__init__.py b/Orange/widgets/obsolete/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/obsolete/tests/test_owtable.py b/Orange/widgets/obsolete/tests/test_owtable.py new file mode 100644 index 00000000000..7df20424f2d --- /dev/null +++ b/Orange/widgets/obsolete/tests/test_owtable.py @@ -0,0 +1,290 @@ +# pylint: skip-file +import unittest +from unittest.mock import Mock, patch + +from AnyQt.QtCore import Qt + +from orangewidget.tests.utils import excepthook_catch +from orangewidget.widget import StateInfo + +from Orange.widgets.obsolete.owtable import OWDataTable +from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin +from Orange.data import Table, Domain +from Orange.widgets.utils.state_summary import format_summary_details +from Orange.data.sql.table import SqlTable +from Orange.tests.sql.base import DataBaseTest as dbt + + +class TestOWDataTable(WidgetTest, WidgetOutputsTestMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + WidgetOutputsTestMixin.init(cls, + output_all_on_no_selection=True) + + cls.signal_name = "Data" + cls.signal_data = cls.data # pylint: disable=no-member + + def setUp(self): + self.widget = self.create_widget(OWDataTable) + + def test_input_data(self): + """Check number of tabs with data on the input""" + self.send_signal(self.widget.Inputs.data, self.data, 1) + self.assertEqual(self.widget.tabs.count(), 1) + self.send_signal(self.widget.Inputs.data, self.data, 2) + self.assertEqual(self.widget.tabs.count(), 2) + self.send_signal(self.widget.Inputs.data, None, 1) + self.assertEqual(self.widget.tabs.count(), 1) + + def test_input_data_empty(self): + self.send_signal(self.widget.Inputs.data, self.data[:0]) + output = self.get_output(self.widget.Outputs.annotated_data) + self.assertIsInstance(output, Table) + self.assertEqual(len(output), 0) + + def test_data_model(self): + self.send_signal(self.widget.Inputs.data, self.data, 1) + self.assertEqual(self.widget.tabs.widget(0).model().rowCount(), + len(self.data)) + + def test_reset_select(self): + self.send_signal(self.widget.Inputs.data, self.data) + self._select_data() + self.send_signal(self.widget.Inputs.data, Table('heart_disease')) + self.assertListEqual([], self.widget.selected_cols) + self.assertListEqual([], self.widget.selected_rows) + + def _select_data(self): + self.widget.selected_cols = list(range(len(self.data.domain.variables))) + self.widget.selected_rows = list(range(0, len(self.data), 10)) + self.widget.set_selection() + return self.widget.selected_rows + + def test_attrs_appear_in_corner_text(self): + domain = self.data.domain + new_domain = Domain( + domain.attributes[1:], domain.class_var, domain.attributes[:1]) + new_domain.metas[0].attributes = {"c": "foo"} + new_domain.attributes[0].attributes = {"a": "bar", "c": "baz"} + new_domain.class_var.attributes = {"b": "foo"} + self.send_signal(self.widget.Inputs.data, self.data.transform(new_domain)) + self.assertEqual( + self.widget.tabs.currentWidget().cornerText(), "\na\nb\nc" + ) + + def test_unconditional_commit_on_new_signal(self): + with patch.object(self.widget.commit, 'now') as commit: + self.widget.auto_commit = False + commit.reset_mock() + self.send_signal(self.widget.Inputs.data, self.data) + commit.assert_called() + + def test_pending_selection(self): + widget = self.create_widget(OWDataTable, stored_settings=dict( + selected_rows=[5, 6, 7, 8, 9], + selected_cols=list(range(len(self.data.domain.variables))))) + self.send_signal(widget.Inputs.data, None, 1) + self.send_signal(widget.Inputs.data, self.data, 1) + output = self.get_output(widget.Outputs.selected_data) + self.assertEqual(5, len(output)) + + def test_sorting(self): + self.send_signal(self.widget.Inputs.data, self.data) + self.widget.selected_rows = [0, 1, 2, 3, 4] + self.widget.selected_cols = list(range(len(self.data.domain.variables))) + self.widget.set_selection() + + output = self.get_output(self.widget.Outputs.selected_data) + output = output.get_column(0) + output_original = output.tolist() + + self.widget.tabs.currentWidget().sortByColumn(1, Qt.AscendingOrder) + + output = self.get_output(self.widget.Outputs.selected_data) + output = output.get_column(0) + output_sorted = output.tolist() + + # the two outputs should not be the same. + self.assertTrue(output_original != output_sorted) + + # check if output after sorting is actually sorted. + self.assertTrue(sorted(output_original) == output_sorted) + self.assertTrue(sorted(output_sorted) == output_sorted) + + def test_summary(self): + """Check if status bar is updated when data is received""" + info = self.widget.info + no_input, no_output = "No data on input", "No data on output" + + self.assertIsInstance(info._StateInfo__input_summary, StateInfo.Empty) + self.assertEqual(info._StateInfo__input_summary.details, no_input) + self.assertIsInstance(info._StateInfo__output_summary, StateInfo.Empty) + self.assertEqual(info._StateInfo__output_summary.details, no_output) + + data = Table("zoo") + self.send_signal(self.widget.Inputs.data, data, 1) + summary, details = f"{len(data)}", format_summary_details(data) + self.assertEqual(info._StateInfo__input_summary.brief, summary) + self.assertEqual(info._StateInfo__input_summary.details, details) + + data = self.data + self.send_signal(self.widget.Inputs.data, data, 2) + summary, details = f"{len(data)}", format_summary_details(data) + self.assertEqual(info._StateInfo__input_summary.brief, summary) + self.assertEqual(info._StateInfo__input_summary.details, details) + + self.send_signal(self.widget.Inputs.data, None, 1) + summary, details = f"{len(data)}", format_summary_details(data) + self.assertEqual(info._StateInfo__input_summary.brief, summary) + self.assertEqual(info._StateInfo__input_summary.details, details) + + self.send_signal(self.widget.Inputs.data, None, 2) + self.assertIsInstance(info._StateInfo__input_summary, StateInfo.Empty) + self.assertEqual(info._StateInfo__input_summary.details, no_input) + + def test_info(self): + info_text = self.widget.info_text + no_input = "No data." + self.assertEqual(info_text.text(), no_input) + + def test_show_distributions(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data, 0) + # run through the delegate paint routines + with excepthook_catch(): + w.grab() + w.controls.show_distributions.toggle() + with excepthook_catch(): + w.grab() + w.controls.color_by_class.toggle() + with excepthook_catch(): + w.grab() + w.controls.show_distributions.toggle() + + def test_whole_rows(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data, 0) + self.assertTrue(w.select_rows) # default value + with excepthook_catch(): + w.controls.select_rows.toggle() + self.assertFalse(w.select_rows) + w.selected_cols = [0, 1] + w.selected_rows = [0, 1, 2, 3] + w.set_selection() + out = self.get_output(w.Outputs.selected_data) + self.assertEqual(out.domain, + Domain([self.data.domain.attributes[0]], self.data.domain.class_var)) + with excepthook_catch(): + w.controls.select_rows.toggle() + out = self.get_output(w.Outputs.selected_data) + self.assertTrue(w.select_rows) + self.assertEqual(out.domain, + self.data.domain) + + def test_show_attribute_labels(self): + w = self.widget + self.send_signal(w.Inputs.data, self.data, 0) + self.assertTrue(w.show_attribute_labels) # default value + with excepthook_catch(): + w.controls.show_attribute_labels.toggle() + self.assertFalse(w.show_attribute_labels) + + def test_deprecate_multiple_inputs(self): + w = self.widget + self.assertFalse(w.Warning.multiple_inputs.is_shown()) + self.send_signal(w.Inputs.data, self.data, 0) + self.assertFalse(w.Warning.multiple_inputs.is_shown()) + self.send_signal(w.Inputs.data, self.data, 0) + self.assertFalse(w.Warning.multiple_inputs.is_shown()) + self.send_signal(w.Inputs.data, self.data, 1) + self.assertTrue(w.Warning.multiple_inputs.is_shown()) + self.send_signal(w.Inputs.data, self.data, 2) + self.assertTrue(w.Warning.multiple_inputs.is_shown()) + self.send_signal(w.Inputs.data, None, 1) + self.assertTrue(w.Warning.multiple_inputs.is_shown()) + self.send_signal(w.Inputs.data, None, 0) + self.assertFalse(w.Warning.multiple_inputs.is_shown()) + self.send_signal(w.Inputs.data, None, 2) + self.assertFalse(w.Warning.multiple_inputs.is_shown()) + + +class TestOWDataTableSQL(TestOWDataTable, dbt): + def setUp(self): + super().setUp() + self._set_input_summary = self.widget._set_input_summary + self.widget._set_input_summary = Mock() + + def setUpDB(self): + # pylint: disable=attribute-defined-outside-init + conn, iris = self.create_iris_sql_table() + data = SqlTable(conn, iris, inspect_values=True) + if self.current_db == "mssql": + # when loading data from mssql db, Sql widget returns Table (not SqlTable) + data = Table(data) + self.data = data.transform(Domain(data.domain.attributes[:-1], + data.domain.attributes[-1])) + + def tearDownDB(self): + self.drop_iris_sql_table() + + @dbt.run_on(["postgres", "mssql"]) + def test_input_data(self): + super().test_input_data() + + @unittest.skip("no data output") + def test_input_data_empty(self): + super().test_input_data_empty() + + def test_data_model(self): + super().test_data_model() + + @dbt.run_on(["postgres", "mssql"]) + def test_unconditional_commit_on_new_signal(self): + super().test_unconditional_commit_on_new_signal() + + @dbt.run_on(["postgres", "mssql"]) + def test_reset_select(self): + super().test_reset_select() + + @dbt.run_on(["postgres", "mssql"]) + def test_attrs_appear_in_corner_text(self): + super().test_attrs_appear_in_corner_text() + + @unittest.skip("no data output") + def test_pending_selection(self): + super().test_pending_selection() + + @unittest.skip("sorting not implemented") + def test_sorting(self): + super().test_sorting() + + @dbt.run_on(["postgres", "mssql"]) + def test_summary(self): + self.widget._set_input_summary = self._set_input_summary + super().test_summary() + self.widget._set_input_summary = Mock() + + @unittest.skip("does nothing") + def test_info(self): + super().test_info() + + @dbt.run_on(["postgres", "mssql"]) + def test_show_distributions(self): + super().test_show_distributions() + + @unittest.skip("no data output") + def test_whole_rows(self): + super().test_whole_rows() + + @dbt.run_on(["postgres", "mssql"]) + def test_show_attribute_labels(self): + super().test_show_distributions() + + @dbt.run_on(["postgres", "mssql"]) + def test_deprecate_multiple_inputs(self): + super().test_deprecate_multiple_inputs() + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/report/report.py b/Orange/widgets/report/report.py index 75ee276a95b..4338e2dd959 100644 --- a/Orange/widgets/report/report.py +++ b/Orange/widgets/report/report.py @@ -3,8 +3,6 @@ from orangewidget.report.report import * from orangewidget.report import report as __report -from Orange.data.sql.table import SqlTable - __all__ = __report.__all__ + [ "DataReport", "describe_data", "describe_data_brief", "describe_domain", "describe_domain_brief", @@ -86,16 +84,19 @@ def describe_domain(domain): :rtype: OrderedDict """ - def clip_attrs(items, s): - return clipped_list([a.name for a in items], 1000, - total_min=10, total=" (total: {{}} {})".format(s)) + def clip_attrs(items, desc): + s = clipped_list([a.name for a in items], 1000) + nitems = len(items) + if nitems >= 10: + s += f" (total: {nitems} {desc})" + return s return OrderedDict( [("Features", clip_attrs(domain.attributes, "features")), ("Meta attributes", bool(domain.metas) and clip_attrs(domain.metas, "meta attributes")), ("Target", bool(domain.class_vars) and - clip_attrs(domain.class_vars, "targets variables"))]) + clip_attrs(domain.class_vars, "target variables"))]) def describe_data(data): @@ -114,10 +115,7 @@ def describe_data(data): items = OrderedDict() if data is None: return items - if isinstance(data, SqlTable): - items["Data instances"] = data.approx_len() - else: - items["Data instances"] = len(data) + items["Data instances"] = len(data) items.update(describe_domain(data.domain)) return items @@ -167,9 +165,6 @@ def describe_data_brief(data): items = OrderedDict() if data is None: return items - if isinstance(data, SqlTable): - items["Data instances"] = data.approx_len() - else: - items["Data instances"] = len(data) + items["Data instances"] = len(data) items.update(describe_domain_brief(data.domain)) return items diff --git a/Orange/widgets/report/tests/test_report.py b/Orange/widgets/report/tests/test_report.py index 2b638190adc..d6b041d4102 100644 --- a/Orange/widgets/report/tests/test_report.py +++ b/Orange/widgets/report/tests/test_report.py @@ -107,12 +107,9 @@ def test_report_widgets_evaluate(self): results.learner_names = ["LR l2"] w = self.create_widget(OWTestAndScore) - set_learner = getattr(w, w.Inputs.learner.handler) - set_train = getattr(w, w.Inputs.train_data.handler) - set_test = getattr(w, w.Inputs.test_data.handler) - set_learner(LogisticRegressionLearner(), 0) - set_train(data) - set_test(data) + w.insert_learner(0, LogisticRegressionLearner()) + w.set_train_data(data) + w.set_test_data(data) w.create_report_html() rep.make_report(w) diff --git a/Orange/widgets/tests/__init__.py b/Orange/widgets/tests/__init__.py index c1f457e795d..9de76632823 100644 --- a/Orange/widgets/tests/__init__.py +++ b/Orange/widgets/tests/__init__.py @@ -1,7 +1,12 @@ import os import unittest + +import Orange import Orange.widgets +if Orange.data.Table.LOCKING is None: + Orange.data.Table.LOCKING = True + def load_tests(loader, tests, pattern): # Need to guard against inf. recursion. This package will be found again diff --git a/Orange/widgets/tests/base.py b/Orange/widgets/tests/base.py index c8472bcca41..69c3f877226 100644 --- a/Orange/widgets/tests/base.py +++ b/Orange/widgets/tests/base.py @@ -1,19 +1,22 @@ # pylint: disable=protected-access,unused-import from contextlib import contextmanager import os +import re +import inspect import pickle from unittest.mock import Mock, patch import numpy as np import scipy.sparse as sp +from AnyQt.QtCore import Qt, QRectF, QPointF from AnyQt.QtGui import QFont, QTextDocumentFragment -from AnyQt.QtCore import QRectF, QPointF from AnyQt.QtTest import QSignalSpy from AnyQt.QtWidgets import ( QComboBox, QSpinBox, QDoubleSpinBox, QSlider ) +from orangewidget.utils.signals import MultiInput from orangewidget.widget import StateInfo from orangewidget.tests.base import ( GuiTest, WidgetTest as WidgetTestBase, DummySignalManager, DEFAULT_TIMEOUT @@ -23,7 +26,7 @@ LearnerClassification, ModelClassification ) from Orange.data import ( - Table, Domain, DiscreteVariable, ContinuousVariable, Variable + Table, Domain, DiscreteVariable, ContinuousVariable, StringVariable ) from Orange.modelling import Fitter from Orange.preprocess import RemoveNaNColumns, Randomize, Continuize @@ -41,6 +44,58 @@ class WidgetTest(WidgetTestBase): + __e3 = np.empty((3, 0), dtype=np.float64) + __y3 = np.ones(3, dtype=np.float64) + __dataa = [ + ("data with just nans", Table( + Domain([ContinuousVariable(x) for x in "abc"], + ContinuousVariable("y"), + [StringVariable("m")]), + np.full((3, 3), np.nan), + np.full(3, np.nan), + np.full((3, 1), "", dtype=object))), + ("data without rows", Table( + Domain([ContinuousVariable(x) for x in "abc"]), + __e3.T)), + ("data with just attributes", Table( + Domain([ContinuousVariable(x) for x in "abc"]), + np.ones((3, 3), dtype=np.float64))), + ("no data (after having attributes)", None), + ("data with just class", Table( + Domain([], DiscreteVariable("y", values=tuple("abc"))), + __e3, __y3) + ), + ("data with just continouos outcome", Table( + Domain([], ContinuousVariable("y")), + __e3, __y3) + ), + ("no data (after having class)", None), + ("data with just metas", Table( + Domain([], None, [StringVariable(x) for x in "abc"]), + __e3, __e3, np.full((3, 3), "x", dtype=object))), + ("with without attributes, class or metas", Table( + Domain([], None, []), __e3, __e3, __e3) + ), + ("no data (after seeing a ghost)", None), + ] + + def __init_subclass__(cls, **kwargs): + super(cls).__init_subclass__(**kwargs) + + if not hasattr(cls, "test_zero_size_data"): + def test_zero_size_data(self): + widget = getattr(self, "widget", None) + if widget is None: + self.skipTest("not tested because .widget is not set") + for input in widget.get_signals("inputs"): + input_id = (1, ) if isinstance(input, MultiInput) else () + if input.type is Table: + for msg, data in cls.__dataa: + with self.subTest(msg): + self.send_signal(input, data, *input_id) + + cls.test_zero_size_data = test_zero_size_data + def assert_table_equal(self, table1, table2): if table1 is None or table2 is None: self.assertIs(table1, table2) @@ -65,6 +120,59 @@ def assert_domain_equal(self, domain1, domain2): if var1.is_discrete: self.assertEqual(var1.values, var2.values) + def test_has_keywords(self): + # If there is no widget, this is probably not a (final) test class? + if not hasattr(self, "widget"): + return + widget_class = type(self.widget).__bases__[0] + + # Only check widget classes that have a name and are final + if (not getattr(widget_class, "_final_class", False) + or not getattr(widget_class, "name", None)): + return + + # Only check the opt-in add-ons + if not any(map(widget_class.__module__.startswith, + self.has_keywords_optin_list)): + return + + # With this line, IDE's will show a link to the class + file = inspect.getsourcefile(widget_class) + file_line = \ + f'\nFile "{file}", line {inspect.getsourcelines(widget_class)[1]}.' + + if "keywords" not in widget_class.__dict__: + self.fail( + f"Widget {widget_class.__name__} must define a 'keywords'" + f"class attribute.\n" + "If none are needed, set keywords='_keywords'." + file_line) + + # Metaclass splits a string with keywords into a list. + # We check the sources to ensure this was originally a string. + source = None + try: + source = inspect.getsource(widget_class) + except OSError: + try: + with open(file, encoding="utf-8") as f: + source = f.read() + except IOError: + pass + if source is not None \ + and re.search(r"^\s+keywords\s*=\s*\[", source, re.MULTILINE): + self.fail( + "'keywords' class attribute must be a comma-separated string, " + "not a list." + file_line) + + has_keywords_optin_list = [] + + @classmethod + def has_keywords_optin(cls, prefix): + cls.has_keywords_optin_list.append(prefix) + + +WidgetTest.has_keywords_optin("Orange.") + class TestWidgetTest(WidgetTest): """Meta tests for widget test helpers""" @@ -76,6 +184,9 @@ def test_process_events_handles_timeouts(self): def test_minimum_size(self): return # skip this test + def test_has_keywords(self): + pass # skip this test + class BaseParameterMapping: """Base class for mapping between gui components and learner's parameters @@ -199,8 +310,8 @@ def _default_set_value(gui_element, values): elif isinstance(gui_element, QComboBox): def fun(val): value = values.index(val) - gui_element.activated.emit(value) gui_element.setCurrentIndex(value) + gui_element.activated.emit(value) return fun else: @@ -248,23 +359,26 @@ def init(self): self.parameters = [] + def click_apply(self): + self.widget.apply_button.button.clicked.emit() + def test_has_unconditional_apply(self): self.assertTrue(hasattr(self.widget, "unconditional_apply")) def test_input_data(self): """Check widget's data with data on the input""" self.assertEqual(self.widget.data, None) - self.send_signal("Data", self.data) + self.send_signal(self.widget.Inputs.data, self.data) self.assertEqual(self.widget.data, self.data) self.wait_until_stop_blocking() def test_input_data_disconnect(self): """Check widget's data and model after disconnecting data from input""" - self.send_signal("Data", self.data) + self.send_signal(self.widget.Inputs.data, self.data) self.assertEqual(self.widget.data, self.data) - self.widget.apply_button.button.click() + self.click_apply() self.wait_until_stop_blocking() - self.send_signal("Data", None) + self.send_signal(self.widget.Inputs.data, None) self.wait_until_stop_blocking() self.assertEqual(self.widget.data, None) self.assertIsNone(self.get_output(self.widget.Outputs.model)) @@ -272,23 +386,23 @@ def test_input_data_disconnect(self): def test_input_data_learner_adequacy(self): """Check if error message is shown with inadequate data on input""" for inadequate in self.inadequate_dataset: - self.send_signal("Data", inadequate) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, inadequate) + self.click_apply() self.wait_until_stop_blocking() self.assertTrue(self.widget.Error.data_error.is_shown()) for valid in self.valid_datasets: - self.send_signal("Data", valid) + self.send_signal(self.widget.Inputs.data, valid) self.wait_until_stop_blocking() self.assertFalse(self.widget.Error.data_error.is_shown()) def test_input_preprocessor(self): """Check learner's preprocessors with an extra pp on input""" randomize = Randomize() - self.send_signal("Preprocessor", randomize) + self.send_signal(self.widget.Inputs.preprocessor, randomize) self.assertEqual( randomize, self.widget.preprocessors, 'Preprocessor not added to widget preprocessors') - self.widget.apply_button.button.click() + self.click_apply() self.wait_until_stop_blocking() self.assertEqual( (randomize,), self.widget.learner.preprocessors, @@ -297,8 +411,8 @@ def test_input_preprocessor(self): def test_input_preprocessors(self): """Check multiple preprocessors on input""" pp_list = PreprocessorList([Randomize(), RemoveNaNColumns()]) - self.send_signal("Preprocessor", pp_list) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.preprocessor, pp_list) + self.click_apply() self.wait_until_stop_blocking() self.assertEqual( (pp_list,), self.widget.learner.preprocessors, @@ -307,23 +421,23 @@ def test_input_preprocessors(self): def test_input_preprocessor_disconnect(self): """Check learner's preprocessors after disconnecting pp from input""" randomize = Randomize() - self.send_signal("Preprocessor", randomize) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.preprocessor, randomize) + self.click_apply() self.wait_until_stop_blocking() self.assertEqual(randomize, self.widget.preprocessors) - self.send_signal("Preprocessor", None) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.preprocessor, None) + self.click_apply() self.wait_until_stop_blocking() self.assertIsNone(self.widget.preprocessors, 'Preprocessors not removed on disconnect.') def test_output_learner(self): """Check if learner is on output after apply""" - initial = self.get_output("Learner") + initial = self.get_output(self.widget.Outputs.learner) self.assertIsNotNone(initial, "Does not initialize the learner output") - self.widget.apply_button.button.click() - newlearner = self.get_output("Learner") + self.click_apply() + newlearner = self.get_output(self.widget.Outputs.learner) self.assertIsNot(initial, newlearner, "Does not send a new learner instance on `Apply`.") self.assertIsNotNone(newlearner) @@ -332,10 +446,10 @@ def test_output_learner(self): def test_output_model(self): """Check if model is on output after sending data and apply""" self.assertIsNone(self.get_output(self.widget.Outputs.model)) - self.widget.apply_button.button.click() + self.click_apply() self.assertIsNone(self.get_output(self.widget.Outputs.model)) - self.send_signal('Data', self.data) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, self.data) + self.click_apply() self.wait_until_stop_blocking() model = self.get_output(self.widget.Outputs.model) self.assertIsNotNone(model) @@ -345,28 +459,33 @@ def test_output_model(self): def test_output_learner_name(self): """Check if learner's name properly changes""" new_name = "Learner Name" - self.widget.apply_button.button.click() + self.click_apply() self.assertEqual(self.widget.learner.name, - self.widget.name_line_edit.text() - or self.widget.name_line_edit.placeholderText()) + self.widget.effective_learner_name()) + self.assertEqual(self.widget.effective_learner_name(), + self.widget.name_line_edit.placeholderText()) self.widget.name_line_edit.setText(new_name) - self.widget.apply_button.button.click() + self.click_apply() self.wait_until_stop_blocking() - self.assertEqual(self.get_output("Learner").name, new_name) + self.assertEqual(self.get_output(self.widget.Outputs.learner).name, + new_name) def test_output_model_name(self): """Check if model's name properly changes""" new_name = "Model Name" + self.send_signal(self.widget.Inputs.data, self.data) + self.click_apply() + self.assertEqual(self.get_output(self.widget.Outputs.model).name, + self.widget.effective_learner_name()) self.widget.name_line_edit.setText(new_name) - self.send_signal("Data", self.data) - self.widget.apply_button.button.click() - self.wait_until_stop_blocking() - self.assertEqual(self.get_output(self.widget.Outputs.model).name, new_name) + self.click_apply() + self.assertEqual(self.get_output(self.widget.Outputs.model).name, + new_name) def test_output_model_picklable(self): """Check if model can be pickled""" - self.send_signal("Data", self.data) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, self.data) + self.click_apply() self.wait_until_stop_blocking() model = self.get_output(self.widget.Outputs.model) self.assertIsNotNone(model) @@ -389,8 +508,8 @@ def test_parameters_default(self): """Check if learner's parameters are set to default (widget's) values """ for dataset in self.valid_datasets: - self.send_signal("Data", dataset) - self.widget.apply_button.button.click() + self.send_signal(self.widget.Inputs.data, dataset) + self.click_apply() self.wait_until_stop_blocking() for parameter in self.parameters: # Skip if the param isn't used for the given data type @@ -404,7 +523,7 @@ def test_parameters(self): # Test params on every valid dataset, since some attributes may apply # to only certain problem types for dataset in self.valid_datasets: - self.send_signal("Data", dataset) + self.send_signal(self.widget.Inputs.data, dataset) self.wait_until_stop_blocking() for parameter in self.parameters: @@ -416,7 +535,7 @@ def test_parameters(self): for value in parameter.values: parameter.set_value(value) - self.widget.apply_button.button.click() + self.click_apply() self.wait_until_stop_blocking() param = self._get_param_value(self.widget.learner, parameter) self.assertEqual( @@ -425,7 +544,9 @@ def test_parameters(self): self.assertEqual( param, value, "Mismatching setting for parameter '%s'" % parameter) - param = self._get_param_value(self.get_output("Learner"), parameter) + param = self._get_param_value( + self.get_output(self.widget.Outputs.learner), + parameter) self.assertEqual( param, value, "Mismatching setting for parameter '%s'" % parameter) @@ -441,7 +562,7 @@ def test_parameters(self): def test_params_trigger_settings_changed(self): """Check that the learner gets updated whenever a param is changed.""" for dataset in self.valid_datasets: - self.send_signal("Data", dataset) + self.send_signal(self.widget.Inputs.data, dataset) self.wait_until_stop_blocking() for parameter in self.parameters: @@ -508,12 +629,13 @@ def init(self, same_table_attributes=True, output_all_on_no_selection=False): self.output_all_on_no_selection = output_all_on_no_selection def test_outputs(self, timeout=DEFAULT_TIMEOUT): + self.widget.linkage = 1 self.send_signal(self.signal_name, self.signal_data) self.wait_until_finished(timeout=timeout) # check selected data output - output = self.get_output("Selected Data") + output = self.get_output(self.widget.Outputs.selected_data) if self.output_all_on_no_selection: self.assertEqual(output, self.signal_data) else: @@ -528,7 +650,7 @@ def test_outputs(self, timeout=DEFAULT_TIMEOUT): selected_indices = self._select_data() # check selected data output - selected = self.get_output("Selected Data") + selected = self.get_output(self.widget.Outputs.selected_data) n_sel, n_attr = len(selected), len(self.data.domain.attributes) self.assertGreater(n_sel, 0) self.assertEqual(selected.domain == self.data.domain, @@ -549,7 +671,7 @@ def test_outputs(self, timeout=DEFAULT_TIMEOUT): # check output when data is removed self.send_signal(self.signal_name, None) - self.assertIsNone(self.get_output("Selected Data")) + self.assertIsNone(self.get_output(self.widget.Outputs.selected_data)) self.assertIsNone(self.get_output(ANNOTATED_DATA_SIGNAL_NAME)) def _select_data(self): @@ -653,19 +775,19 @@ def test_plot_once(self, timeout=DEFAULT_TIMEOUT): """Test if data is plotted only once but committed on every input change""" table = Table("heart_disease") self.widget.setup_plot = Mock() - self.widget.commit = self.widget.unconditional_commit = Mock() + self.widget.commit.now = self.widget.commit.deferred = Mock() self.send_signal(self.widget.Inputs.data, table) self.widget.setup_plot.assert_called_once() - self.widget.commit.assert_called_once() + self.widget.commit.now.assert_called_once() self.wait_until_finished(timeout=timeout) self.widget.setup_plot.assert_called_once() - self.widget.commit.assert_called_once() + self.widget.commit.now.assert_called_once() - self.widget.commit.reset_mock() + self.widget.commit.now.reset_mock() self.send_signal(self.widget.Inputs.data_subset, table[::10]) self.widget.setup_plot.assert_called_once() - self.widget.commit.assert_called_once() + self.widget.commit.now.assert_called_once() def test_subset_data_color(self, timeout=DEFAULT_TIMEOUT): self.send_signal(self.widget.Inputs.data, self.data) @@ -691,7 +813,8 @@ def test_class_density(self, timeout=DEFAULT_TIMEOUT): def test_dragging_tooltip(self): """Dragging tooltip depends on data being jittered""" - text = QTextDocumentFragment.fromHtml(self.widget.graph.tiptexts[0]).toPlainText() + text = QTextDocumentFragment.fromHtml( + self.widget.graph.tiptexts[Qt.NoModifier]).toPlainText() self.send_signal(self.widget.Inputs.data, Table("heart_disease")) self.assertEqual(self.widget.graph.tip_textitem.toPlainText(), text) @@ -763,6 +886,7 @@ def test_hidden_effective_variables(self, timeout=DEFAULT_TIMEOUT): self.wait_until_finished(timeout=timeout) self.send_signal(self.widget.Inputs.data, table) + @WidgetTest.skipNonEnglish def test_visual_settings(self, timeout=DEFAULT_TIMEOUT): graph = self.widget.graph font = QFont() @@ -831,7 +955,8 @@ def assertFontEqual(self, font1, font2): class AnchorProjectionWidgetTestMixin(ProjectionWidgetTestMixin): def test_embedding_missing_values(self): table = Table("heart_disease") - table.X[0] = np.nan + with table.unlocked(): + table.X[0] = np.nan self.send_signal(self.widget.Inputs.data, table) self.assertFalse(np.all(self.widget.valid_data)) output = self.get_output(ANNOTATED_DATA_SIGNAL_NAME) @@ -842,7 +967,8 @@ def test_embedding_missing_values(self): def test_sparse_data(self, timeout=DEFAULT_TIMEOUT): table = Table("iris") - table.X = sp.csr_matrix(table.X) + with table.unlocked(): + table.X = sp.csr_matrix(table.X) self.assertTrue(sp.issparse(table.X)) self.send_signal(self.widget.Inputs.data, table) self.assertTrue(self.widget.Error.sparse_data.is_shown()) @@ -853,7 +979,8 @@ def test_sparse_data(self, timeout=DEFAULT_TIMEOUT): def test_manual_move(self): data = self.data.copy() - data[1, 0] = np.nan + with data.unlocked(): + data[1, 0] = np.nan nvalid, nsample = len(self.data) - 1, self.widget.SAMPLE_SIZE self.send_signal(self.widget.Inputs.data, data) self.widget.graph.select_by_indices(list(range(0, len(data), 10))) @@ -962,7 +1089,8 @@ def data_one_column_vals(cls, value=np.nan): ["", "", "", ""], "ynyn" ))) - table[:, 1] = value + with table.unlocked(): + table[:, 1] = value return table @classmethod diff --git a/Orange/widgets/tests/test_gui.py b/Orange/widgets/tests/test_gui.py index 9ae416af876..9dcf70d1518 100644 --- a/Orange/widgets/tests/test_gui.py +++ b/Orange/widgets/tests/test_gui.py @@ -1,9 +1,13 @@ +import unittest from unittest.mock import patch +import numpy as np + from AnyQt.QtCore import Qt from Orange.data import ContinuousVariable from Orange.widgets import gui +from Orange.widgets.gui import BarRatioTableModel from Orange.widgets.tests.base import GuiTest from Orange.widgets.utils.itemmodels import VariableListModel from Orange.widgets.widget import OWWidget @@ -31,6 +35,7 @@ def setUp(self): def tearDown(self) -> None: self.widget.deleteLater() del self.widget + super().tearDown() def test_select_callback(self): widget = self.widget @@ -52,6 +57,10 @@ def test_select_callback(self): view.setCurrentIndex(self.attrs.index(1, 0)) self.assertEqual(widget.foo, [b]) + # unselect all + sel_model.clear() + self.assertEqual(widget.foo, []) + def test_select_callfront(self): widget = self.widget view = self.view @@ -107,4 +116,26 @@ def test_set_initial_value(self): def test_warn_value_type(self, gui_combobox): with self.assertWarns(DeprecationWarning): gui.comboBox(None, None, "foo", valueType=int, editable=True) - self.assertEqual(gui_combobox.call_args[1], {"editable": True}) + + +class TestRankModel(GuiTest): + @staticmethod + def test_argsort(): + func = BarRatioTableModel()._argsortData # pylint: disable=protected-access + assert_equal = np.testing.assert_equal + + test_array = np.array([4.2, 7.2, np.nan, 1.3, np.nan]) + assert_equal(func(test_array, Qt.AscendingOrder)[:3], [3, 0, 1]) + assert_equal(func(test_array, Qt.DescendingOrder)[:3], [1, 0, 3]) + + test_array = np.array([4, 7, 2]) + assert_equal(func(test_array, Qt.AscendingOrder), [2, 0, 1]) + assert_equal(func(test_array, Qt.DescendingOrder), [1, 0, 2]) + + test_array = np.array(["Bertha", "daniela", "ann", "Cecilia"]) + assert_equal(func(test_array, Qt.AscendingOrder), [2, 0, 3, 1]) + assert_equal(func(test_array, Qt.DescendingOrder), [1, 3, 0, 2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/tests/test_matplotlib_export.py b/Orange/widgets/tests/test_matplotlib_export.py index 334f7d1ae35..3fb67a3a8ff 100644 --- a/Orange/widgets/tests/test_matplotlib_export.py +++ b/Orange/widgets/tests/test_matplotlib_export.py @@ -11,6 +11,7 @@ def add_intro(a): r = "import matplotlib.pyplot as plt\n" + \ + "import numpy as np\n" + \ "from numpy import array\n" + \ "plt.clf()" return r + a diff --git a/Orange/widgets/tests/test_workflows.py b/Orange/widgets/tests/test_workflows.py index bc0f9c5d319..630eee450b5 100644 --- a/Orange/widgets/tests/test_workflows.py +++ b/Orange/widgets/tests/test_workflows.py @@ -2,8 +2,12 @@ from os import listdir, environ from os.path import isfile, join, dirname import unittest +from unittest import mock + +from AnyQt.QtTest import QTest from orangecanvas.registry import WidgetRegistry +from orangecanvas.config import EntryPoint from orangewidget.workflow import widgetsscheme from Orange.canvas.config import Config @@ -45,6 +49,7 @@ def test_scheme_examples(self): new_scheme.widget_manager.set_creation_policy( new_scheme.widget_manager.Immediate ) + new_scheme.signal_manager.pause() with open(ows_file, "rb") as f: try: with excepthook_catch(raise_on_exit=True): @@ -54,3 +59,22 @@ def test_scheme_examples(self): format(ows_file, str(e))) finally: new_scheme.clear() + new_scheme.deleteLater() + del new_scheme + QTest.qWait(0) + + def test_examples_order(self): + ep_first = EntryPoint( + '!Testname', 'orangecontrib.any_addon.tutorials', '') + ep_last = EntryPoint( + 'exampletutorials', 'orangecontrib.other_addon.tutorials', '') + + def entry_points(*_, **__): + return ep_first, ep_last + + with mock.patch("Orange.canvas.config.entry_points", entry_points): + ep_names = [point.name for point in Config.examples_entry_points()] + self.assertLess(ep_names.index(ep_first.name), + ep_names.index("000-Orange3")) + self.assertLess(ep_names.index("000-Orange3"), + ep_names.index(ep_last.name)) diff --git a/Orange/widgets/tests/utils.py b/Orange/widgets/tests/utils.py index b7bf94ceb32..53bbb1c134d 100644 --- a/Orange/widgets/tests/utils.py +++ b/Orange/widgets/tests/utils.py @@ -1,296 +1,32 @@ -import sys from functools import wraps -import warnings -import contextlib - -from AnyQt.QtCore import Qt, QObject, QEventLoop, QTimer, QLocale, QPoint +from AnyQt.QtCore import Qt, QLocale, QPoint, QT_VERSION_INFO from AnyQt.QtTest import QTest -from AnyQt.QtGui import QMouseEvent, QContextMenuEvent -from AnyQt.QtWidgets import QApplication, QWidget - -from Orange.data import Table, Domain, ContinuousVariable - - -class EventSpy(QObject): - """ - A testing utility class (similar to QSignalSpy) to record events - delivered to a QObject instance. - - Note - ---- - Only event types can be recorded (as QEvent instances are deleted - on delivery). - - Note - ---- - Can only be used with a QCoreApplication running. - - Parameters - ---------- - object : QObject - An object whose events need to be recorded. - etype : Union[QEvent.Type, Sequence[QEvent.Type] - A event type (or types) that should be recorded - """ - def __init__(self, object, etype, **kwargs): - super().__init__(**kwargs) - if not isinstance(object, QObject): - raise TypeError - - self.__object = object - try: - len(etype) - except TypeError: - etypes = {etype} - else: - etypes = set(etype) - - self.__etypes = etypes - self.__record = [] - self.__loop = QEventLoop() - self.__timer = QTimer(self, singleShot=True) - self.__timer.timeout.connect(self.__loop.quit) - self.__object.installEventFilter(self) - - def wait(self, timeout=5000): - """ - Start an event loop that runs until a spied event or a timeout occurred. - - Parameters - ---------- - timeout : int - Timeout in milliseconds. - - Returns - ------- - res : bool - True if the event occurred and False otherwise. - - Example - ------- - >>> app = QCoreApplication.instance() or QCoreApplication([]) - >>> obj = QObject() - >>> spy = EventSpy(obj, QEvent.User) - >>> app.postEvent(obj, QEvent(QEvent.User)) - >>> spy.wait() - True - >>> print(spy.events()) - [1000] - """ - count = len(self.__record) - self.__timer.stop() - self.__timer.setInterval(timeout) - self.__timer.start() - self.__loop.exec() - self.__timer.stop() - return len(self.__record) != count - - def eventFilter(self, reciever, event): - if reciever is self.__object and event.type() in self.__etypes: - self.__record.append(event.type()) - if self.__loop.isRunning(): - self.__loop.quit() - return super().eventFilter(reciever, event) - - def events(self): - """ - Return a list of all (listened to) event types that occurred. - - Returns - ------- - events : List[QEvent.Type] - """ - return list(self.__record) - - -@contextlib.contextmanager -def excepthook_catch(raise_on_exit=True): - """ - Override `sys.excepthook` with a custom handler to record unhandled - exceptions. - - Use this to capture or note exceptions that are raised and - unhandled within PyQt slots or virtual function overrides. - - Note - ---- - The exceptions are still dispatched to the original `sys.excepthook` +from AnyQt.QtGui import QContextMenuEvent +from AnyQt.QtWidgets import QApplication, QWidget, QButtonGroup - Parameters - ---------- - raise_on_exit : bool - If True then the (first) exception that was captured will be - reraised on context exit +from orangecanvas.gui.test import dragDrop +from orangewidget.tests.utils import ( + simulate, excepthook_catch, EventSpy, mouseMove +) - Returns - ------- - ctx : ContextManager - A context manager - - Example - ------- - >>> class Obj(QObject): - ... signal = pyqtSignal() - ... - >>> o = Obj() - >>> o.signal.connect(lambda : 1/0) - >>> with excepthook_catch(raise_on_exit=False) as exc_list: - ... o.signal.emit() - ... - >>> print(exc_list) # doctest: +ELLIPSIS - [(, ZeroDivisionError('division by zero',), ... - """ - excepthook = sys.excepthook - seen = [] - - def excepthook_handle(exctype, value, traceback): - seen.append((exctype, value, traceback)) - excepthook(exctype, value, traceback) - - sys.excepthook = excepthook_handle - shouldraise = raise_on_exit - try: - yield seen - except BaseException: - # propagate/preserve exceptions from within the ctx - shouldraise = False - raise - finally: - if sys.excepthook == excepthook_handle: - sys.excepthook = excepthook - else: - raise RuntimeError( - "The sys.excepthook that was installed by " - "'excepthook_catch' context at enter is not " - "the one present at exit.") - if shouldraise and seen: - raise seen[0][1] - - -class simulate: - """ - Utility functions for simulating user interactions with Qt widgets. - """ - @staticmethod - def combobox_run_through_all(cbox, delay=-1, callback=None): - """ - Run through all items in a given combo box, simulating the user - focusing the combo box and pressing the Down arrow key activating - all the items on the way. - - Unhandled exceptions from invoked PyQt slots/virtual function overrides - are captured and reraised. - - Parameters - ---------- - cbox : QComboBox - delay : int - Run the event loop after the simulated key press (-1, the default, - means no delay) - callback : callable - A callback that will be executed after every item change. Takes no - parameters. - - See Also - -------- - QTest.keyClick - """ - assert cbox.focusPolicy() & Qt.TabFocus - cbox.setFocus(Qt.TabFocusReason) - cbox.setCurrentIndex(-1) - for i in range(cbox.count()): - with excepthook_catch() as exlist: - QTest.keyClick(cbox, Qt.Key_Down, delay=delay) - if callback: - callback() - if exlist: - raise exlist[0][1] from exlist[0][1] - - @staticmethod - def combobox_activate_index(cbox, index, delay=-1): - """ - Activate an item at `index` in a given combo box. - - The item at index **must** be enabled and selectable. - - Parameters - ---------- - cbox : QComboBox - index : int - delay : int - Run the event loop after the signals are emitted for `delay` - milliseconds (-1, the default, means no delay). - """ - assert 0 <= index < cbox.count() - model = cbox.model() - column = cbox.modelColumn() - root = cbox.rootModelIndex() - mindex = model.index(index, column, root) - assert mindex.flags() & Qt.ItemIsEnabled - cbox.setCurrentIndex(index) - text = cbox.currentText() - # QComboBox does not have an interface which would allow selecting - # the current item as if a user would. Only setCurrentIndex which - # does not emit the activated signals. - cbox.activated[int].emit(index) - cbox.activated[str].emit(text) - if delay >= 0: - QTest.qWait(delay) - - @staticmethod - def combobox_index_of(cbox, value, role=Qt.DisplayRole): - """ - Find the index of an **selectable** item in a combo box whose `role` - data contains the given `value`. - - Parameters - ---------- - cbox : QComboBox - value : Any - role : Qt.ItemDataRole - - Returns - ------- - index : int - An index such that `cbox.itemData(index, role) == value` **and** - the item is enabled for selection or -1 if such an index could - not be found. - """ - model = cbox.model() - column = cbox.modelColumn() - root = cbox.rootModelIndex() - for i in range(model.rowCount(root)): - index = model.index(i, column, root) - if index.data(role) == value and \ - index.flags() & Qt.ItemIsEnabled: - pos = i - break - else: - pos = -1 - return pos +from Orange.data import Table, Domain, ContinuousVariable - @staticmethod - def combobox_activate_item(cbox, value, role=Qt.DisplayRole, delay=-1): - """ - Find an **selectable** item in a combo box whose `role` data - contains the given value and activate it. +# pylint: disable=self-assigning-variable,invalid-name +EventSpy = EventSpy +excepthook_catch = excepthook_catch +simulate = simulate +mouseMove = mouseMove +dragDrop = dragDrop - Raise an ValueError if the item could not be found. - Parameters - ---------- - cbox : QComboBox - value : Any - role : Qt.ItemDataRole - delay : int - Run the event loop after the signals are emitted for `delay` - milliseconds (-1, the default, means no delay). - """ - index = simulate.combobox_index_of(cbox, value, role) - if index < 0: - raise ValueError("{!r} not in {}".format(value, cbox)) - simulate.combobox_activate_index(cbox, index, delay) +def qbuttongroup_emit_clicked(bg: QButtonGroup, id_: int): + button = bg.button(id_) + bg.buttonClicked.emit(button) + if QT_VERSION_INFO >= (5, 15): + bg.idClicked.emit(id_) + if QT_VERSION_INFO < (6, 0): + bg.buttonClicked[int].emit(id_) def override_locale(language): @@ -306,18 +42,6 @@ def wrap(*args, **kwargs): return wrapper -def mouseMove(widget, pos=QPoint(), delay=-1): # pragma: no-cover - # Like QTest.mouseMove, but functional without QCursor.setPos - if pos.isNull(): - pos = widget.rect().center() - me = QMouseEvent(QMouseEvent.MouseMove, pos, widget.mapToGlobal(pos), - Qt.NoButton, Qt.MouseButtons(0), Qt.NoModifier) - if delay > 0: - QTest.qWait(delay) - - QApplication.sendEvent(widget, me) - - def contextMenu( widget: QWidget, pos=QPoint(), reason=QContextMenuEvent.Mouse, modifiers=Qt.NoModifier, delay=-1 diff --git a/Orange/widgets/unsupervised/__init__.py b/Orange/widgets/unsupervised/__init__.py index 912138e530e..759c88bb372 100644 --- a/Orange/widgets/unsupervised/__init__.py +++ b/Orange/widgets/unsupervised/__init__.py @@ -7,10 +7,10 @@ """ -# Category description for the widget registry - NAME = "Unsupervised" +ID = "orange.widgets.unsupervised" + DESCRIPTION = "Unsupervised learning." BACKGROUND = "#CAE1EF" diff --git a/Orange/widgets/unsupervised/icons/CorrespondenceAnalysis.svg b/Orange/widgets/unsupervised/icons/CorrespondenceAnalysis-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/CorrespondenceAnalysis.svg rename to Orange/widgets/unsupervised/icons/CorrespondenceAnalysis-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/DBSCAN.svg b/Orange/widgets/unsupervised/icons/DBSCAN-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/DBSCAN.svg rename to Orange/widgets/unsupervised/icons/DBSCAN-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/Distance.svg b/Orange/widgets/unsupervised/icons/Distance-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/Distance.svg rename to Orange/widgets/unsupervised/icons/Distance-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/DistanceFile.svg b/Orange/widgets/unsupervised/icons/DistanceFile-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/DistanceFile.svg rename to Orange/widgets/unsupervised/icons/DistanceFile-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/DistanceMap.svg b/Orange/widgets/unsupervised/icons/DistanceMap-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/DistanceMap.svg rename to Orange/widgets/unsupervised/icons/DistanceMap-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/DistanceMatrix.svg b/Orange/widgets/unsupervised/icons/DistanceMatrix-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/DistanceMatrix.svg rename to Orange/widgets/unsupervised/icons/DistanceMatrix-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/DistancesTransformation.svg b/Orange/widgets/unsupervised/icons/DistancesTransformation-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/DistancesTransformation.svg rename to Orange/widgets/unsupervised/icons/DistancesTransformation-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/HierarchicalClustering.svg b/Orange/widgets/unsupervised/icons/HierarchicalClustering-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/HierarchicalClustering.svg rename to Orange/widgets/unsupervised/icons/HierarchicalClustering-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/KMeans.svg b/Orange/widgets/unsupervised/icons/KMeans-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/KMeans.svg rename to Orange/widgets/unsupervised/icons/KMeans-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/LouvainClustering.svg b/Orange/widgets/unsupervised/icons/LouvainClustering-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/LouvainClustering.svg rename to Orange/widgets/unsupervised/icons/LouvainClustering-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/MDS.svg b/Orange/widgets/unsupervised/icons/MDS-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/MDS.svg rename to Orange/widgets/unsupervised/icons/MDS-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/Manifold.svg b/Orange/widgets/unsupervised/icons/Manifold-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/Manifold.svg rename to Orange/widgets/unsupervised/icons/Manifold-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/PCA.svg b/Orange/widgets/unsupervised/icons/PCA-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/PCA.svg rename to Orange/widgets/unsupervised/icons/PCA-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/SOM.svg b/Orange/widgets/unsupervised/icons/SOM-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/SOM.svg rename to Orange/widgets/unsupervised/icons/SOM-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/SaveDistances.svg b/Orange/widgets/unsupervised/icons/SaveDistances-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/SaveDistances.svg rename to Orange/widgets/unsupervised/icons/SaveDistances-symbolic.svg diff --git a/Orange/widgets/unsupervised/icons/TSNE.svg b/Orange/widgets/unsupervised/icons/TSNE-symbolic.svg similarity index 100% rename from Orange/widgets/unsupervised/icons/TSNE.svg rename to Orange/widgets/unsupervised/icons/TSNE-symbolic.svg diff --git a/Orange/widgets/unsupervised/owcorrespondence.py b/Orange/widgets/unsupervised/owcorrespondence.py index d88ea37fc4d..e100671e125 100644 --- a/Orange/widgets/unsupervised/owcorrespondence.py +++ b/Orange/widgets/unsupervised/owcorrespondence.py @@ -4,20 +4,20 @@ import numpy as np from AnyQt.QtWidgets import QListView, QApplication, QSizePolicy -from AnyQt.QtGui import QBrush, QColor, QPainter +from AnyQt.QtGui import QBrush, QColor, QPainter, QPalette from AnyQt.QtCore import QEvent, Qt -from orangewidget.utils.listview import ListViewSearch import pyqtgraph as pg + +from orangewidget.utils.listview import ListViewSearch + from Orange.data import Table, Domain, ContinuousVariable, StringVariable from Orange.statistics import contingency - from Orange.widgets import widget, gui, settings from Orange.widgets.utils import itemmodels, colorpalettes from Orange.widgets.utils.itemmodels import select_rows from Orange.widgets.utils.widgetpreview import WidgetPreview - -from Orange.widgets.visualize.owscatterplotgraph import ScatterPlotItem +from Orange.widgets.visualize.utils.plotutils import PlotWidget from Orange.widgets.widget import Input, Output from Orange.widgets.settings import Setting @@ -32,14 +32,14 @@ def paint(self, painter, option, widget=None): class OWCorrespondenceAnalysis(widget.OWWidget): name = "Correspondence Analysis" description = "Correspondence analysis for categorical multivariate data." - icon = "icons/CorrespondenceAnalysis.svg" - keywords = [] + icon = "icons/CorrespondenceAnalysis-symbolic.svg" + keywords = "correspondence analysis" class Inputs: data = Input("Data", Table) class Outputs: - coordinates = Output("Coordinates", Table) + coordinates = Output("Coordinates", Table, dynamic=False) Invalidate = QEvent.registerEventType() @@ -48,7 +48,7 @@ class Outputs: selected_var_indices = settings.ContextSetting([]) auto_commit = Setting(True) - graph_name = "plot.plotItem" + graph_name = "plot.plotItem" # QGraphicsView (pg.PlotWidget) class Error(widget.OWWidget.Error): empty_data = widget.Msg("Empty dataset") @@ -93,7 +93,7 @@ def __init__(self): gui.auto_send(self.buttonsArea, self, "auto_commit") - self.plot = pg.PlotWidget(background="w") + self.plot = PlotWidget() self.plot.setMenuEnabled(False) self.mainArea.layout().addWidget(self.plot) @@ -130,7 +130,9 @@ def set_data(self, data): self.openContext(data) self._restore_selection() self._update_CA() + self.commit.now() + @gui.deferred def commit(self): output_table = None if self.ca is not None: @@ -194,6 +196,7 @@ def customEvent(self, event): self.ca = None self.plot.clear() self._update_CA() + self.commit.deferred() return return super().customEvent(event) @@ -208,7 +211,6 @@ def _update_CA(self): self._setup_plot() self._update_info() - self.commit() def update_XY(self): self.axis_x_cb.clear() @@ -275,6 +277,7 @@ def get_minmax(points): margin = margin * 0.05 if margin > 1e-10 else 1 self.plot.setYRange(minmax[2] - margin, minmax[3] + margin) + foreground = self.palette().color(QPalette.Text) for i, (v, points) in enumerate(zip(variables, points)): color_outline = colors[i] color_outline.setAlpha(200) @@ -288,7 +291,7 @@ def get_minmax(points): self.plot.addItem(item) for name, point in zip(v.values, points): - item = pg.TextItem(name, anchor=(0.5, 0)) + item = pg.TextItem(name, anchor=(0.5, 0), color=foreground) self.plot.addItem(item) item.setPos(point[0], point[1]) diff --git a/Orange/widgets/unsupervised/owdbscan.py b/Orange/widgets/unsupervised/owdbscan.py index 28582a60429..27b5b6134cb 100644 --- a/Orange/widgets/unsupervised/owdbscan.py +++ b/Orange/widgets/unsupervised/owdbscan.py @@ -2,6 +2,7 @@ from itertools import chain import numpy as np +import scipy from AnyQt.QtWidgets import QApplication from AnyQt.QtGui import QColor from sklearn.metrics import pairwise_distances @@ -56,8 +57,9 @@ def get_kth_distances(data, metric, k=5): class OWDBSCAN(widget.OWWidget): name = "DBSCAN" description = "Density-based spatial clustering." - icon = "icons/DBSCAN.svg" + icon = "icons/DBSCAN-symbolic.svg" priority = 2150 + keywords = "density based clustering, clustering" class Inputs: data = Input("Data", Table) @@ -67,7 +69,9 @@ class Outputs: class Error(widget.OWWidget.Error): not_enough_instances = Msg("Not enough unique data instances. " - "At least two are required.") + "At least two rows (with any defined values) are required.") + no_features = Msg("The data does not contain any features.") + METRICS = [ ("Euclidean", "euclidean"), @@ -122,11 +126,16 @@ def __init__(self): def check_data_size(self, data): if data is None: return False - if len(data) < 2: + # For sparse tables, we assume that there are no nans + # For dense, count rows that have at least one non-nan value + nrows = data.X.shape[0] if scipy.sparse.issparse(data.X) \ + else np.sum(np.any(np.isfinite(data.X), axis=1)) + if nrows < 2: self.Error.not_enough_instances() return False return True + @gui.deferred def commit(self): self.cluster() @@ -164,14 +173,17 @@ def _compute_cut_point(self): self.cut_point = int(DEFAULT_CUT_POINT * len(self.k_distances)) self.eps = self.k_distances[self.cut_point] - mask = self.k_distances >= EPS_BOTTOM_LIMIT - if self.eps < EPS_BOTTOM_LIMIT and sum(mask): - self.eps = np.min(self.k_distances[mask]) + if self.eps < EPS_BOTTOM_LIMIT: + mask = self.k_distances >= EPS_BOTTOM_LIMIT + self.eps = sum(mask) and np.min(self.k_distances[mask]) or EPS_BOTTOM_LIMIT self.cut_point = self._find_nearest_dist(self.eps) @Inputs.data def set_data(self, data): self.Error.clear() + if data is not None and data.X.shape[1] == 0: + data = None + self.Error.no_features() if not self.check_data_size(data): data = None self.data = self.data_normalized = data @@ -180,13 +192,10 @@ def set_data(self, data): self.plot.clear_plot() return - if self.data is None: - return - self._preprocess_data() self._compute_and_plot() - self.unconditional_commit() + self.commit.now() def _preprocess_data(self): self.data_normalized = self.data @@ -223,7 +232,7 @@ def send_data(self): self.Outputs.annotated_data.send(new_table) def _invalidate(self): - self.commit() + self.commit.deferred() def _find_nearest_dist(self, value): array = np.asarray(self.k_distances) @@ -248,7 +257,7 @@ def _on_cut_changed(self, value): self.cut_point = value self.eps = self.k_distances[value] - self.commit() + self.commit.deferred() def _min_samples_changed(self): if self.data is None: diff --git a/Orange/widgets/unsupervised/owdistancefile.py b/Orange/widgets/unsupervised/owdistancefile.py index 30079771451..48cfda078ef 100644 --- a/Orange/widgets/unsupervised/owdistancefile.py +++ b/Orange/widgets/unsupervised/owdistancefile.py @@ -1,35 +1,49 @@ import os +import numpy as np + from AnyQt.QtWidgets import QSizePolicy, QStyle, QMessageBox, QFileDialog -from AnyQt.QtCore import QTimer +from AnyQt.QtCore import QTimer, QUrl + +from orangewidget.settings import Setting +from orangewidget.widget import Msg +from orangewidget.workflow.drophandler import SingleFileDropHandler from Orange.misc import DistMatrix from Orange.widgets import widget, gui from Orange.data import get_sample_datasets_dir -from Orange.widgets.utils.filedialogs import RecentPathsWComboMixin +from Orange.widgets.utils.filedialogs import RecentPathsWComboMixin, RecentPath, \ + stored_recent_paths_prepend, OWUrlDropBase from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Output -class OWDistanceFile(widget.OWWidget, RecentPathsWComboMixin): +class OWDistanceFile(OWUrlDropBase, RecentPathsWComboMixin): name = "Distance File" id = "orange.widgets.unsupervised.distancefile" description = "Read distances from a file." - icon = "icons/DistanceFile.svg" + icon = "icons/DistanceFile-symbolic.svg" priority = 10 - category = "Data" - keywords = ["load", "read", "open"] + keywords = "distance file, load, read, open" class Outputs: distances = Output("Distances", DistMatrix, dynamic=False) + class Error(widget.OWWidget.Error): + invalid_file = Msg("Data was not loaded:{}") + non_square_matrix = Msg( + "Matrix is not square. " + "Reformat the file and use the File widget to read it.") + want_main_area = False resizing_enabled = False + auto_symmetric = Setting(True) + def __init__(self): super().__init__() RecentPathsWComboMixin.__init__(self) - self.loaded_file = "" + self.distances = None vbox = gui.vBox(self.controlArea, "Distance File") box = gui.hBox(vbox) @@ -47,13 +61,14 @@ def __init__(self): button.setIcon(self.style().standardIcon(QStyle.SP_BrowserReload)) button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - box = gui.vBox(self.controlArea, "Info") - self.infoa = gui.widgetLabel(box, 'No data loaded.') - self.warnings = gui.widgetLabel(box, ' ') - #Set word wrap, so long warnings won't expand the widget - self.warnings.setWordWrap(True) - self.warnings.setSizePolicy( - QSizePolicy.Ignored, QSizePolicy.MinimumExpanding) + vbox = gui.vBox(self.controlArea, "Options") + gui.checkBox( + vbox, self, "auto_symmetric", + "Treat triangular matrices as symmetric", + tooltip="If matrix is triangular, this will copy the data to the " + "other triangle", + callback=self.commit + ) gui.rubber(self.buttonsArea) gui.button( @@ -64,9 +79,6 @@ def __init__(self): self.set_file_list() QTimer.singleShot(0, self.open_file) - def set_file_list(self): - super().set_file_list() - def reload(self): return self.open_file() @@ -87,61 +99,76 @@ def browse_file(self, in_demos=False): start_file = self.last_path() or os.path.expanduser("~/") filename, _ = QFileDialog.getOpenFileName( - self, 'Open Distance File', start_file, "(*.dst)") + self, 'Open Distance File', start_file, + "All Readable Files (*.xlsx *.dst);;" + "Excel File (*.xlsx);;" + "Distance File (*.dst)") if not filename: return self.add_path(filename) self.open_file() - # Open a file, create data from it and send it over the data channel def open_file(self): - self.clear_messages() + self.Error.clear() + self.distances = None fn = self.last_path() - if not fn: - return - if not os.path.exists(fn): + if fn and not os.path.exists(fn): dir_name, basename = os.path.split(fn) if os.path.exists(os.path.join(".", basename)): fn = os.path.join(".", basename) - self.information("Loading '{}' from the current directory." - .format(basename)) - if fn == "(none)": - self.Outputs.distances.send(None) - self.infoa.setText("No data loaded") - self.infob.setText("") - self.warnings.setText("") - return - - self.loaded_file = "" - - try: - distances = DistMatrix.from_file(fn) - self.loaded_file = fn - except Exception as exc: - err_value = str(exc) - self.error("Invalid file format") - self.infoa.setText('Data was not loaded due to an error.') - self.warnings.setText(err_value) - distances = None - - if distances is not None: - self.infoa.setText( - "{} points(s), ".format(len(distances)) + - (["unlabelled", "labelled"][distances.row_items is not None])) - self.warnings.setText("") - file_name = os.path.split(fn)[1] - if "." in file_name: - distances.name = file_name[:file_name.rfind('.')] + if fn and fn != "(none)": + try: + distances = DistMatrix.from_file(fn) + except Exception as exc: + err = str(exc) + self.Error.invalid_file(" \n"[len(err) > 40] + err) else: - distances.name = file_name - + if distances.shape[0] != distances.shape[1]: + self.Error.non_square_matrix() + else: + np.nan_to_num(distances) + self.distances = distances + _, filename = os.path.split(fn) + self.distances.name, _ = os.path.splitext(filename) + self.commit() + + def commit(self): + distances = self.distances + if distances is not None: + if self.auto_symmetric: + distances = distances.auto_symmetricized() + if np.any(np.isnan(distances)): + distances = np.nan_to_num(distances) self.Outputs.distances.send(distances) def send_report(self): - if not self.loaded_file: + if not self.distances: self.report_paragraph("No data was loaded.") else: - self.report_items([("File name", self.loaded_file)]) + self.report_items([("File name", self.distances.name)]) + + def canDropUrl(self, url: QUrl) -> bool: + if url.isLocalFile(): + return OWDistanceFileDropHandler().canDropFile(url.toLocalFile()) + else: + return False + + def handleDroppedUrl(self, url: QUrl) -> None: + if url.isLocalFile(): + self.add_path(url.toLocalFile()) + self.open_file() + + +class OWDistanceFileDropHandler(SingleFileDropHandler): + WIDGET = OWDistanceFile + + def parametersFromFile(self, path): + r = RecentPath(os.path.abspath(path), None, None, + os.path.basename(path)) + return {"recent_paths": stored_recent_paths_prepend(self.WIDGET, r)} + + def canDropFile(self, path: str) -> bool: + return os.path.splitext(path)[1].lower() in (".dst", ".xlsx") if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/unsupervised/owdistancemap.py b/Orange/widgets/unsupervised/owdistancemap.py index e3b554436bd..0a3ee891c33 100644 --- a/Orange/widgets/unsupervised/owdistancemap.py +++ b/Orange/widgets/unsupervised/owdistancemap.py @@ -22,10 +22,15 @@ from Orange.widgets.utils import itemmodels, colorpalettes from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) -from Orange.widgets.utils.graphicstextlist import TextListWidget +from Orange.widgets.utils.graphicsscene import graphicsscene_help_event +from Orange.widgets.utils.graphicstextlist import ( + TextListWidget, effective_point_size_for_height +) from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.utils.plotutils import HelpEventDelegate from Orange.widgets.widget import Input, Output from Orange.widgets.utils.dendrogram import DendrogramWidget +from Orange.widgets.visualize.utils.plotutils import GraphicsView from Orange.widgets.visualize.utils.heatmap import ( GradientColorMap, GradientLegendWidget, ) @@ -245,12 +250,24 @@ def hoverMoveEvent(self, event): self.setToolTip("") +class GraphicsView(pg.GraphicsView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + scene = self.scene() + delegate = HelpEventDelegate(self.__helpEvent, parent=self) + scene.installEventFilter(delegate) + + def __helpEvent(self, event): + graphicsscene_help_event(self.scene(), event) + return event.isAccepted() + + class OWDistanceMap(widget.OWWidget): name = "Distance Map" description = "Visualize a distance matrix." - icon = "icons/DistanceMap.svg" + icon = "icons/DistanceMap-symbolic.svg" priority = 1200 - keywords = [] + keywords = "distance map" class Inputs: distances = Input("Distances", Orange.misc.DistMatrix) @@ -260,6 +277,10 @@ class Outputs: annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table) features = Output("Features", widget.AttributeList, dynamic=False) + class Error(widget.OWWidget.Error): + empty_matrix = widget.Msg("Empty distance matrix") + not_symmetric = widget.Msg("Distance matrix is not symmetric.") + settingsHandler = settings.PerfectDomainContextHandler() #: type of ordering to apply to matrix rows/columns @@ -277,7 +298,7 @@ class Outputs: autocommit = settings.Setting(True) - graph_name = "grid_widget" + graph_name = "grid_widget" # pg.GraphicsItem (pg.GraphicsWidget) # Disable clustering for inputs bigger than this _MaxClustering = 25000 @@ -330,7 +351,7 @@ def _set_thresholds(low, high): gui.auto_send(self.buttonsArea, self, "autocommit") - self.view = pg.GraphicsView(background="w") + self.view = GraphicsView(background=None) self.mainArea.layout().addWidget(self.view) self.grid_widget = pg.GraphicsWidget() @@ -401,11 +422,13 @@ def pack_settings(self): def set_distances(self, matrix): self.closeContext() self.clear() - self.error() + self.Error.clear() if matrix is not None: - N, _ = matrix.shape - if N < 2: - self.error("Empty distance matrix.") + if matrix.shape[1] < 2: + self.Error.empty_matrix() + matrix = None + elif not matrix.is_symmetric(): + self.Error.not_symmetric() matrix = None self.matrix = matrix @@ -481,7 +504,7 @@ def handleNewSignals(self): if self.pending_selection is not None: self.matrix_item.set_selections(self.pending_selection) self.pending_selection = None - self.unconditional_commit() + self.commit.now() def _clear_plot(self): def remove(item): @@ -585,7 +608,7 @@ def _update_labels(self, ): labels = [v.name for v in self.items] elif isinstance(self.items, Orange.data.Table): var = self.annot_combo.model()[self.annotation_idx] - column, _ = self.items.get_column_view(var) + column = self.items.get_column(var) labels = [var.str_val(value) for value in column] self._set_labels(labels) @@ -631,8 +654,9 @@ def _invalidate_selection(self): sortind = self._sort_indices indices = [sortind[i] for i in indices] self._selection = list(sorted(set(indices))) - self.commit() + self.commit.deferred() + @gui.deferred def commit(self): datasubset = None featuresubset = None @@ -697,17 +721,11 @@ def _updateFontSize(self): fontsize = min(self._point_size(lineheight), maxfontsize) font_ = QFont() - font_.setPointSize(fontsize) + font_.setPointSizeF(fontsize) self.setFont(font_) def _point_size(self, height): - font = self.font() - font.setPointSize(height) - fix = 0 - while QFontMetrics(font).lineSpacing() > height and height - fix > 1: - fix += 1 - font.setPointSize(height - fix) - return height - fix + return effective_point_size_for_height(self.font(), height) # run widget with `python -m Orange.widgets.unsupervised.owdistancemap` diff --git a/Orange/widgets/unsupervised/owdistancematrix.py b/Orange/widgets/unsupervised/owdistancematrix.py index b2ef89beca2..b59ecdce517 100644 --- a/Orange/widgets/unsupervised/owdistancematrix.py +++ b/Orange/widgets/unsupervised/owdistancematrix.py @@ -1,131 +1,23 @@ import itertools +import logging +from functools import partial -import numpy as np - -from AnyQt.QtWidgets import QTableView, QHeaderView -from AnyQt.QtGui import QColor, QPen, QBrush -from AnyQt.QtCore import Qt, QAbstractTableModel, QSize +from AnyQt.QtWidgets import QTableView +from AnyQt.QtCore import Qt, QSize, QItemSelection, QItemSelectionRange from Orange.data import Table, Variable, StringVariable from Orange.misc import DistMatrix +from Orange.widgets.utils.distmatrixmodel import \ + DistMatrixModel, DistMatrixView from Orange.widgets import widget, gui -from Orange.widgets.gui import OrangeUserRole from Orange.widgets.settings import Setting, ContextSetting, ContextHandler -from Orange.widgets.utils.itemdelegates import FixedFormatNumericColumnDelegate from Orange.widgets.utils.itemmodels import VariableListModel -from Orange.widgets.utils.itemselectionmodel import SymmetricSelectionModel +from Orange.widgets.utils.itemselectionmodel import SymmetricSelectionModel, \ + BlockSelectionModel, selection_blocks, ranges from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output - -class DistanceMatrixModel(QAbstractTableModel): - def __init__(self): - super().__init__() - self.distances = None - self.fact = 70 - self.labels = None - self.colors = None - self.variable = None - self.values = None - self.label_colors = None - self.zero_diag = True - self.span = None - - def set_data(self, distances): - self.beginResetModel() - self.distances = distances - if distances is None: - return - self.span = span = float(distances.max()) - - self.colors = \ - (distances * (170 / span if span > 1e-10 else 0)).astype(np.int) - self.zero_diag = all(distances.diagonal() < 1e-6) - self.endResetModel() - - def set_labels(self, labels, variable=None, values=None): - self.labels = labels - self.variable = variable - self.values = values - if self.values is not None and not isinstance(self.variable, - StringVariable): - self.label_colors = variable.palette.values_to_qcolors(values) - else: - self.label_colors = None - self.headerDataChanged.emit(Qt.Vertical, 0, self.rowCount() - 1) - self.headerDataChanged.emit(Qt.Horizontal, 0, self.columnCount() - 1) - self.dataChanged.emit( - self.index(0, 0), - self.index(self.rowCount() - 1, self.columnCount() - 1) - ) - - def dimension(self, parent=None): - if parent and parent.isValid() or self.distances is None: - return 0 - return len(self.distances) - - columnCount = rowCount = dimension - - def color_for_label(self, ind, light=100): - if self.label_colors is None: - return Qt.lightGray - return QBrush(self.label_colors[ind].lighter(light)) - - def color_for_cell(self, row, col): - return QBrush(QColor.fromHsv(120, self.colors[row, col], 255)) - - def data(self, index, role=Qt.DisplayRole): - if role == Qt.TextAlignmentRole: - return Qt.AlignRight | Qt.AlignVCenter - row, col = index.row(), index.column() - if self.distances is None: - return - if role == TableBorderItem.BorderColorRole: - return self.color_for_label(col), self.color_for_label(row) - if role == FixedFormatNumericColumnDelegate.ColumnDataSpanRole: - return 0., self.span - if row == col and self.zero_diag: - if role == Qt.BackgroundColorRole and self.variable: - return self.color_for_label(row, 200) - return - if role == Qt.DisplayRole: - return float(self.distances[row, col]) - if role == Qt.BackgroundColorRole: - return self.color_for_cell(row, col) - - def headerData(self, ind, orientation, role): - if not self.labels: - return - if role == Qt.DisplayRole and ind < len(self.labels): - return self.labels[ind] - # On some systems, Qt doesn't respect the following role in the header - if role == Qt.BackgroundRole: - return self.color_for_label(ind, 150) - - -class TableBorderItem(FixedFormatNumericColumnDelegate): - BorderColorRole = next(OrangeUserRole) - - def paint(self, painter, option, index): - super().paint(painter, option, index) - colors = self.cachedData(index, self.BorderColorRole) - vcolor, hcolor = colors or (None, None) - if vcolor is not None or hcolor is not None: - painter.save() - x1, y1, x2, y2 = option.rect.getCoords() - if vcolor is not None: - painter.setPen( - QPen(QBrush(vcolor), 1, Qt.SolidLine, Qt.RoundCap)) - painter.drawLine(x1, y1, x1, y2) - if hcolor is not None: - painter.setPen( - QPen(QBrush(hcolor), 1, Qt.SolidLine, Qt.RoundCap)) - painter.drawLine(x1, y1, x2, y1) - painter.restore() - - -class TableView(gui.HScrollStepMixin, QTableView): - pass +log = logging.getLogger(__name__) class DistanceMatrixContextHandler(ContextHandler): @@ -133,40 +25,45 @@ class DistanceMatrixContextHandler(ContextHandler): def _var_names(annotations): return [a.name if isinstance(a, Variable) else a for a in annotations] + # pylint: disable=arguments-differ def new_context(self, matrix, annotations): context = super().new_context() - context.dim = matrix.shape[0] + context.shape = matrix.shape + context.symmetric = matrix.is_symmetric() context.annotations = self._var_names(annotations) context.annotation = context.annotations[1] - context.selection = [] + context.selection = [] if context.symmetric else ([], []) return context - # noinspection PyMethodOverriding + # pylint: disable=arguments-differ def match(self, context, matrix, annotations): annotations = self._var_names(annotations) - if context.dim != matrix.shape[0] or \ - context.annotation not in annotations: + if context.shape != matrix.shape \ + or context.symmetric is not matrix.is_symmetric() \ + or context.annotation not in annotations: return 0 return 1 + (context.annotations == annotations) def settings_from_widget(self, widget, *args): + # pylint: disable=protected-access context = widget.current_context if context is not None: context.annotation = widget.annot_combo.currentText() - context.selection = widget.tableview.selectionModel().selectedItems() + context.selection, _ = widget._get_selection() def settings_to_widget(self, widget, *args): + # pylint: disable=protected-access context = widget.current_context widget.annotation_idx = context.annotations.index(context.annotation) - widget.tableview.selectionModel().setSelectedItems(context.selection) + widget._set_selection(context.selection) class OWDistanceMatrix(widget.OWWidget): name = "Distance Matrix" description = "View distance matrix." - icon = "icons/DistanceMatrix.svg" + icon = "icons/DistanceMatrix-symbolic.svg" priority = 200 - keywords = [] + keywords = "distance matrix" class Inputs: distances = Input("Distances", DistMatrix) @@ -175,7 +72,11 @@ class Outputs: distances = Output("Distances", DistMatrix, dynamic=False) table = Output("Selected Data", Table, replaces=["Table"]) + class Error(widget.OWWidget.Error): + empty_matrix = widget.Msg("Distance matrix is empty.") + settingsHandler = DistanceMatrixContextHandler() + settings_version = 2 auto_commit = Setting(True) annotation_idx = ContextSetting(1) selection = ContextSetting([]) @@ -188,23 +89,9 @@ def __init__(self): self.distances = None self.items = None - self.tablemodel = DistanceMatrixModel() - view = self.tableview = TableView() - view.setWordWrap(False) - view.setTextElideMode(Qt.ElideNone) - view.setEditTriggers(QTableView.NoEditTriggers) - view.setItemDelegate(TableBorderItem(roles=(Qt.DisplayRole, Qt.BackgroundRole))) + self.tablemodel = DistMatrixModel() + view = self.tableview = DistMatrixView() view.setModel(self.tablemodel) - view.setShowGrid(False) - for header in (view.horizontalHeader(), view.verticalHeader()): - header.setResizeContentsPrecision(1) - header.setSectionResizeMode(QHeaderView.ResizeToContents) - header.setHighlightSections(True) - header.setSectionsClickable(False) - view.verticalHeader().setDefaultAlignment( - Qt.AlignRight | Qt.AlignVCenter) - selmodel = SymmetricSelectionModel(view.model(), view) - view.setSelectionModel(selmodel) view.setSelectionBehavior(QTableView.SelectItems) self.controlArea.layout().addWidget(view) @@ -217,132 +104,235 @@ def __init__(self): gui.rubber(self.buttonsArea) acb = gui.auto_send(self.buttonsArea, self, "auto_commit", box=False) acb.setFixedWidth(200) - # Signal must be connected after self.commit is redirected - selmodel.selectionChanged.connect(self.commit) def sizeHint(self): return QSize(800, 500) @Inputs.distances def set_distances(self, distances): + self.clear_messages() self.closeContext() + if distances is not None: + if len(distances) == 0: + distances = None + self.Error.empty_matrix() self.distances = distances self.tablemodel.set_data(self.distances) - self.selection = [] - self.tableview.selectionModel().clear() + self.items = None - self.items = items = distances is not None and distances.row_items annotations = ["None", "Enumerate"] - pending_idx = 1 - if items and not distances.axis: - annotations.append("Attribute names") - pending_idx = 2 - elif isinstance(items, list) and \ - all(isinstance(item, Variable) for item in items): - annotations.append("Name") - pending_idx = 2 - elif isinstance(items, Table): - annotations.extend( - itertools.chain(items.domain.variables, items.domain.metas)) - if items.domain.class_var: - pending_idx = 2 + len(items.domain.attributes) + view = self.tableview + + if distances is not None: + pending_idx = 1 + + if not distances.is_symmetric(): + seltype = BlockSelectionModel + if distances.row_items is not None \ + or distances.col_items is not None: + annotations.append("Labels") + pending_idx = 2 + else: + seltype = SymmetricSelectionModel + self.items = items = distances.row_items + + if items and not distances.axis: + annotations.append("Attribute names") + pending_idx = 2 + elif isinstance(items, list) and \ + all(isinstance(item, Variable) for item in items): + annotations.append("Name") + pending_idx = 2 + elif isinstance(items, Table): + annotations.extend( + itertools.chain(items.domain.variables, items.domain.metas)) + pending_idx = annotations.index(self._choose_label(items)) + + selmodel = seltype(view.model(), view) + selmodel.selectionChanged.connect(self.commit.deferred) + view.setSelectionModel(selmodel) + else: + pending_idx = 0 + view.selectionModel().clear() + self.annot_combo.model()[:] = annotations self.annotation_idx = pending_idx - - if items: + if distances is not None: self.openContext(distances, annotations) - self._update_labels() - self.tableview.resizeColumnsToContents() - self.unconditional_commit() + self._update_labels() + view.resizeColumnsToContents() + self.commit.now() + + @staticmethod + def _choose_label(data: Table): + attr = max((attr for attr in data.domain.metas + if isinstance(attr, StringVariable)), + key=lambda x: len(set(data.get_column(x))), + default=None) + return attr or data.domain.class_var or "Enumerate" def _invalidate_annotations(self): if self.distances is not None: self._update_labels() def _update_labels(self): - var = column = None + def enumeration(n): + return [str(i + 1) for i in range(n)] + + hor_labels = ver_labels = None + colors = None + if self.annotation_idx == 0: - labels = None + pass + elif self.annotation_idx == 1: - labels = [str(i + 1) for i in range(self.distances.shape[0])] + ver_labels, hor_labels = map(enumeration, self.distances.shape) + elif self.annot_combo.model()[self.annotation_idx] == "Attribute names": attr = self.distances.row_items.domain.attributes - labels = [str(attr[i]) for i in range(self.distances.shape[0])] + ver_labels = hor_labels = [ + str(attr[i]) for i in range(self.distances.shape[0])] + + elif self.annot_combo.model()[self.annotation_idx] == "Labels": + if self.distances.col_items is not None: + hor_labels = [ + str(x) + for x in self.distances.get_labels(self.distances.col_items)] + else: + hor_labels = enumeration(self.distances.shape[1]) + if self.distances.row_items is not None: + ver_labels = [ + str(x) + for x in self.distances.get_labels(self.distances.row_items)] + else: + ver_labels = enumeration(self.distances.shape[0]) + elif self.annotation_idx == 2 and \ isinstance(self.items, widget.AttributeList): - labels = [v.name for v in self.items] + ver_labels = hor_labels = [v.name for v in self.items] + elif isinstance(self.items, Table): var = self.annot_combo.model()[self.annotation_idx] - column, _ = self.items.get_column_view(var) - labels = [var.str_val(value) for value in column] - if labels: - self.tableview.horizontalHeader().show() - self.tableview.verticalHeader().show() - else: - self.tableview.horizontalHeader().hide() - self.tableview.verticalHeader().hide() - self.tablemodel.set_labels(labels, var, column) + column = self.items.get_column(var) + if var.is_primitive(): + colors = var.palette.values_to_qcolors(column) + ver_labels = hor_labels = [var.str_val(value) for value in column] + + for header, labels in ((self.tableview.horizontalHeader(), hor_labels), + (self.tableview.verticalHeader(), ver_labels)): + self.tablemodel.set_labels(header.orientation(), labels, colors) + if labels is None: + header.hide() + else: + header.show() self.tableview.resizeColumnsToContents() + @gui.deferred def commit(self): sub_table = sub_distances = None if self.distances is not None: - inds = self.tableview.selectionModel().selectedItems() - if inds: - sub_distances = self.distances.submatrix(inds) - if self.distances.axis and isinstance(self.items, Table): - sub_table = self.items[inds] + inds, symmetric = self._get_selection() + if symmetric: + if inds: + sub_distances = self.distances.submatrix(inds) + if self.distances.axis and isinstance(self.items, Table): + sub_table = self.items[inds] + elif all(inds): + sub_distances = self.distances.submatrix(*inds) self.Outputs.distances.send(sub_distances) self.Outputs.table.send(sub_table) + def _get_selection(self): + selmodel = self.tableview.selectionModel() + if isinstance(selmodel, SymmetricSelectionModel): + return self.tableview.selectionModel().selectedItems(), True + else: + row_spans, col_spans = selection_blocks(selmodel.selection()) + rows = list(itertools.chain.from_iterable( + itertools.starmap(range, row_spans))) + cols = list(itertools.chain.from_iterable( + itertools.starmap(range, col_spans))) + return (rows, cols), False + + def _set_selection(self, selection): + selmodel = self.tableview.selectionModel() + if isinstance(selmodel, SymmetricSelectionModel): + if not isinstance(selection, list): + log.error("wrong data for symmetric selection") + return + selmodel.setSelectedItems(selection) + else: + if not isinstance(selection, tuple) and len(selection) == 2: + log.error("wrong data for asymmetric selection") + return + rows, cols = selection + selection = QItemSelection() + rowranges = list(ranges(rows)) + colranges = list(ranges(cols)) + + index = self.tablemodel.index + for rowstart, rowend in rowranges: + for colstart, colend in colranges: + selection.append( + QItemSelectionRange( + index(rowstart, colstart), + index(rowend - 1, colend - 1) + ) + ) + selmodel.select(selection, selmodel.ClearAndSelect) + def send_report(self): if self.distances is None: return model = self.tablemodel - dim = self.distances.shape[0] - col_cell = model.color_for_cell - - def _rgb(brush): - return "rgb({}, {}, {})".format(*brush.color().getRgb()) - if model.labels: - col_label = model.color_for_label - label_colors = [_rgb(col_label(i)) for i in range(dim)] - self.report_raw('') - self.report_raw("") - self.report_raw("".join( - ''.format(*cv) - for cv in zip(label_colors, model.labels))) + index = model.index + ndec = self.tableview.itemDelegate().ndecimals + header = model.headerData + h, w = self.distances.shape + + hor_header = bool(header(0, Qt.Horizontal, Qt.DisplayRole)) + ver_header = bool(header(0, Qt.Vertical, Qt.DisplayRole)) + + def cell(func, num): + label, brush = (func(role) + for role in (Qt.DisplayRole, Qt.BackgroundRole)) + if brush: + style = f' style="background-color: {brush.color().name()}"' + else: + style = "" + label = "" if label is None else f"{label:.{ndec}f}" if num else label + self.report_raw(f"\n") + + self.report_raw('
      {}{label}
      ') + if hor_header: + self.report_raw("") + if ver_header: + self.report_raw("") + for col in range(w): + cell(partial(header, col, Qt.Horizontal), False) self.report_raw("") - for i in range(dim): - self.report_raw("") - self.report_raw( - ''. - format(label_colors[i], model.labels[i])) - self.report_raw( - "".join( - ''.format( - _rgb(col_cell(i, j)), - label_colors[i], label_colors[j], - self.distances[i, j]) - for j in range(dim))) - self.report_raw("") - self.report_raw("
      {}' - '{:.3f}
      ") - else: - self.report_raw('') - for i in range(dim): - self.report_raw( - "" + - "".join(''. - format(_rgb(col_cell(i, j)), self.distances[i, j]) - for j in range(dim)) + - "") - self.report_raw("
      {:.3f}
      ") + + for row in range(h): + self.report_raw("") + if ver_header: + cell(partial(header, row, Qt.Vertical), False) + for col in range(w): + cell(index(row, col).data, True) + self.report_raw("") + self.report_raw("") + + @classmethod + def migrate_context(cls, context, version): + if version < 2: + context.shape = (context.dim, context.dim) + context.symmetric = True if __name__ == "__main__": # pragma: no cover import Orange.distance - data = Orange.data.Table("iris") + data = Orange.data.Table("zoo") dist = Orange.distance.Euclidean(data) + # dist = DistMatrix([[1, 2, 3], [4, 5, 6]]) + # dist.row_items = DistMatrix._labels_to_tables(["aa", "bb"]) + # dist.col_items = DistMatrix._labels_to_tables(["cc", "dd", "ee"]) WidgetPreview(OWDistanceMatrix).run(dist) diff --git a/Orange/widgets/unsupervised/owdistances.py b/Orange/widgets/unsupervised/owdistances.py index 1676d9c51da..7c883764ba1 100644 --- a/Orange/widgets/unsupervised/owdistances.py +++ b/Orange/widgets/unsupervised/owdistances.py @@ -1,7 +1,9 @@ -from scipy.sparse import issparse -import bottleneck as bn +from typing import NamedTuple, Dict, Type, Optional +from AnyQt.QtWidgets import QButtonGroup, QRadioButton from AnyQt.QtCore import Qt +from scipy.sparse import issparse +import bottleneck as bn import Orange.data import Orange.misc @@ -14,20 +16,58 @@ from Orange.widgets.widget import OWWidget, Msg, Input, Output -METRICS = [ - ("Euclidean", distance.Euclidean), - ("Manhattan", distance.Manhattan), - ("Cosine", distance.Cosine), - ("Jaccard", distance.Jaccard), - ("Spearman", distance.SpearmanR), - ("Absolute Spearman", distance.SpearmanRAbsolute), - ("Pearson", distance.PearsonR), - ("Absolute Pearson", distance.PearsonRAbsolute), - ("Hamming", distance.Hamming), - ("Mahalanobis", distance.Mahalanobis), - ('Bhattacharyya', distance.Bhattacharyya) -] - +Euclidean, EuclideanNormalized, Manhattan, ManhattanNormalized, Cosine, \ + Mahalanobis, Hamming, \ + Pearson, PearsonAbsolute, Spearman, SpearmanAbsolute, Jaccard = range(12) + + +class MetricDef(NamedTuple): + id: int # pylint: disable=invalid-name + name: str + tooltip: str + metric: Type[distance.Distance] + normalize: bool = False + + +MetricDefs: Dict[int, MetricDef] = { + metric.id: metric for metric in ( + MetricDef(EuclideanNormalized, "Euclidean (normalized)", + "Square root of summed difference between normalized values", + distance.Euclidean, normalize=True), + MetricDef(Euclidean, "Euclidean", + "Square root of summed difference between values", + distance.Euclidean), + MetricDef(ManhattanNormalized, "Manhattan (normalized)", + "Sum of absolute differences between normalized values", + distance.Manhattan, normalize=True), + MetricDef(Manhattan, "Manhattan", + "Sum of absolute differences between values", + distance.Manhattan), + MetricDef(Mahalanobis, "Mahalanobis", + "Mahalanobis distance", + distance.Mahalanobis), + MetricDef(Hamming, "Hamming", "Hamming distance", + distance.Hamming), + MetricDef(Cosine, "Cosine", "Cosine distance", + distance.Cosine), + MetricDef(Pearson, "Pearson", + "Pearson correlation; distance = 1 - ρ/2", + distance.PearsonR), + MetricDef(PearsonAbsolute, "Pearson (absolute)", + "Absolute value of Pearson correlation; distance = 1 - |ρ|", + distance.PearsonRAbsolute), + MetricDef(Spearman, "Spearman", + "Spearman correlation; distance = 1 - ρ/2", + distance.SpearmanR), + MetricDef(SpearmanAbsolute, "Spearman (absolute)", + "Absolute value of Pearson correlation; distance = 1 - |ρ|", + distance.SpearmanRAbsolute), + MetricDef(Jaccard, "Jaccard", "Jaccard distance", + distance.Jaccard) + ) +} + +MAX_ITEMS = 20_000 class InterruptException(Exception): pass @@ -36,7 +76,7 @@ class InterruptException(Exception): class DistanceRunner: @staticmethod def run(data: Orange.data.Table, metric: distance, normalized_dist: bool, - axis: int, state: TaskState) -> Orange.misc.DistMatrix: + axis: int, state: TaskState) -> Optional[Orange.misc.DistMatrix]: if data is None: return None @@ -55,8 +95,8 @@ def callback(i: float) -> bool: class OWDistances(OWWidget, ConcurrentWidgetMixin): name = "Distances" description = "Compute a matrix of pairwise distances." - icon = "icons/Distance.svg" - keywords = [] + icon = "icons/Distance-symbolic.svg" + keywords = "distances" class Inputs: data = Input("Data", Orange.data.Table) @@ -64,16 +104,11 @@ class Inputs: class Outputs: distances = Output("Distances", Orange.misc.DistMatrix, dynamic=False) - settings_version = 3 + settings_version = 4 - axis = Setting(0) # type: int - metric_idx = Setting(0) # type: int - - #: Use normalized distances if the metric supports it. - #: The default is `True`, expect when restoring from old pre v2 settings - #: (see `migrate_settings`). - normalized_dist = Setting(True) # type: bool - autocommit = Setting(True) # type: bool + axis: int = Setting(0) + metric_id: int = Setting(EuclideanNormalized) + autocommit: bool = Setting(True) want_main_area = False resizing_enabled = False @@ -86,11 +121,15 @@ class Error(OWWidget.Error): distances_value_error = Msg("Problem in calculation:\n{}") data_too_large_for_mahalanobis = Msg( "Mahalanobis handles up to 1000 {}.") + data_too_large = Msg(f"Data is too large (> {MAX_ITEMS} items).") class Warning(OWWidget.Warning): ignoring_discrete = Msg("Ignoring categorical features") ignoring_nonbinary = Msg("Ignoring non-binary features") + unsupported_sparse = Msg("Some metrics don't support sparse data\n" + "and were disabled: {}") imputing_data = Msg("Missing values were imputed") + no_features = Msg("Data has no features") def __init__(self): OWWidget.__init__(self) @@ -100,50 +139,61 @@ def __init__(self): gui.radioButtons( self.controlArea, self, "axis", ["Rows", "Columns"], - box="Distances between", callback=self._invalidate - ) - box = gui.widgetBox(self.controlArea, "Distance Metric") - self.metrics_combo = gui.comboBox( - box, self, "metric_idx", - items=[m[0] for m in METRICS], - callback=self._metric_changed - ) - self.normalization_check = gui.checkBox( - box, self, "normalized_dist", "Normalized", - callback=self._invalidate, - tooltip=("All dimensions are (implicitly) scaled to a common" - "scale to normalize the influence across the domain."), - stateWhenDisabled=False, attribute=Qt.WA_LayoutUsesWidgetRect + box="Compare", orientation=Qt.Horizontal, callback=self._invalidate ) - _, metric = METRICS[self.metric_idx] - self.normalization_check.setEnabled(metric.supports_normalization) + box = gui.hBox(self.controlArea, "Distance Metric") + self.metric_buttons = QButtonGroup() + width = 0 + for i, metric in enumerate(MetricDefs.values()): + if i % 6 == 0: + vb = gui.vBox(box) + b = QRadioButton(metric.name) + b.setChecked(self.metric_id == metric.id) + b.setToolTip(metric.tooltip) + vb.layout().addWidget(b) + width = max(width, b.sizeHint().width()) + self.metric_buttons.addButton(b, metric.id) + for b in self.metric_buttons.buttons(): + b.setFixedWidth(width) + + self.metric_buttons.idClicked.connect(self._metric_changed) gui.auto_apply(self.buttonsArea, self, "autocommit") + @Inputs.data @check_sql_input def set_data(self, data): self.cancel() self.data = data - self.refresh_metrics() - self.unconditional_commit() + self.refresh_radios() + self.commit.now() - def refresh_metrics(self): - sparse = self.data is not None and issparse(self.data.X) - for i, metric in enumerate(METRICS): - item = self.metrics_combo.model().item(i) - item.setEnabled(not sparse or metric[1].supports_sparse) + def _metric_changed(self, id_): + self.metric_id = id_ + self._invalidate() + def refresh_radios(self): + sparse = self.data is not None and issparse(self.data.X) + unsupported_sparse = [] + for metric in MetricDefs.values(): + button = self.metric_buttons.button(metric.id) + no_sparse = sparse and not metric.metric.supports_sparse + button.setEnabled(not no_sparse) + if no_sparse: + unsupported_sparse.append(metric.name) + self.Warning.unsupported_sparse(", ".join(unsupported_sparse), + shown=bool(unsupported_sparse)) + + @gui.deferred def commit(self): - # pylint: disable=invalid-sequence-index - metric = METRICS[self.metric_idx][1] - self.compute_distances(metric, self.data) + self.compute_distances(self.data) - def compute_distances(self, metric, data): + def compute_distances(self, data): def _check_sparse(): # pylint: disable=invalid-sequence-index if issparse(data.X) and not metric.supports_sparse: - self.Error.dense_metric_sparse_data(METRICS[self.metric_idx][0]) + self.Error.dense_metric_sparse_data(metric_def.name) return False return True @@ -158,7 +208,7 @@ def _fix_discrete(): self.Error.no_continuous_features() return False self.Warning.ignoring_discrete() - data = distance.remove_discrete_features(data) + data = distance.remove_discrete_features(data, to_metas=True) return True def _fix_nonbinary(): @@ -171,7 +221,8 @@ def _fix_nonbinary(): return False elif nbinary < len(data.domain.attributes): self.Warning.ignoring_nonbinary() - data = distance.remove_nonbinary_features(data) + data = distance.remove_nonbinary_features(data, + to_metas=True) return True def _fix_missing(): @@ -183,8 +234,8 @@ def _fix_missing(): def _check_tractability(): if metric is distance.Mahalanobis: - if self.axis == 1: - # when computing distances by columns, we want < 100 rows + if self.axis == 0: + # when computing distances by columns, we want < 1000 rows if len(data) > 1000: self.Error.data_too_large_for_mahalanobis("rows") return False @@ -192,18 +243,31 @@ def _check_tractability(): if len(data.domain.attributes) > 1000: self.Error.data_too_large_for_mahalanobis("columns") return False + # pylint: disable=invalid-sequence-index + if (len(data), len(data.domain.attributes))[self.axis] > MAX_ITEMS: + self.Error.data_too_large() + return False + + return True + + def _check_no_features(): + if len(data.domain.attributes) == 0: + self.Warning.no_features() return True + metric_def = MetricDefs[self.metric_id] + metric = metric_def.metric self.clear_messages() if data is not None: for check in (_check_sparse, _check_tractability, + _check_no_features, _fix_discrete, _fix_missing, _fix_nonbinary): if not check(): data = None break self.start(DistanceRunner.run, data, metric, - self.normalized_dist, self.axis) + metric_def.normalize, self.axis) def on_partial_result(self, _): pass @@ -227,18 +291,13 @@ def onDeleteWidget(self): super().onDeleteWidget() def _invalidate(self): - self.commit() - - def _metric_changed(self): - metric = METRICS[self.metric_idx][1] - self.normalization_check.setEnabled(metric.supports_normalization) - self._invalidate() + self.commit.deferred() def send_report(self): # pylint: disable=invalid-sequence-index self.report_items(( ("Distances Between", ["Rows", "Columns"][self.axis]), - ("Metric", METRICS[self.metric_idx][0]) + ("Metric", MetricDefs[self.metric_id].name) )) @classmethod @@ -254,6 +313,16 @@ def migrate_settings(cls, settings, version): settings["metric_idx"] = 9 elif 2 < metric_idx <= 9: settings["metric_idx"] -= 1 + if version < 4: + metric_idx = settings.pop("metric_idx") + metric_id = [Euclidean, Manhattan, Cosine, Jaccard, + Spearman, SpearmanAbsolute, Pearson, PearsonAbsolute, + Hamming, Mahalanobis, Euclidean][metric_idx] + if settings.pop("normalized_dist", False): + metric_id = {Euclidean: EuclideanNormalized, + Manhattan: ManhattanNormalized}.get(metric_id, + metric_id) + settings["metric_id"] = metric_id if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/unsupervised/owdistancetransformation.py b/Orange/widgets/unsupervised/owdistancetransformation.py index 34b5a80b0a6..385eda15a23 100644 --- a/Orange/widgets/unsupervised/owdistancetransformation.py +++ b/Orange/widgets/unsupervised/owdistancetransformation.py @@ -10,8 +10,8 @@ class OWDistanceTransformation(widget.OWWidget): name = "Distance Transformation" description = "Transform distances according to selected criteria." - icon = "icons/DistancesTransformation.svg" - keywords = [] + icon = "icons/DistancesTransformation-symbolic.svg" + keywords = "distance transformation" class Inputs: distances = Input("Distances", DistMatrix) @@ -61,8 +61,9 @@ def __init__(self): @Inputs.distances def set_data(self, data): self.data = data - self.unconditional_commit() + self.commit.now() + @gui.deferred def commit(self): distances = self.data if distances is not None: @@ -88,7 +89,7 @@ def send_report(self): {'Transformation': ', '.join(parts).capitalize() or 'None'}) def _invalidate(self): - self.commit() + self.commit.deferred() if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py index 6d3f8712078..d20b9922773 100644 --- a/Orange/widgets/unsupervised/owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py @@ -7,18 +7,24 @@ import numpy as np from AnyQt.QtWidgets import ( - QGraphicsWidget, QGraphicsObject, QGraphicsScene, QGridLayout, QSizePolicy, - QAction, QComboBox, QGraphicsGridLayout, QGraphicsSceneMouseEvent + QGraphicsWidget, QGraphicsScene, QGridLayout, QSizePolicy, + QAction, QComboBox, QGraphicsGridLayout, QGraphicsSceneMouseEvent, QLabel +) +from AnyQt.QtGui import (QPen, QFont, QKeySequence, QPainterPath, QColor, + QFontMetrics) +from AnyQt.QtCore import ( + Qt, QObject, QSize, QPointF, QRectF, QLineF, QEvent, QModelIndex ) -from AnyQt.QtGui import QColor, QPen, QFont, QKeySequence -from AnyQt.QtCore import Qt, QSize, QSizeF, QPointF, QRectF, QLineF, QEvent from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot -import pyqtgraph as pg +from Orange.widgets.utils.localization import pl +from orangewidget.utils.itemmodels import PyListModel +from orangewidget.utils.signals import LazyValue import Orange.data from Orange.data.domain import filter_visible -from Orange.data import Domain +from Orange.data import Domain, DiscreteVariable, ContinuousVariable, \ + StringVariable, Table import Orange.misc from Orange.clustering.hierarchical import \ postorder, preorder, Tree, tree_from_linkage, dist_matrix_linkage, \ @@ -27,19 +33,26 @@ from Orange.widgets import widget, gui, settings from Orange.widgets.utils import itemmodels, combobox -from Orange.widgets.utils.annotated_data import (create_annotated_table, - ANNOTATED_DATA_SIGNAL_NAME) +from Orange.widgets.utils.annotated_data import (lazy_annotated_table, + ANNOTATED_DATA_SIGNAL_NAME, + domain_with_annotation_column, + add_columns, + create_annotated_table) from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.utils.plotutils import AxisItem from Orange.widgets.widget import Input, Output, Msg from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView -from Orange.widgets.utils.graphicstextlist import TextListWidget +from Orange.widgets.utils.graphicsview import GraphicsWidgetView +from Orange.widgets.utils.graphicstextlist import TextListView from Orange.widgets.utils.dendrogram import DendrogramWidget __all__ = ["OWHierarchicalClustering"] LINKAGE = ["Single", "Average", "Weighted", "Complete", "Ward"] +LINKAGE_ARGS = ["single", "average", "weighted", "complete", "ward"] +DEFAULT_LINKAGE = "Ward" def make_pen(brush=Qt.black, width=1, style=Qt.SolidLine, @@ -101,31 +114,96 @@ class _DomainContextHandler(settings.DomainContextHandler, SelectionState = Tuple[List[Tuple[int]], List[Tuple[int, int, float]]] +class SelectedLabelsModel(PyListModel): + def __init__(self): + super().__init__([]) + self.subset = set() + self.__font = QFont() + self.__colors = None + + def rowCount(self, parent=QModelIndex()): + count = super().rowCount() + if self.__colors is not None: + count = max(count, len(self.__colors)) + return count + + def _emit_data_changed(self): + self.dataChanged.emit(self.index(0, 0), self.index(len(self) - 1, 0)) + + def set_subset(self, subset): + self.subset = set(subset) + self._emit_data_changed() + + def set_colors(self, colors): + self.__colors = colors + self._emit_data_changed() + + def setFont(self, font): + self.__font = font + self._emit_data_changed() + + def data(self, index, role=Qt.DisplayRole): + if role == Qt.FontRole: + font = QFont(self.__font) + font.setBold(index.row() in self.subset) + return font + if role == Qt.BackgroundRole: + if self.__colors is not None: + if index.row() < len(self.__colors): + return self.__colors[index.row()] + else: + return QColor() + elif not any(self) and self.subset: # no labels, no color, but subset + return QColor(0, 0, 0) + if role == Qt.UserRole and self.subset: + return index.row() in self.subset + + return super().data(index, role) + + +class GraphicsView(GraphicsWidgetView, StickyGraphicsView): + def minimumSizeHint(self) -> QSize: + msh = super().minimumSizeHint() + w = self.centralWidget() + if w is not None: + width = w.minimumWidth() + 4 + self.verticalScrollBar().width() + msh.setWidth(max(int(width), msh.width())) + return msh + + def eventFilter(self, recv: QObject, event: QEvent) -> bool: + ret = super().eventFilter(recv, event) + if event.type() == QEvent.LayoutRequest and recv is self.centralWidget(): + self.updateGeometry() + return ret + + class OWHierarchicalClustering(widget.OWWidget): name = "Hierarchical Clustering" description = "Display a dendrogram of a hierarchical clustering " \ "constructed from the input distance matrix." - icon = "icons/HierarchicalClustering.svg" + icon = "icons/HierarchicalClustering-symbolic.svg" priority = 2100 - keywords = [] + keywords = "hierarchical clustering" class Inputs: distances = Input("Distances", Orange.misc.DistMatrix) + subset = Input("Data Subset", Orange.data.Table, explicit=True) class Outputs: selected_data = Output("Selected Data", Orange.data.Table, default=True) annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Orange.data.Table) + settings_version = 2 settingsHandler = _DomainContextHandler() #: Selected linkage - linkage = settings.Setting(1) + linkage = settings.Setting(LINKAGE.index(DEFAULT_LINKAGE)) #: Index of the selected annotation item (variable, ...) annotation = settings.ContextSetting("Enumeration") #: Out-of-context setting for the case when the "Name" option is available annotation_if_names = settings.Setting("Name") - #: Out-of-context setting for the case with just "Enumerate" and "None" - annotation_if_enumerate = settings.Setting("Enumerate") + #: Out-of-context setting for the case with just "Enumeration" and "None" + annotation_if_enumerate = settings.Setting("Enumeration") #: Selected tree pruning (none/max depth) pruning = settings.Setting(0) #: Maximum depth when max depth pruning is selected @@ -139,15 +217,35 @@ class Outputs: top_n = settings.Setting(3) #: Dendrogram zoom factor zoom_factor = settings.Setting(0) + #: Show labels only for subset (if present) + label_only_subset = settings.Setting(False) + #: Color for label decoration + color_by: Union[DiscreteVariable, ContinuousVariable, None] = \ + settings.ContextSetting(None) autocommit = settings.Setting(True) - graph_name = "scene" + graph_name = "scene" # QGraphicsScene - basic_annotations = ["None", "Enumeration"] + basic_annotations = [None, "Enumeration"] class Error(widget.OWWidget.Error): + empty_matrix = Msg("Distance matrix is empty.") not_finite_distances = Msg("Some distances are infinite") + not_symmetric = widget.Msg("Distance matrix is not symmetric.") + + class Warning(widget.OWWidget.Warning): + subset_on_no_table = \ + Msg("Unused data subset: distances do not refer to data instances") + subset_not_subset = \ + Msg("Some data from the subset does not appear in distance matrix") + subset_wrong = \ + Msg("Subset data refers to a different table") + pruning_disables_colors = \ + Msg("Pruned cluster doesn't show colors and indicate subset") + many_clusters = \ + Msg("Variables with too many values may " + "degrade the performance of downstream widgets.") #: Stored (manual) selection state (from a saved workflow) to restore. __pending_selection_restore = None # type: Optional[SelectionState] @@ -157,19 +255,23 @@ def __init__(self): self.matrix = None self.items = None + self.subset = None + self.subset_rows = set() self.linkmatrix = None self.root = None self._displayed_root = None self.cutoff_height = 0.0 + spin_width = QFontMetrics(self.font()).horizontalAdvance("M" * 7) gui.comboBox( self.controlArea, self, "linkage", items=LINKAGE, box="Linkage", callback=self._invalidate_clustering) - model = itemmodels.VariableListModel() + model = itemmodels.VariableListModel(placeholder="None") model[:] = self.basic_annotations - box = gui.widgetBox(self.controlArea, "Annotations") + grid = QGridLayout() + gui.widgetBox(self.controlArea, "Annotations", orientation=grid) self.label_cb = cb = combobox.ComboBoxSearch( minimumContentsLength=14, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon @@ -178,15 +280,36 @@ def __init__(self): cb.setCurrentIndex(cb.findData(self.annotation, Qt.EditRole)) def on_annotation_activated(): - self.annotation = cb.currentData(Qt.EditRole) + self.annotation = self.label_cb.currentData(Qt.EditRole) self._update_labels() cb.activated.connect(on_annotation_activated) def on_annotation_changed(value): - cb.setCurrentIndex(cb.findData(value, Qt.EditRole)) + self.label_cb.setCurrentIndex( + self.label_cb.findData(value, Qt.EditRole)) self.connect_control("annotation", on_annotation_changed) - box.layout().addWidget(self.label_cb) + grid.addWidget(self.label_cb, 0, 0, 1, 2) + + cb = gui.checkBox( + None, self, "label_only_subset", "Show labels only for subset", + disabled=True, + callback=self._update_labels, stateWhenDisabled=False) + grid.addWidget(cb, 1, 0, 1, 2) + + model = itemmodels.DomainModel( + valid_types=(DiscreteVariable, ContinuousVariable), + placeholder="None") + cb = gui.comboBox( + None, self, "color_by", orientation=Qt.Horizontal, + model=model, callback=self._update_labels, + sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, + QSizePolicy.Fixed), + contentsLength=10 + ) + self.color_by_label = QLabel("Color by:") + grid.addWidget(self.color_by_label, 2, 0) + grid.addWidget(cb, 2, 1) box = gui.radioButtons( self.controlArea, self, "pruning", box="Pruning", @@ -199,9 +322,12 @@ def on_annotation_changed(value): ) self.max_depth_spin = gui.spin( box, self, "max_depth", minv=1, maxv=100, - callback=self._invalidate_pruning, + controlWidth=spin_width, alignment=Qt.AlignRight, + callback=self._max_depth_changed, keyboardTracking=False, addToLayout=False ) + self.max_depth_spin.lineEdit().returnPressed.connect( + self._max_depth_return) grid.addWidget( gui.appendRadioButton(box, "Max depth:", addToLayout=False), @@ -227,10 +353,13 @@ def on_annotation_changed(value): ) self.cut_ratio_spin = gui.spin( self.selection_box, self, "cut_ratio", 0, 100, step=1e-1, - spinType=float, callback=self._selection_method_changed, + controlWidth=spin_width, alignment = Qt.AlignRight, + spinType=float, callback=self._cut_ratio_changed, addToLayout=False ) - self.cut_ratio_spin.setSuffix("%") + self.cut_ratio_spin.setSuffix(" %") + self.cut_ratio_spin.lineEdit().returnPressed.connect( + self._cut_ratio_return) grid.addWidget(self.cut_ratio_spin, 1, 1) @@ -239,9 +368,11 @@ def on_annotation_changed(value): self.selection_box, "Top N:", addToLayout=False), 2, 0 ) - self.top_n_spin = gui.spin(self.selection_box, self, "top_n", 1, 20, - callback=self._selection_method_changed, - addToLayout=False) + self.top_n_spin = gui.spin( + self.selection_box, self, "top_n", 1, 1000, + controlWidth=spin_width, alignment=Qt.AlignRight, + callback=self._top_n_changed, addToLayout=False) + self.top_n_spin.lineEdit().returnPressed.connect(self._top_n_return) grid.addWidget(self.top_n_spin, 2, 1) self.zoom_slider = gui.hSlider( @@ -269,12 +400,16 @@ def on_annotation_changed(value): gui.auto_send(self.buttonsArea, self, "autocommit") self.scene = QGraphicsScene(self) - self.view = StickyGraphicsView( + self.view = GraphicsView( self.scene, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, - alignment=Qt.AlignLeft | Qt.AlignVCenter + alignment=Qt.AlignLeft | Qt.AlignVCenter, + widgetResizable=True, ) + # Disable conflicting action shortcuts. We define our own. + for a in self.view.viewActions(): + a.setEnabled(False) self.mainArea.layout().setSpacing(1) self.mainArea.layout().addWidget(self.view) @@ -289,21 +424,27 @@ def axis_view(orientation): self.top_axis = axis_view("top") self.bottom_axis = axis_view("bottom") - self._main_graphics = QGraphicsWidget() + self._main_graphics = QGraphicsWidget( + sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + ) scenelayout = QGraphicsGridLayout() scenelayout.setHorizontalSpacing(10) scenelayout.setVerticalSpacing(10) self._main_graphics.setLayout(scenelayout) self.scene.addItem(self._main_graphics) + self.view.setCentralWidget(self._main_graphics) + self.scene.addItem(self._main_graphics) - self.dendrogram = DendrogramWidget() + self.dendrogram = DendrogramWidget(pen_width=2) self.dendrogram.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.dendrogram.selectionChanged.connect(self._invalidate_output) self.dendrogram.selectionEdited.connect(self._selection_edited) - self.labels = TextListWidget() + self.labels = TextListView(elideMode=Qt.ElideRight) + self.label_model = SelectedLabelsModel() + self.labels.setModel(self.label_model) self.labels.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) self.labels.setAlignment(Qt.AlignLeft) self.labels.setMaximumWidth(200) @@ -316,9 +457,6 @@ def axis_view(orientation): alignment=Qt.AlignLeft | Qt.AlignVCenter) scenelayout.addItem(self.bottom_axis, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) - self.view.viewport().installEventFilter(self) - self._main_graphics.installEventFilter(self) - self.top_axis.setZValue(self.dendrogram.zValue() + 10) self.bottom_axis.setZValue(self.dendrogram.zValue() + 10) self.cut_line = SliderLine(self.top_axis, @@ -330,95 +468,140 @@ def axis_view(orientation): @Inputs.distances def set_distances(self, matrix): + self.error() + self.Error.clear() + + self.matrix = None + self.Error.clear() + if matrix is not None: + if len(matrix) < 2: + self.Error.empty_matrix() + elif not matrix.is_symmetric(): + self.Error.not_symmetric() + elif not np.all(np.isfinite(matrix)): + self.Error.not_finite_distances() + else: + self.matrix = matrix + + @Inputs.subset + def set_subset(self, subset): + self.subset = subset + self.controls.label_only_subset.setDisabled(subset is None) + + def handleNewSignals(self): if self.__pending_selection_restore is not None: selection_state = self.__pending_selection_restore else: # save the current selection to (possibly) restore later selection_state = self._save_selection() - self.error() - self.Error.clear() - if matrix is not None: - N, _ = matrix.shape - if N < 2: - self.error("Empty distance matrix") - matrix = None - if matrix is not None: - if not np.all(np.isfinite(matrix)): - self.Error.not_finite_distances() - matrix = None - - self.matrix = matrix + matrix = self.matrix if matrix is not None: self._set_items(matrix.row_items, matrix.axis) else: self._set_items(None) - self._invalidate_clustering() + self._update() # Can now attempt to restore session state from a saved workflow. if self.root and selection_state is not None: self._restore_selection(selection_state) self.__pending_selection_restore = None - self.unconditional_commit() + self.Warning.clear() + rows = set() + if self.subset: + subsetids = set(self.subset.ids) + if not isinstance(self.items, Orange.data.Table) \ + or not self.matrix.axis: + self.Warning.subset_on_no_table() + elif (dataids := set(self.items.ids)) and not subsetids & dataids: + self.Warning.subset_wrong() + elif not subsetids <= dataids: + self.Warning.subset_not_subset() + else: + indices = [leaf.value.index for leaf in leaves(self.root)] + rows = { + row for row, rowid in enumerate(self.items.ids[indices]) + if rowid in subsetids + } + + self.subset_rows = rows + self._update_labels() + self.commit.now() def _set_items(self, items, axis=1): self.closeContext() self.items = items model = self.label_cb.model() - if len(model) == 3: - self.annotation_if_names = self.annotation - elif len(model) == 2: - self.annotation_if_enumerate = self.annotation + color_model = self.controls.color_by.model() + color_model.set_domain(None) + self.color_by = None + if len(model) == 2 and model[0] is None: + if model[1] == "Name": + self.annotation_if_names = self.annotation + if model[1] == self.basic_annotations[1]: + self.annotation_if_enumerate = self.annotation if isinstance(items, Orange.data.Table) and axis: - model[:] = chain( - self.basic_annotations, - [model.Separator], - items.domain.class_vars, - items.domain.metas, - [model.Separator] if (items.domain.class_vars or items.domain.metas) and - next(filter_visible(items.domain.attributes), False) else [], - filter_visible(items.domain.attributes) - ) - if items.domain.class_vars: - self.annotation = items.domain.class_vars[0] + metas_class = tuple( + filter_visible(chain(items.domain.metas, + items.domain.class_vars))) + visible_attrs = tuple(filter_visible(items.domain.attributes)) + if not (metas_class or visible_attrs): + model[:] = self.basic_annotations + else: + model[:] = ( + (None, ) + + metas_class + + (model.Separator, ) * bool(metas_class and visible_attrs) + + visible_attrs) + for meta in items.domain.metas: + if isinstance(meta, StringVariable): + self.annotation = meta + break else: - self.annotation = "Enumeration" + if items.domain.class_vars: + # No string metas: show class + self.annotation = items.domain.class_vars[0] + else: + # No string metas and no class: show the first option + # which is not None (in the worst case, Enumeration) + self.annotation = model[1] + + color_model.set_domain(items.domain) + if items.domain.class_vars: + self.color_by = items.domain.class_vars[0] self.openContext(items.domain) + elif isinstance(items, Orange.data.Table) and not axis \ + or (isinstance(items, list) and \ + all(isinstance(var, Orange.data.Variable) + for var in items)): + model[:] = (None, "Name") + self.annotation = self.annotation_if_names else: - name_option = bool( - items is not None and ( - not axis or - isinstance(items, list) and - all(isinstance(var, Orange.data.Variable) for var in items))) - model[:] = self.basic_annotations + ["Name"] * name_option - self.annotation = self.annotation_if_names if name_option \ - else self.annotation_if_enumerate + model[:] = self.basic_annotations + self.annotation = self.annotation_if_enumerate + + no_colors = len(color_model) == 1 + self.controls.color_by.setDisabled(no_colors) + self.color_by_label.setDisabled(no_colors) def _clear_plot(self): self.dendrogram.set_root(None) - self.labels.setItems([]) + self.label_model.clear() def _set_displayed_root(self, root): self._clear_plot() self._displayed_root = root self.dendrogram.set_root(root) - self._update_labels() - self._main_graphics.resize( - self._main_graphics.size().width(), - self._main_graphics.sizeHint(Qt.PreferredSize).height() - ) - self._main_graphics.layout().activate() - def _update(self): self._clear_plot() distances = self.matrix if distances is not None: - method = LINKAGE[self.linkage].lower() + method = LINKAGE_ARGS[self.linkage] Z = dist_matrix_linkage(distances, linkage=method) tree = tree_from_linkage(Z) @@ -440,32 +623,61 @@ def _update(self): self._apply_selection() def _update_labels(self): + if not hasattr(self, "label_model"): + # This method can be called during widget initialization when + # creating check box for label_only_subset, if it's value is + # initially True. + # See https://github.com/biolab/orange-widget-base/pull/213; + # if it's merged, this check can be removed. + return + + self.Warning.pruning_disables_colors( + shown=self.pruning + and (self.subset_rows or self.color_by is not None)) labels = [] if self.root and self._displayed_root: indices = [leaf.value.index for leaf in leaves(self.root)] - if self.annotation == "None": - labels = [] + if self.annotation is None: + if not self.pruning \ + and self.subset_rows and self.color_by is None: + # Model fails if number of labels and of colors mismatch + labels = [""] * len(indices) + else: + labels = [] elif self.annotation == "Enumeration": labels = [str(i+1) for i in indices] elif self.annotation == "Name": attr = self.matrix.row_items.domain.attributes labels = [str(attr[i]) for i in indices] elif isinstance(self.annotation, Orange.data.Variable): - col_data, _ = self.items.get_column_view(self.annotation) - labels = [self.annotation.str_val(val) for val in col_data] + col_data = self.items.get_column(self.annotation) + labels = [self.annotation.str_val(val).replace("\n", " ") + for val in col_data] labels = [labels[idx] for idx in indices] else: labels = [] + if not self.pruning and \ + labels and self.label_only_subset and self.subset_rows: + labels = [label if row in self.subset_rows else "" + for row, label in enumerate(labels)] if labels and self._displayed_root is not self.root: joined = leaves(self._displayed_root) labels = [", ".join(labels[leaf.value.first: leaf.value.last]) for leaf in joined] - self.labels.setItems(labels) + self.label_model[:] = labels + self.label_model.set_subset(set() if self.pruning else self.subset_rows) self.labels.setMinimumWidth(1 if labels else -1) + if not self.pruning and self.color_by is not None: + col = self.items.get_column(self.color_by) + self.label_model.set_colors( + self.color_by.palette.values_to_qcolors(col[indices])) + else: + self.label_model.set_colors(None) + def _restore_selection(self, state): # type: (SelectionState) -> bool """ @@ -527,13 +739,22 @@ def _set_selected_nodes(self, selection): finally: self.dendrogram.selectionChanged.connect(self._invalidate_output) + def _max_depth_return(self): + if self.pruning != 1: + self.pruning = 1 + self._invalidate_pruning() + + def _max_depth_changed(self): + self.pruning = 1 + self._invalidate_pruning() + def _invalidate_clustering(self): self._update() self._update_labels() self._invalidate_output() def _invalidate_output(self): - self.commit() + self.commit.deferred() def _invalidate_pruning(self): if self.root: @@ -551,8 +772,10 @@ def _invalidate_pruning(self): self._apply_selection() + @gui.deferred def commit(self): items = getattr(self.matrix, "items", self.items) + self.Warning.many_clusters.clear() if not items: self.Outputs.selected_data.send(None) self.Outputs.annotated_data.send(None) @@ -565,82 +788,81 @@ def commit(self): maps = [indices[node.value.first:node.value.last] for node in selection] + if len(maps) > 20: + self.Warning.many_clusters() selected_indices = list(chain(*maps)) - unselected_indices = sorted(set(range(self.root.value.last)) - - set(selected_indices)) if not selected_indices: self.Outputs.selected_data.send(None) - annotated_data = create_annotated_table(items, []) \ + annotated_data = lazy_annotated_table(items, []) \ if self.selection_method == 0 and self.matrix.axis else None self.Outputs.annotated_data.send(annotated_data) return - selected_data = None + selected_data = annotated_data = None if isinstance(items, Orange.data.Table) and self.matrix.axis == 1: # Select rows - c = np.zeros(self.matrix.shape[0]) + data, domain = items, items.domain + c = np.full(self.matrix.shape[0], len(maps)) for i, indices in enumerate(maps): c[indices] = i - c[unselected_indices] = len(maps) - mask = c != len(maps) - - data, domain = items, items.domain - attrs = domain.attributes - classes = domain.class_vars - metas = domain.metas - - var_name = get_unique_names(domain, "Cluster") + clust_name = get_unique_names(domain, "Cluster") values = [f"C{i + 1}" for i in range(len(maps))] - clust_var = Orange.data.DiscreteVariable( - var_name, values=values + ["Other"]) - domain = Orange.data.Domain(attrs, classes, metas + (clust_var,)) - data = items.transform(domain) - data.get_column_view(clust_var)[0][:] = c + sel_clust_var = Orange.data.DiscreteVariable( + name=clust_name, values=values) + sel_domain = add_columns(domain, metas=(sel_clust_var,)) + selected_data = LazyValue[Table]( + lambda: items.add_column( + sel_clust_var, c, to_metas=True)[c != len(maps)], + domain=sel_domain, length=len(selected_indices)) - if selected_indices: - selected_data = data[mask] - clust_var = Orange.data.DiscreteVariable( - var_name, values=values) - selected_data.domain = Domain( - attrs, classes, metas + (clust_var, )) + ann_clust_var = Orange.data.DiscreteVariable( + name=clust_name, values=values + ["Other"] + ) + ann_domain = add_columns( + domain_with_annotation_column(data)[0], metas=(ann_clust_var, )) + annotated_data = LazyValue[Table]( + lambda: create_annotated_table( + data=items.add_column(ann_clust_var, c, to_metas=True), + selected_indices=selected_indices), + domain=ann_domain, length=len(items) + ) elif isinstance(items, Orange.data.Table) and self.matrix.axis == 0: # Select columns - domain = Orange.data.Domain( - [items.domain[i] for i in selected_indices], + attrs = [] + unselected_indices = sorted(set(range(self.root.value.last)) - + set(selected_indices)) + for clust, indices in chain(enumerate(maps, start=1), + [(0, unselected_indices)]): + for i in indices: + attr = items.domain[i].copy() + attr.attributes["cluster"] = clust + attrs.append(attr) + all_domain = Orange.data.Domain( + # len(unselected_indices) can be 0 + attrs[:len(attrs) - len(unselected_indices)], items.domain.class_vars, items.domain.metas) - selected_data = items.from_table(domain, items) - data = None + + selected_data = LazyValue[Table]( + lambda: items.from_table(all_domain, items), + domain=all_domain, length=len(items)) + + sel_domain = Orange.data.Domain( + attrs, + items.domain.class_vars, items.domain.metas) + annotated_data = LazyValue[Table]( + lambda: items.from_table(sel_domain, items), + domain=sel_domain, length=len(items)) self.Outputs.selected_data.send(selected_data) - annotated_data = create_annotated_table(data, selected_indices) self.Outputs.annotated_data.send(annotated_data) - def eventFilter(self, obj, event): - if obj is self.view.viewport() and event.type() == QEvent.Resize: - # NOTE: not using viewport.width(), due to 'transient' scroll bars - # (macOS). Viewport covers the whole view, but QGraphicsView still - # scrolls left, right with scroll bar extent (other - # QAbstractScrollArea widgets behave as expected). - w_frame = self.view.frameWidth() - margin = self.view.viewportMargins() - w_scroll = self.view.verticalScrollBar().width() - width = (self.view.width() - w_frame * 2 - - margin.left() - margin.right() - w_scroll) - # layout with new width constraint - self.__layout_main_graphics(width=width) - elif obj is self._main_graphics and \ - event.type() == QEvent.LayoutRequest: - # layout preserving the width (vertical re layout) - self.__layout_main_graphics() - return super().eventFilter(obj, event) - @Slot(QPointF) def _activate_cut_line(self, pos: QPointF): """Activate cut line selection an set cut value to `pos.x()`.""" @@ -713,6 +935,24 @@ def select_max_height(self, height): clusters = clusters_at_height(root, height) self.dendrogram.set_selected_clusters(clusters) + def _cut_ratio_changed(self): + self.selection_method = 1 + self._selection_method_changed() + + def _cut_ratio_return(self): + if self.selection_method != 1: + self.selection_method = 1 + self._selection_method_changed() + + def _top_n_changed(self): + self.selection_method = 2 + self._selection_method_changed() + + def _top_n_return(self): + if self.selection_method != 2: + self.selection_method = 2 + self._selection_method_changed() + def _selection_method_changed(self): self._set_cut_line_visible(self.selection_method == 1) if self.root: @@ -807,21 +1047,12 @@ def __zoom_reset(self): self.zoom_factor = 0 self.__update_font_scale() - def __layout_main_graphics(self, width=-1): - if width < 0: - # Preserve current width. - width = self._main_graphics.size().width() - preferred = self._main_graphics.effectiveSizeHint( - Qt.PreferredSize, constraint=QSizeF(width, -1)) - self._main_graphics.resize(QSizeF(width, preferred.height())) - mw = self._main_graphics.minimumWidth() + 4 - self.view.setMinimumWidth(mw + self.view.verticalScrollBar().width()) - def __update_font_scale(self): font = self.scene.font() factor = (1.25 ** self.zoom_factor) font = qfont_scaled(font, factor) self._main_graphics.setFont(font) + self.label_model.setFont(font) def send_report(self): annot = self.label_cb.currentText() @@ -832,16 +1063,22 @@ def send_report(self): elif self.selection_method == 1: sel = "at {:.1f} of height".format(self.cut_ratio) else: - sel = "top {} clusters".format(self.top_n) + sel = f"top {self.top_n} {pl(self.top_n, 'cluster')}" self.report_items(( - ("Linkage", LINKAGE[self.linkage].lower()), + ("Linkage", LINKAGE[self.linkage]), ("Annotation", annot), - ("Prunning", + ("Pruning", self.pruning != 0 and "{} levels".format(self.max_depth)), ("Selection", sel), )) self.report_plot() + @classmethod + def migrate_context(cls, context, version): + if version < 2: + if context.values["annotation"] == "None": + context.values["annotation"] = None + def qfont_scaled(font, factor): scaled = QFont(font) @@ -852,7 +1089,7 @@ def qfont_scaled(font, factor): return scaled -class AxisItem(pg.AxisItem): +class AxisItem(AxisItem): mousePressed = Signal(QPointF, Qt.MouseButton) mouseMoved = Signal(QPointF, Qt.MouseButtons) mouseReleased = Signal(QPointF, Qt.MouseButton) @@ -877,7 +1114,7 @@ def mouseReleaseEvent(self, event): event.accept() -class SliderLine(QGraphicsObject): +class SliderLine(QGraphicsWidget): """A movable slider line.""" valueChanged = Signal(float) @@ -893,14 +1130,10 @@ def __init__(self, parent=None, orientation=Qt.Vertical, value=0.0, self._length = length self._min = 0.0 self._max = 1.0 - self._line = QLineF() # type: Optional[QLineF] - self._pen = QPen() + self._line: Optional[QLineF] = QLineF() + self._pen: Optional[QPen] = None super().__init__(parent, **kwargs) - self.setAcceptedMouseButtons(Qt.LeftButton) - self.setPen(make_pen(brush=QColor(50, 50, 50), width=1, cosmetic=False, - style=Qt.DashLine)) - if self._orientation == Qt.Vertical: self.setCursor(Qt.SizeVerCursor) else: @@ -915,7 +1148,10 @@ def setPen(self, pen: Union[QPen, Qt.GlobalColor, Qt.PenStyle]) -> None: self.update() def pen(self) -> QPen: - return QPen(self._pen) + if self._pen is None: + return QPen(self.palette().text(), 1.0, Qt.DashLine) + else: + return QPen(self._pen) def setValue(self, value: float): value = min(max(value, self._min), self._max) @@ -977,6 +1213,11 @@ def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: self.lineReleased.emit() event.accept() + def shape(self) -> QPainterPath: + path = QPainterPath() + path.addRect(self.boundingRect()) + return path + def boundingRect(self) -> QRectF: if self._line is None: if self._orientation == Qt.Vertical: @@ -1011,8 +1252,14 @@ def clusters_at_height(root, height): return cluster_list -if __name__ == "__main__": # pragma: no cover - from Orange import distance +def main(): + # pragma: no cover + from Orange import distance # pylint: disable=import-outside-toplevel data = Orange.data.Table("iris") matrix = distance.Euclidean(distance._preprocess(data)) - WidgetPreview(OWHierarchicalClustering).run(matrix) + subset = data[10:30] + WidgetPreview(OWHierarchicalClustering).run(matrix, set_subset=subset) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/Orange/widgets/unsupervised/owkmeans.py b/Orange/widgets/unsupervised/owkmeans.py index c135503f5f2..af8bc41d528 100644 --- a/Orange/widgets/unsupervised/owkmeans.py +++ b/Orange/widgets/unsupervised/owkmeans.py @@ -34,6 +34,7 @@ class ClusterTableModel(QAbstractTableModel): def __init__(self, parent=None): super().__init__(parent) self.scores = [] + self.max_score = 1 self.start_k = 0 def rowCount(self, index=QModelIndex()): @@ -51,12 +52,15 @@ def flags(self, index): def set_scores(self, scores, start_k): self.modelAboutToBeReset.emit() self.scores = scores + self.max_score = max( + (s for s in scores if not isinstance(s, str)), default=1) self.start_k = start_k self.modelReset.emit() def clear_scores(self): self.modelAboutToBeReset.emit() self.scores = [] + self.max_score = 1 self.start_k = 0 self.modelReset.emit() @@ -70,7 +74,7 @@ def data(self, index, role=Qt.DisplayRole): elif role == Qt.ToolTipRole and not valid: return score elif role == gui.BarRatioRole and valid: - return score + return score / self.max_score if self.max_score > 0 else 0 return None def headerData(self, row, _orientation, role=Qt.DisplayRole): @@ -102,9 +106,9 @@ class OWKMeans(widget.OWWidget): name = "k-Means" description = "k-Means clustering algorithm with silhouette-based " \ "quality estimation." - icon = "icons/KMeans.svg" + icon = "icons/KMeans-symbolic.svg" priority = 2100 - keywords = ["kmeans", "clustering"] + keywords = "k-means, kmeans, clustering" class Inputs: data = Input("Data", Table) @@ -114,7 +118,7 @@ class Outputs: ANNOTATED_DATA_SIGNAL_NAME, Table, default=True, replaces=["Annotated Data"] ) - centroids = Output("Centroids", Table) + centroids = Output("Centroids", Table, dynamic=False) class Error(widget.OWWidget.Error): failed = widget.Msg("Clustering failed\nError: {}") @@ -255,24 +259,24 @@ def adjustSize(self): def update_method(self): self.table_model.clear_scores() - self.commit() + self.commit.deferred() def update_k(self): self.optimize_k = False self.table_model.clear_scores() - self.commit() + self.commit.deferred() def update_from(self): self.k_to = max(self.k_from + 1, self.k_to) self.optimize_k = True self.table_model.clear_scores() - self.commit() + self.commit.deferred() def update_to(self): self.k_from = min(self.k_from, self.k_to - 1) self.optimize_k = True self.table_model.clear_scores() - self.commit() + self.commit.deferred() def enough_data_instances(self, k): """k cannot be larger than the number of data instances.""" @@ -283,7 +287,7 @@ def has_attributes(self): return len(self.data.domain.attributes) @staticmethod - def _compute_clustering(data, k, init, n_init, max_iter, random_state): + def _compute_clustering(data, k, init, n_init, max_iter, random_state, original_domain): # type: (Table, int, str, int, int, bool) -> KMeansModel if k > len(data): raise NotEnoughData() @@ -293,6 +297,9 @@ def _compute_clustering(data, k, init, n_init, max_iter, random_state): random_state=random_state, preprocessors=[] ).get_model(data) + # set explict original domain because data was preprocessed separately + model.original_domain = original_domain + if data.X.shape[0] <= SILHOUETTE_MAX_SAMPLES: model.silhouette_samples = silhouette_samples(data.X, model.labels) model.silhouette = np.mean(model.silhouette_samples) @@ -365,6 +372,7 @@ def __launch_tasks(self, ks): n_init=self.n_init, max_iter=self.max_iterations, random_state=RANDOM_STATE, + original_domain=self.data.domain, ) for k in ks] watcher = FutureSetWatcher(futures) watcher.resultReadyAt.connect(self.__clustering_complete) @@ -421,6 +429,7 @@ def cluster(self): self.__launch_tasks([self.k]) + @gui.deferred def commit(self): self.cancel() self.clear_messages() @@ -461,9 +470,9 @@ def invalidate(self, unconditional=False): self.table_model.clear_scores() if unconditional: - self.unconditional_commit() + self.commit.now() else: - self.commit() + self.commit.deferred() def update_results(self): scores = [mk if isinstance(mk, str) else mk.silhouette for mk in @@ -520,15 +529,16 @@ def send_data(self): domain = self.data.domain cluster_var = DiscreteVariable( get_unique_names(domain, "Cluster"), - values=["C%d" % (x + 1) for x in range(km.k)] + values=["C%d" % (x + 1) for x in range(km.k)], + compute_value=km ) - clust_ids = km.labels silhouette_var = ContinuousVariable( get_unique_names(domain, "Silhouette")) if km.silhouette_samples is not None: self.Warning.no_silhouettes.clear() scores = np.arctan(km.silhouette_samples) / np.pi + 0.5 clust_scores = [] + clust_ids = km.labels for i in range(km.k): in_clust = clust_ids == i if in_clust.any(): @@ -543,8 +553,8 @@ def send_data(self): new_domain = add_columns(domain, metas=[cluster_var, silhouette_var]) new_table = self.data.transform(new_domain) - new_table.get_column_view(cluster_var)[0][:] = clust_ids - new_table.get_column_view(silhouette_var)[0][:] = scores + with new_table.unlocked(new_table.metas): + new_table.set_column(silhouette_var, scores) domain_attributes = set(domain.attributes) centroid_attributes = [ @@ -556,8 +566,12 @@ def send_data(self): centroid_domain = add_columns( Domain(centroid_attributes, [], domain.metas), metas=[cluster_var, silhouette_var]) + # Table is constructed from a copy of centroids: if data is stored in + # the widget, it can be modified, so the widget should preferrably + # output a copy. The number of centroids is small, hence copying it is + # cheap. centroids = Table( - centroid_domain, km.centroids, None, + centroid_domain, km.centroids.copy(), None, np.hstack((np.full((km.k, len(domain.metas)), np.nan), np.arange(km.k).reshape(km.k, 1), clust_scores)) diff --git a/Orange/widgets/unsupervised/owlouvainclustering.py b/Orange/widgets/unsupervised/owlouvainclustering.py index ee3296811b3..0f23f47bc52 100644 --- a/Orange/widgets/unsupervised/owlouvainclustering.py +++ b/Orange/widgets/unsupervised/owlouvainclustering.py @@ -25,6 +25,7 @@ from Orange.widgets.utils.annotated_data import add_columns, \ ANNOTATED_DATA_SIGNAL_NAME from Orange.widgets.utils.concurrent import FutureWatcher +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.signals import Input, Output from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Msg @@ -47,8 +48,9 @@ class OWLouvainClustering(widget.OWWidget): name = "Louvain Clustering" description = "Detects communities in a network of nearest neighbors." - icon = "icons/LouvainClustering.svg" + icon = "icons/LouvainClustering-symbolic.svg" priority = 2110 + keywords = "community" settings_version = 2 @@ -61,7 +63,7 @@ class Inputs: class Outputs: annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table, default=True) if Network is not None: - graph = Output("Network", Network) + graph = Output("Network", Network, dynamic=False) apply_pca = Setting(True) pca_components = Setting(_DEFAULT_PCA_COMPONENTS) @@ -72,7 +74,7 @@ class Outputs: auto_commit = Setting(False) class Information(widget.OWWidget.Information): - modified = Msg("Press commit to recompute clusters and send new data") + modified = Msg("Press Apply to recompute clusters and send new data") class Error(widget.OWWidget.Error): empty_dataset = Msg("No features in data") @@ -140,7 +142,7 @@ def __init__(self): self.apply_button = gui.auto_apply( self.buttonsArea, self, "auto_commit", commit=lambda: self.commit(), callback=lambda: self._on_auto_commit_changed() - ) # type: QWidget + ).button # type: QWidget def _preprocess_data(self): if self.preprocessed_data is None: @@ -201,6 +203,7 @@ def _set_modified(self, state): elif self.auto_commit: # does not apply when auto commit is on state = False + self.apply_button.setEnabled(state) self.Information.modified(shown=state) def _on_auto_commit_changed(self): @@ -365,7 +368,8 @@ def __set_results(self, results): # Display the number of found clusters in the UI num_clusters = len(np.unique(self.partition)) - self.info_label.setText("%d clusters found." % num_clusters) + self.info_label.setText( + f"{num_clusters} {pl(num_clusters, 'cluster')} found.") self._send_data() @@ -386,15 +390,17 @@ def _send_data(self): new_domain = add_columns(domain, metas=[cluster_var]) new_table = self.data.transform(new_domain) - new_table.get_column_view(cluster_var)[0][:] = new_partition + with new_table.unlocked(new_table.metas): + new_table.set_column(cluster_var, new_partition) self.Outputs.annotated_data.send(new_table) if Network is not None: n_edges = self.graph.number_of_edges() + n_nodes = self.graph.number_of_nodes() edges = sp.coo_matrix( (np.ones(n_edges), np.array(self.graph.edges()).T), - shape=(n_edges, n_edges)) + shape=(n_nodes, n_nodes)) graph = Network(new_table, edges) self.Outputs.graph.send(graph) @@ -459,7 +465,7 @@ def onDeleteWidget(self): def send_report(self): pca = report.bool_str(self.apply_pca) if self.apply_pca: - pca += report.plural(", {number} component{s}", self.pca_components) + pca += f", {self.pca_components} {pl(self.pca_components, 'component')}" self.report_items(( ("Normalize data", report.bool_str(self.normalize)), diff --git a/Orange/widgets/unsupervised/owmanifoldlearning.py b/Orange/widgets/unsupervised/owmanifoldlearning.py index e3d7f91c9c3..2cc1ee2578e 100644 --- a/Orange/widgets/unsupervised/owmanifoldlearning.py +++ b/Orange/widgets/unsupervised/owmanifoldlearning.py @@ -1,5 +1,5 @@ import warnings -from itertools import chain +from typing import Optional import numpy as np @@ -23,6 +23,7 @@ def __init__(self, parent): QWidget.__init__(self, parent) gui.OWComponent.__init__(self, parent) self.parameters = {} + self.parameters_name = {} self.parent_callback = parent.settings_changed layout = QFormLayout() @@ -42,7 +43,7 @@ def _create_spin_parameter(self, name, minv, maxv, label): width = QFontMetrics(self.font()).horizontalAdvance("0" * 10) control = gui.spin( self, self, name, minv, maxv, - alignment=Qt.AlignRight, callbackOnReturn=True, + alignment=Qt.AlignRight, addToLayout=False, controlWidth=width, callback=lambda f=self.__spin_parameter_update, p=name: self.__parameter_changed(f, p)) @@ -65,6 +66,7 @@ def __combo_parameter_update(self, name): index = getattr(self, name + "_index") values = getattr(self, name + "_values") self.parameters[name] = values[index][0] + self.parameters_name[name] = values[index][1] def _create_radio_parameter(self, name, label): self.__radio_parameter_update(name) @@ -85,12 +87,14 @@ def __radio_parameter_update(self, name): index = getattr(self, name + "_index") values = getattr(self, name + "_values") self.parameters[name] = values[index][0] + self.parameters_name[name] = values[index][1] class TSNEParametersEditor(ManifoldParametersEditor): _metrics = ("euclidean", "manhattan", "chebyshev", "jaccard") metric_index = Setting(0) - metric_values = [(x, x.capitalize()) for x in _metrics] + metric_values = [("euclidean", "Euclidean"), ("manhattan", "Manhattan"), + ("chebyshev", "Chebyshev"), ("jaccard", "Jaccard")] perplexity = Setting(30) early_exaggeration = Setting(12) @@ -107,9 +111,16 @@ def __init__(self, parent): self._create_spin_parameter("early_exaggeration", 1, 100, "Early exaggeration:") self._create_spin_parameter("learning_rate", 1, 1000, "Learning rate:") - self._create_spin_parameter("n_iter", 250, 1e5, "Max iterations:") + self._create_spin_parameter("n_iter", 250, 10000, "Max iterations:") self._create_radio_parameter("initialization", "Initialization:") + def get_report_parameters(self): + return {"Metric": self.parameters_name["metric"], + "Perplexity": self.parameters["perplexity"], + "Early exaggeration": self.parameters["early_exaggeration"], + "Learning rate": self.parameters["learning_rate"], + "Max iterations": self.parameters["n_iter"], + "Initialization": self.parameters_name["initialization"]} class MDSParametersEditor(ManifoldParametersEditor): max_iter = Setting(300) @@ -128,6 +139,10 @@ def get_parameters(self): par = {"n_init": 1, **par} return par + def get_report_parameters(self): + return {"Max iterations": self.parameters["max_iter"], + "Initialization": self.parameters_name["init_type"]} + class IsomapParametersEditor(ManifoldParametersEditor): n_neighbors = Setting(5) @@ -135,6 +150,8 @@ def __init__(self, parent): super().__init__(parent) self._create_spin_parameter("n_neighbors", 1, 10 ** 2, "Neighbors:") + def get_report_parameters(self): + return {"Neighbors": self.parameters["n_neighbors"]} class LocallyLinearEmbeddingParametersEditor(ManifoldParametersEditor): n_neighbors = Setting(5) @@ -151,6 +168,10 @@ def __init__(self, parent): self._create_spin_parameter("n_neighbors", 1, 10 ** 2, "Neighbors:") self._create_spin_parameter("max_iter", 10, 10 ** 4, "Max iterations:") + def get_report_parameters(self): + return {"Method": self.parameters_name["method"], + "Neighbors": self.parameters["n_neighbors"], + "Max iterations": self.parameters["max_iter"]} class SpectralEmbeddingParametersEditor(ManifoldParametersEditor): affinity_index = Setting(0) @@ -161,20 +182,22 @@ def __init__(self, parent): super().__init__(parent) self._create_combo_parameter("affinity", "Affinity:") + def get_report_parameters(self): + return {"Affinity": self.parameters_name["affinity"]} class OWManifoldLearning(OWWidget): name = "Manifold Learning" description = "Nonlinear dimensionality reduction." - icon = "icons/Manifold.svg" + icon = "icons/Manifold-symbolic.svg" priority = 2200 - keywords = [] + keywords = "manifold learning" settings_version = 2 class Inputs: data = Input("Data", Table) class Outputs: - transformed_data = Output("Transformed Data", Table, dynamic=False, + transformed_data = Output("Transformed Data", Table, replaces=["Transformed data"]) MANIFOLD_METHODS = (TSNE, MDS, Isomap, LocallyLinearEmbedding, @@ -202,6 +225,9 @@ class Error(OWWidget.Error): class Warning(OWWidget.Warning): graph_not_connected = Msg("Disconnected graph, embedding may not work") + less_components = Msg( + "Creating {} components\n" + "The number of components is limited by the number of variables.") @classmethod def migrate_settings(cls, settings, version): @@ -246,31 +272,34 @@ def __init__(self): self.params_widget.show() output_box = gui.vBox(self.controlArea, "Output") - self.n_components_spin = gui.spin( + gui.spin( output_box, self, "n_components", 1, 10, label="Components:", controlWidth=QFontMetrics(self.font()).horizontalAdvance("0" * 10), - alignment=Qt.AlignRight, callbackOnReturn=True, + alignment=Qt.AlignRight, callback=self.settings_changed) - gui.rubber(self.n_components_spin.box) - self.apply_button = gui.auto_apply(self.buttonsArea, self, commit=self.apply) + self.apply_button = gui.auto_apply(self.buttonsArea, self) + + @property + def act_components(self): + return min(self.n_components, + len(self.data.domain.attributes) if self.data else 0) def manifold_method_changed(self): self.params_widget.hide() self.params_widget = self.parameter_editors[self.manifold_method_index] self.params_widget.show() - self.apply() + self.commit.deferred() def settings_changed(self): - self.apply() + self.commit.deferred() @Inputs.data def set_data(self, data): self.data = data - self.n_components_spin.setMaximum(len(self.data.domain.attributes) - if self.data else 10) - self.unconditional_apply() + self.commit.now() - def apply(self): + @gui.deferred + def commit(self): builtin_warn = warnings.warn def _handle_disconnected_graph_warning(msg, *args, **kwargs): @@ -279,7 +308,7 @@ def _handle_disconnected_graph_warning(msg, *args, **kwargs): else: builtin_warn(msg, *args, **kwargs) - out = None + embedding = None data = self.data method = self.MANIFOLD_METHODS[self.manifold_method_index] have_data = data is not None and len(data) @@ -289,28 +318,20 @@ def _handle_disconnected_graph_warning(msg, *args, **kwargs): if have_data and data.is_sparse(): self.Error.sparse_not_supported() elif have_data: - names = [var.name for var in chain(data.domain.class_vars, - data.domain.metas) if var] - proposed = ["C{}".format(i) for i in range(self.n_components)] - unique = get_unique_names(names, proposed) - domain = Domain([ContinuousVariable(name) for name in unique], - data.domain.class_vars, - data.domain.metas) try: warnings.warn = _handle_disconnected_graph_warning - projector = method(**self.get_method_parameters(data, method)) + projector = method(**self.get_method_parameters()) model = projector(data) if isinstance(model, TSNEModel): - out = model.embedding + embedding = model.embedding.X else: - X = model.embedding_ - out = Table(domain, X, data.Y, data.metas) + embedding = model.embedding_ except ValueError as e: if e.args[0] == "for method='hessian', n_neighbors " \ "must be greater than [n_components" \ " * (n_components + 3) / 2]": - n = self.n_components * (self.n_components + 3) / 2 - self.Error.n_neighbors_too_small("{}".format(n)) + n = self.act_components * (self.act_components + 3) // 2 + self.Error.n_neighbors_too_small(n) else: self.Error.manifold_error(e.args[0]) except MemoryError: @@ -320,18 +341,38 @@ def _handle_disconnected_graph_warning(msg, *args, **kwargs): finally: warnings.warn = builtin_warn - self.Outputs.transformed_data.send(out) + output = self._create_output_table(embedding) + self.Outputs.transformed_data.send(output) + if output and self.n_components != self.act_components: + self.Warning.less_components(self.act_components) - def get_method_parameters(self, data, method): - parameters = dict(n_components=self.n_components) + def _create_output_table(self, embedding: np.ndarray) -> Optional[Table]: + if embedding is None: + return None + + data = self.data + metas = list(data.domain.metas) + names = [v.name for v in data.domain.variables + data.domain.metas] + proposed = ["C{}".format(i) for i in range(self.act_components)] + unique = get_unique_names(names, proposed) + domain = Domain(data.domain.attributes, data.domain.class_vars, + metas + [ContinuousVariable(name) for name in unique]) + table = data.transform(domain) + with table.unlocked(table.metas): + table.metas[:, len(metas):] = embedding + return table + + def get_method_parameters(self): + parameters = dict(n_components=self.act_components) parameters.update(self.params_widget.get_parameters()) return parameters def send_report(self): method = self.MANIFOLD_METHODS[self.manifold_method_index] self.report_items((("Method", method.name),)) - parameters = self.get_method_parameters(self.data, method) - self.report_items("Method parameters", tuple(parameters.items())) + parameters = {"Number of components": self.act_components} + parameters.update(self.params_widget.get_report_parameters()) + self.report_items("Method parameters", parameters) if self.data: self.report_data("Data", self.data) diff --git a/Orange/widgets/unsupervised/owmds.py b/Orange/widgets/unsupervised/owmds.py index 98b580b06df..215532856d4 100644 --- a/Orange/widgets/unsupervised/owmds.py +++ b/Orange/widgets/unsupervised/owmds.py @@ -1,21 +1,27 @@ # pylint: disable=too-many-ancestors +import time from types import SimpleNamespace as namespace +from typing import Optional +import hashlib import numpy as np import scipy.spatial.distance -from AnyQt.QtCore import Qt +from AnyQt.QtGui import QIcon +from AnyQt.QtWidgets import QSizePolicy, QGridLayout, QLabel, QPushButton import pyqtgraph as pg +from orangecanvas.gui.svgiconengine import SvgIconEngine + from Orange.data import ContinuousVariable, Domain, Table, StringVariable from Orange.data.util import array_equal from Orange.distance import Euclidean from Orange.misc import DistMatrix from Orange.projection.manifold import torgerson, MDS -from Orange.widgets import gui, settings -from Orange.widgets.settings import SettingProvider +from Orange.widgets import gui +from Orange.widgets.settings import SettingProvider, Setting from Orange.widgets.utils.concurrent import TaskState, ConcurrentWidgetMixin from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase @@ -23,15 +29,6 @@ from Orange.widgets.widget import Msg, Input -def stress(X, distD): - assert X.shape[0] == distD.shape[0] == distD.shape[1] - D1_c = scipy.spatial.distance.pdist(X, metric="euclidean") - D1 = scipy.spatial.distance.squareform(D1_c, checks=False) - delta = D1 - distD - delta_sq = np.square(delta, out=delta) - return delta_sq.sum(axis=0) / 2 - - class Result(namespace): embedding = None # type: np.ndarray @@ -43,9 +40,10 @@ def run_mds(matrix: DistMatrix, max_iter: int, step_size: int, init_type: int, iterations_done = 0 init = embedding state.set_status("Running...") - oldstress = np.finfo(np.float).max + oldstress = np.finfo(float).max while True: + loop_start = time.time() step_iter = min(max_iter - iterations_done, step_size) mds = MDS( dissimilarity="precomputed", n_components=2, @@ -71,6 +69,8 @@ def run_mds(matrix: DistMatrix, max_iter: int, step_size: int, init_type: int, oldstress = stress if state.is_interruption_requested(): return res + if (wait := 0.1 - (time.time() - loop_start)) > 0: + time.sleep(wait) #: Maximum number of displayed closest pairs. @@ -79,42 +79,36 @@ def run_mds(matrix: DistMatrix, max_iter: int, step_size: int, init_type: int, class OWMDSGraph(OWScatterPlotBase): #: Percentage of all pairs displayed (ranges from 0 to 20) - connected_pairs = settings.Setting(5) + connected_pairs = Setting(5) + aggregate_dense_regions = Setting(True) def __init__(self, scatter_widget, parent): super().__init__(scatter_widget, parent) self.pairs_curve = None - self.draw_pairs = True self._similar_pairs = None self.effective_matrix = None def set_effective_matrix(self, effective_matrix): self.effective_matrix = effective_matrix - - def pause_drawing_pairs(self): - self.draw_pairs = False - - def resume_drawing_pairs(self): - self.draw_pairs = True - self.update_pairs(True) + self._similar_pairs = None def update_coordinates(self): super().update_coordinates() - self.update_pairs(reconnect=False) + self.update_pairs() def update_jittering(self): super().update_jittering() - self.update_pairs(reconnect=False) + self.update_pairs() - def update_pairs(self, reconnect): + def update_pairs(self): if self.pairs_curve: self.plot_widget.removeItem(self.pairs_curve) - if not self.draw_pairs or self.connected_pairs == 0 \ + if self.connected_pairs == 0 \ or self.effective_matrix is None \ or self.scatterplot_item is None: return emb_x, emb_y = self.scatterplot_item.getData() - if self._similar_pairs is None or reconnect: + if self._similar_pairs is None: # This code requires storing lower triangle of X (n x n / 2 # doubles), n x n / 2 * 2 indices to X, n x n / 2 indices for # argsort result. If this becomes an issue, it can be reduced to @@ -131,10 +125,10 @@ def update_pairs(self, reconnect): p = min(n * (n - 1) // 2 * self.connected_pairs // 100, MAX_N_PAIRS * self.connected_pairs // 20) indcs = np.triu_indices(n, 1) - sorted = np.argsort(m[indcs])[:p] + sorted_ind = np.argsort(m[indcs])[:p] self._similar_pairs = fpairs = np.empty(2 * p, dtype=int) - fpairs[::2] = indcs[0][sorted] - fpairs[1::2] = indcs[1][sorted] + fpairs[::2] = indcs[0][sorted_ind] + fpairs[1::2] = indcs[1][sorted_ind] emb_x_pairs = emb_x[self._similar_pairs].reshape((-1, 2)) emb_y_pairs = emb_y[self._similar_pairs].reshape((-1, 2)) @@ -147,8 +141,9 @@ def update_pairs(self, reconnect): emb_y_pairs = emb_y_pairs[pairs_mask, :] self.pairs_curve = pg.PlotCurveItem( emb_x_pairs.ravel(), emb_y_pairs.ravel(), - pen=pg.mkPen(0.8, width=2, cosmetic=True), + pen=pg.mkPen(0.8, width=1, cosmetic=True), connect="pairs", antialias=True) + self.pairs_curve.setSegmentedLineMode("on") self.pairs_curve.setZValue(-1) self.plot_widget.addItem(self.pairs_curve) @@ -157,8 +152,8 @@ class OWMDS(OWDataProjectionWidget, ConcurrentWidgetMixin): name = "MDS" description = "Two-dimensional data projection by multidimensional " \ "scaling constructed from a distance matrix." - icon = "icons/MDS.svg" - keywords = ["multidimensional scaling", "multi dimensional scaling"] + icon = "icons/MDS-symbolic.svg" + keywords = "mds, multidimensional scaling, multi dimensional scaling" class Inputs(OWDataProjectionWidget.Inputs): distances = Input("Distances", DistMatrix) @@ -178,16 +173,19 @@ class Inputs(OWDataProjectionWidget.Inputs): ("None", -1) ] - max_iter = settings.Setting(300) - initialization = settings.Setting(PCA) - refresh_rate = settings.Setting(3) + max_iter = Setting(300) + initialization = Setting(PCA) + refresh_rate: int = Setting(3) GRAPH_CLASS = OWMDSGraph graph = SettingProvider(OWMDSGraph) embedding_variables_names = ("mds-x", "mds-y") + positions_hint: Optional[tuple[list[list[float]], str]] = \ + Setting(None, schema_only=True) class Error(OWDataProjectionWidget.Error): not_enough_rows = Msg("Input data needs at least 2 rows") + matrix_not_symmetric = Msg("Distance matrix is not symmetric") matrix_too_small = Msg("Input matrix must be at least 2x2") no_attributes = Msg("Data has no attributes") mismatching_dimensions = \ @@ -207,8 +205,9 @@ def __init__(self): self.embedding = None # type: Optional[np.ndarray] self.effective_matrix = None # type: Optional[DistMatrix] - - self.graph.pause_drawing_pairs() + self._effective_matrix_hash_ref: Optional[DistMatrix] = None + self._effective_matrix_hash: Optional[str] = None + self.stress = None self.size_model = self.gui.points_models[2] self.size_model.order = \ @@ -226,16 +225,32 @@ def _add_controls(self): ) def _add_controls_optimization(self): + # This is a part of init + # pylint: disable=attribute-defined-outside-init box = gui.vBox(self.controlArea, box="Optimize", spacing=0) hbox = gui.hBox(box, margin=0) gui.button(hbox, self, "PCA", callback=self.do_PCA, autoDefault=False) gui.button(hbox, self, "Randomize", callback=self.do_random, autoDefault=False) gui.button(hbox, self, "Jitter", callback=self.do_jitter, autoDefault=False) - gui.comboBox(box, self, "refresh_rate", label="Refresh: ", - orientation=Qt.Horizontal, - items=[t for t, _ in OWMDS.RefreshRate], - callback=self.__refresh_rate_combo_changed) - self.run_button = gui.button(box, self, "Start", self._toggle_run) + gui.separator(box, height=18) + grid = QGridLayout() + gui.widgetBox(box, orientation=grid) + self.run_button = gui.button(None, self, "Start", self._toggle_run) + self.step_button = QPushButton(QIcon(SvgIconEngine(_playpause_icon)), "") + self.step_button.pressed.connect(self._step) + self.step_button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + grid.addWidget(self.run_button, 0, 0, 1, 2) + grid.addWidget(self.step_button, 0, 2) + grid.addWidget(QLabel("Refresh:"), 1, 0) + grid.addWidget( + gui.comboBox( + None, self, "refresh_rate", + items=[t for t, _ in OWMDS.RefreshRate], + sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed), + callback=self.__refresh_rate_combo_changed), + 1, 1) + self.stress_label = QLabel("Kruskal Stress: -") + grid.addWidget(self.stress_label, 2, 0, 1, 3) def __refresh_rate_combo_changed(self): if self.task is not None: @@ -266,13 +281,17 @@ def set_disimilarity(self, matrix): matrix : Optional[Orange.misc.DistMatrix] """ - if matrix is not None and len(matrix) < 2: - self.Error.matrix_too_small() - matrix = None - else: - self.Error.matrix_too_small.clear() + self.Error.matrix_too_small.clear() + self.Error.matrix_not_symmetric.clear() + self.matrix = None + if matrix is not None: + if not matrix.is_symmetric(): + self.Error.matrix_not_symmetric() + elif len(matrix) < 2: + self.Error.matrix_too_small() + else: + self.matrix = matrix - self.matrix = matrix self.matrix_data = matrix.row_items if matrix is not None else None def clear(self): @@ -288,7 +307,11 @@ def _initialize(self): self.data = None self.effective_matrix = None self.closeContext() - self.clear_messages() + + self.Error.no_attributes.clear() + self.Error.mismatching_dimensions.clear() + self.Error.out_of_memory.clear() + self.Error.optimization_error.clear() # if no data nor matrix is present reset plot if self.signal_data is None and self.matrix is None: @@ -343,21 +366,26 @@ def _toggle_run(self): if self.task is not None: self.cancel() self.run_button.setText("Resume") - self.commit() + self.step_button.setEnabled(True) + self.commit.deferred() else: self._run() - def _run(self): + def _step(self): + self._run(1) + + def _run(self, steps=None): if self.effective_matrix is None \ or np.allclose(self.effective_matrix, 0): return - self.graph.pause_drawing_pairs() self.run_button.setText("Stop") + self.step_button.setEnabled(False) + # false positive, pylint: disable=invalid-sequence-index _, step_size = OWMDS.RefreshRate[self.refresh_rate] if step_size == -1: step_size = self.max_iter init_type = "PCA" if self.initialization == OWMDS.PCA else "random" - self.start(run_mds, self.effective_matrix, self.max_iter, + self.start(run_mds, self.effective_matrix, steps or self.max_iter, step_size, init_type, self.embedding) # ConcurrentWidgetMixin @@ -367,29 +395,37 @@ def on_partial_result(self, result: Result): first_result = self.embedding is None new_embedding = result.embedding need_update = new_embedding is not self.embedding - self.embedding = new_embedding + self.set_embedding(new_embedding, update=not first_result and need_update) if first_result: self.setup_plot() - else: - if need_update: - self.graph.update_coordinates() - self.graph.update_density() def on_done(self, result: Result): assert isinstance(result.embedding, np.ndarray) assert len(result.embedding) == len(self.effective_matrix) - self.embedding = result.embedding - self.graph.resume_drawing_pairs() + # embedding, graph and stress are already updated in on_partial_result self.run_button.setText("Start") - self.commit() + self.step_button.setEnabled(True) + self.commit.deferred() + + def update_stress(self): + self.stress = self._compute_stress() + stress_val = "-" if self.stress is None else f"{self.stress:.3f}" + self.stress_label.setText(f"Kruskal Stress: {stress_val}") + + def _compute_stress(self): + if self.embedding is None or self.effective_matrix is None: + return None + point_stress = self.get_stress(self.embedding, self.effective_matrix) + return np.sqrt(2 * np.sum(point_stress) + / (np.sum(self.effective_matrix ** 2) or 1)) def on_exception(self, ex: Exception): if isinstance(ex, MemoryError): self.Error.out_of_memory() else: self.Error.optimization_error(str(ex)) - self.graph.resume_drawing_pairs() self.run_button.setText("Start") + self.step_button.setEnabled(True) def do_PCA(self): self.do_initialization(self.PCA) @@ -402,9 +438,10 @@ def do_jitter(self): def do_initialization(self, init_type: int): self.run_button.setText("Start") + self.step_button.setEnabled(True) self.__invalidate_embedding(init_type) - self.setup_plot() - self.commit() + self.graph.update_coordinates() + self.commit.deferred() def __invalidate_embedding(self, initialization=PCA): def jitter_coord(part): @@ -415,6 +452,7 @@ def jitter_coord(part): # (Random or PCA), restarting the optimization if necessary. if self.effective_matrix is None: self.graph.reset_graph() + self.update_stress() return X = self.effective_matrix @@ -430,33 +468,85 @@ def jitter_coord(part): # restart the optimization if it was interrupted. if self.task is not None: self._run() + else: + self.update_stress() + + def __array_hash(self): + X = self.effective_matrix + if X is None: + return "" + if self._effective_matrix_hash_ref is X: + return self._effective_matrix_hash + h = hashlib.sha256() + digest = h.hexdigest() + self._effective_matrix_hash_ref = X + self._effective_matrix_hash = digest + return digest def handleNewSignals(self): self._initialize() self.input_changed.emit(self.data) if self._invalidated: - self.graph.pause_drawing_pairs() - self.__invalidate_embedding() self.enable_controls() - if self.effective_matrix is not None: - self._run() + if (self.positions_hint is not None + and self.effective_matrix is not None + and len(self.positions_hint[0]) == len(self.effective_matrix) + and self.positions_hint[1] == self.__array_hash()): + self.embedding = np.array(self.positions_hint[0]) + self.positions_hint = None + self.graph.update_coordinates() + self.graph.update_density() + self.update_stress() + else: + self.__invalidate_embedding() + if self.effective_matrix is not None: + self._run() super().handleNewSignals() + def set_embedding(self, embedding, update=True): + self.set_coordinates(..., embedding, update) + + def set_coordinates(self, idx, coordinates, update=True): + if coordinates is not None \ + and self.effective_matrix is not None \ + and self.embedding is not None: + self.embedding[idx] = coordinates + self.positions_hint = (self.embedding.tolist(), self.__array_hash()) + else: + self.positions_hint = None + if update: + self.graph.update_coordinates() + self.graph.update_density() + self.update_stress() + self.graph.update_sizes() + + def finish_dragging(self): + self.commit.deferred() + def _on_connected_changed(self): self.graph.set_effective_matrix(self.effective_matrix) - self.graph.update_pairs(reconnect=True) + self.graph.update_pairs() def setup_plot(self): super().setup_plot() if self.embedding is not None: - self.graph.update_pairs(reconnect=True) + self.graph.update_pairs() def get_size_data(self): if self.attr_size == "Stress": - return stress(self.embedding, self.effective_matrix) + return self.get_stress(self.embedding, self.effective_matrix) else: return super().get_size_data() + @staticmethod + def get_stress(X, distD): + assert X.shape[0] == distD.shape[0] == distD.shape[1] + D1_c = scipy.spatial.distance.pdist(X, metric="euclidean") + D1 = scipy.spatial.distance.squareform(D1_c, checks=False) + delta = D1 - distD + delta_sq = np.square(delta, out=delta) + return delta_sq.sum(axis=0) / 2 + def get_embedding(self): self.valid_data = np.ones(len(self.embedding), dtype=bool) \ if self.embedding is not None else None @@ -477,21 +567,21 @@ def onDeleteWidget(self): super().onDeleteWidget() @classmethod - def migrate_settings(cls, settings_, version): + def migrate_settings(cls, settings, version): if version < 2: settings_graph = {} for old, new in (("label_only_selected", "label_only_selected"), ("symbol_opacity", "alpha_value"), ("symbol_size", "point_width"), ("jitter", "jitter_size")): - settings_graph[new] = settings_[old] - settings_["graph"] = settings_graph - settings_["auto_commit"] = settings_["autocommit"] + settings_graph[new] = settings[old] + settings["graph"] = settings_graph + settings["auto_commit"] = settings["autocommit"] if version < 3: - if "connected_pairs" in settings_: - connected_pairs = settings_["connected_pairs"] - settings_["graph"]["connected_pairs"] = connected_pairs + if "connected_pairs" in settings: + connected_pairs = settings["connected_pairs"] + settings["graph"]["connected_pairs"] = connected_pairs @classmethod def migrate_context(cls, context, version): @@ -521,6 +611,26 @@ def migrate_context(cls, context, version): values["attr_label"] = values["graph"]["attr_label"] +_playpause_icon = b""" + + + + + + + + + +""" + if __name__ == "__main__": # pragma: no cover table = Table("iris") WidgetPreview(OWMDS).run(set_data=table, set_subset_data=table[:30]) diff --git a/Orange/widgets/unsupervised/owpca.py b/Orange/widgets/unsupervised/owpca.py index d1904b25922..c41e9073521 100644 --- a/Orange/widgets/unsupervised/owpca.py +++ b/Orange/widgets/unsupervised/owpca.py @@ -4,28 +4,32 @@ from AnyQt.QtWidgets import QFormLayout from AnyQt.QtCore import Qt +from orangewidget.report import bool_str +from orangewidget.settings import Setting + from Orange.data import Table, Domain, StringVariable, ContinuousVariable from Orange.data.util import get_unique_names from Orange.data.sql.table import SqlTable, AUTO_DL_LIMIT from Orange.preprocess import preprocess from Orange.projection import PCA -from Orange.widgets import widget, gui, settings +from Orange.widgets import widget, gui +from Orange.widgets.utils.annotated_data import add_columns +from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin from Orange.widgets.utils.slidergraph import SliderGraph from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output - # Maximum number of PCA components that we can set in the widget MAX_COMPONENTS = 100 LINE_NAMES = ["component variance", "cumulative variance"] -class OWPCA(widget.OWWidget): +class OWPCA(widget.OWWidget, ConcurrentWidgetMixin): name = "PCA" description = "Principal component analysis with a scree-diagram." - icon = "icons/PCA.svg" + icon = "icons/PCA-symbolic.svg" priority = 3050 - keywords = ["principal component analysis", "linear transformation"] + keywords = "pca, principal component analysis, linear transformation" class Inputs: data = Input("Data", Table) @@ -33,17 +37,17 @@ class Inputs: class Outputs: transformed_data = Output("Transformed Data", Table, replaces=["Transformed data"]) data = Output("Data", Table, default=True) - components = Output("Components", Table) + components = Output("Components", Table, dynamic=False) pca = Output("PCA", PCA, dynamic=False) - ncomponents = settings.Setting(2) - variance_covered = settings.Setting(100) - auto_commit = settings.Setting(True) - normalize = settings.Setting(True) - maxp = settings.Setting(20) - axis_labels = settings.Setting(10) + ncomponents = Setting(2) + variance_covered = Setting(100) + auto_commit = Setting(True) + normalize = Setting(True) + maxp = Setting(20) + axis_labels = Setting(10) - graph_name = "plot.plotItem" + graph_name = "plot.plotItem" # QGraphicsView (pg.PlotWidget -> SliderGraph) class Warning(widget.OWWidget.Warning): trivial_components = widget.Msg( @@ -56,13 +60,13 @@ class Error(widget.OWWidget.Error): def __init__(self): super().__init__() - self.data = None + ConcurrentWidgetMixin.__init__(self) + self.data = None self._pca = None self._transformed = None self._variance_ratio = None self._cumulative = None - self._init_projector() # Components Selection form = QFormLayout() @@ -113,6 +117,7 @@ def __init__(self): @Inputs.data def set_data(self, data): + self.cancel() self.clear_messages() self.clear() self.information() @@ -120,7 +125,7 @@ def set_data(self, data): if not data: self.clear_outputs() if isinstance(data, SqlTable): - if data.approx_len() < AUTO_DL_LIMIT: + if len(data) < AUTO_DL_LIMIT: data = Table(data) else: self.information("Data has been sampled") @@ -137,12 +142,11 @@ def set_data(self, data): self.clear_outputs() return - self._init_projector() - self.data = data self.fit() def fit(self): + self.cancel() self.clear() self.Warning.trivial_components.clear() if self.data is None: @@ -150,27 +154,45 @@ def fit(self): data = self.data - if self.normalize: - self._pca_projector.preprocessors = \ - self._pca_preprocessors + [preprocess.Normalize(center=False)] - else: - self._pca_projector.preprocessors = self._pca_preprocessors + projector = self._create_projector() if not isinstance(data, SqlTable): - pca = self._pca_projector(data) - variance_ratio = pca.explained_variance_ratio_ - cumulative = numpy.cumsum(variance_ratio) - - if numpy.isfinite(cumulative[-1]): - self.components_spin.setRange(0, len(cumulative)) - self._pca = pca - self._variance_ratio = variance_ratio - self._cumulative = cumulative - self._setup_plot() - else: - self.Warning.trivial_components() + self.start(self._call_projector, data, projector) + + @staticmethod + def _call_projector(data: Table, projector, state): + + def callback(i: float, status=""): + state.set_progress_value(i * 100) + if status: + state.set_status(status) + if state.is_interruption_requested(): + raise Exception # pylint: disable=broad-exception-raised + + return projector(data, progress_callback=callback) + + def on_done(self, result): + pca = result + variance_ratio = pca.explained_variance_ratio_ + cumulative = numpy.cumsum(variance_ratio) + + if numpy.isfinite(cumulative[-1]): + self.components_spin.setRange(0, len(cumulative)) + self._pca = pca + self._variance_ratio = variance_ratio + self._cumulative = cumulative + self._setup_plot() + else: + self.Warning.trivial_components() + + self.commit.now() - self.unconditional_commit() + def on_partial_result(self, result): + pass + + def onDeleteWidget(self): + self.shutdown() + super().onDeleteWidget() def clear(self): self._pca = None @@ -183,7 +205,7 @@ def clear_outputs(self): self.Outputs.transformed_data.send(None) self.Outputs.data.send(None) self.Outputs.components.send(None) - self.Outputs.pca.send(self._pca_projector) + self.Outputs.pca.send(self._create_projector()) def _setup_plot(self): if self._pca is None: @@ -202,8 +224,7 @@ def _setup_plot(self): self._update_axis() def _on_cut_changed(self, components): - if components == self.ncomponents \ - or self.ncomponents == 0: + if self.ncomponents in (components, 0): return self.ncomponents = components @@ -250,10 +271,13 @@ def _update_normalize(self): if self.data is None: self._invalidate_selection() - def _init_projector(self): - self._pca_projector = PCA(n_components=MAX_COMPONENTS, random_state=0) - self._pca_projector.component = self.ncomponents - self._pca_preprocessors = PCA.preprocessors + def _create_projector(self): + projector = PCA(n_components=MAX_COMPONENTS, random_state=0) + projector.component = self.ncomponents # for use as a Scorer + if self.normalize: + projector.preprocessors = \ + PCA.preprocessors + [preprocess.Normalize(center=False)] + return projector def _nselected_components(self): """Return the number of selected components.""" @@ -277,7 +301,7 @@ def _nselected_components(self): return cut def _invalidate_selection(self): - self.commit() + self.commit.deferred() def _update_axis(self): p = min(len(self._variance_ratio), self.maxp) @@ -285,6 +309,7 @@ def _update_axis(self): d = max((p-1)//(self.axis_labels-1), 1) axis.setTicks([[(i, str(i)) for i in range(1, p + 1, d)]]) + @gui.deferred def commit(self): transformed = data = components = None if self._pca is not None: @@ -293,48 +318,55 @@ def commit(self): self._transformed = self._pca(self.data) transformed = self._transformed + if self._variance_ratio is not None: + for var, explvar in zip( + transformed.domain.attributes, + self._variance_ratio[:self.ncomponents]): + var.attributes["variance"] = round(explvar, 6) domain = Domain( transformed.domain.attributes[:self.ncomponents], self.data.domain.class_vars, self.data.domain.metas ) transformed = transformed.from_table(domain, transformed) + # prevent caching new features by defining compute_value proposed = [a.name for a in self._pca.orig_domain.attributes] meta_name = get_unique_names(proposed, 'components') + meta_vars = [StringVariable(name=meta_name)] + metas = numpy.array( + [[f"PC{i + 1}"for i in range(self.ncomponents)]], dtype=object + ).T + if self._variance_ratio is not None: + variance_name = get_unique_names(proposed, "variance") + meta_vars.append(ContinuousVariable(variance_name)) + metas = numpy.hstack( + (metas, + self._variance_ratio[:self.ncomponents, None])) + dom = Domain( [ContinuousVariable(name, compute_value=lambda _: None) for name in proposed], - metas=[StringVariable(name=meta_name)]) - metas = numpy.array([['PC{}'.format(i + 1) - for i in range(self.ncomponents)]], - dtype=object).T + metas=meta_vars) components = Table(dom, self._pca.components_[:self.ncomponents], metas=metas) components.name = 'components' - data_dom = Domain( - self.data.domain.attributes, - self.data.domain.class_vars, - self.data.domain.metas + domain.attributes) - data = Table.from_numpy( - data_dom, self.data.X, self.data.Y, - numpy.hstack((self.data.metas, transformed.X)), - ids=self.data.ids) + data_dom = add_columns(self.data.domain, metas=domain.attributes) + data = self.data.transform(data_dom) - self._pca_projector.component = self.ncomponents self.Outputs.transformed_data.send(transformed) self.Outputs.components.send(components) self.Outputs.data.send(data) - self.Outputs.pca.send(self._pca_projector) + self.Outputs.pca.send(self._create_projector()) def send_report(self): if self.data is None: return self.report_items(( - ("Normalize data", str(self.normalize)), + ("Normalize data", bool_str(self.normalize)), ("Selected components", self.ncomponents), - ("Explained variance", "{:.3f} %".format(self.variance_covered)) + ("Explained variance", f"{self.variance_covered:.3f} %") )) self.report_plot() diff --git a/Orange/widgets/unsupervised/owsavedistances.py b/Orange/widgets/unsupervised/owsavedistances.py index ea0731aea15..63ef062c499 100644 --- a/Orange/widgets/unsupervised/owsavedistances.py +++ b/Orange/widgets/unsupervised/owsavedistances.py @@ -7,10 +7,10 @@ class OWSaveDistances(OWSaveBase): name = "Save Distance Matrix" description = "Save distance matrix to an output file." - icon = "icons/SaveDistances.svg" - keywords = ["distance matrix", "save"] + icon = "icons/SaveDistances-symbolic.svg" + keywords = "save distance matrix, distance matrix, save" - filters = ["Distance File (*.dst)"] + filters = ["Excel File (*.xlsx)", "Distance File (*.dst)"] class Warning(OWSaveBase.Warning): table_not_saved = Msg("Associated data was not saved.") @@ -35,7 +35,7 @@ def do_save(self): def send_report(self): self.report_items(( - ("Input:", "none" if self.data is None else self._description()), + ("Input", "none" if self.data is None else self._description()), ("File name", self.filename or "not set"))) def _description(self): diff --git a/Orange/widgets/unsupervised/owsom.py b/Orange/widgets/unsupervised/owsom.py index 4917a6933f5..280e5d23215 100644 --- a/Orange/widgets/unsupervised/owsom.py +++ b/Orange/widgets/unsupervised/owsom.py @@ -1,8 +1,9 @@ from collections import defaultdict, namedtuple +from contextlib import contextmanager +from typing import Optional, Union from xml.sax.saxutils import escape import numpy as np -import scipy.sparse as sp from AnyQt.QtCore import Qt, QRectF, pyqtSignal as Signal, QObject, QThread, \ pyqtSlot as Slot @@ -13,20 +14,25 @@ QGraphicsItem, QGraphicsRectItem, QGraphicsItemGroup, QSizePolicy, \ QGraphicsPathItem -from Orange.data import Table, Domain +from Orange.widgets.utils.signals import lazy_table_transform + +from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable +from Orange.data.util import array_equal, SharedComputeValue, get_unique_names from Orange.preprocess import decimal_binnings, time_binnings from Orange.projection.som import SOM from Orange.widgets import gui +from Orange.widgets.utils.localization import pl from Orange.widgets.widget import OWWidget, Msg, Input, Output from Orange.widgets.settings import \ DomainContextHandler, ContextSetting, Setting from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.utils.annotated_data import \ - create_annotated_table, create_groups_table, ANNOTATED_DATA_SIGNAL_NAME + add_columns, group_values, \ + ANNOTATED_DATA_SIGNAL_NAME, ANNOTATED_DATA_FEATURE_NAME from Orange.widgets.utils.colorpalettes import \ - BinnedContinuousPalette, LimitedDiscretePalette + BinnedContinuousPalette, LimitedDiscretePalette, DiscretePalette from Orange.widgets.visualize.utils import CanvasRectangle, CanvasText from Orange.widgets.visualize.utils.plotutils import wrap_legend_items @@ -163,19 +169,137 @@ def paint(self, painter, _option, _index): pen = QPen(QBrush(self.color), 2) pen.setCosmetic(True) painter.setPen(pen) - painter.setBrush(QBrush(self.color.lighter(200 - 80 * self.proportion))) + painter.setBrush(QBrush(self.color.lighter(int(200 - 80 * self.proportion)))) painter.drawEllipse(self.boundingRect()) painter.restore() +@contextmanager +def disconnected_spin(spin): + spin.blockSignals(True) + try: + yield + finally: + spin.blockSignals(False) + + N_ITERATIONS = 200 +class SomSharedValueCompute: + def __init__(self, domain: Domain, model: SOM, + offsets: Union[np.ndarray, None], + scales: Union[np.ndarray, None]): + # offsets and scales are made immutable so that they are hashable + def immutable(a): + if a is not None: + a = a.copy() + a.flags.writeable = False + return a + + self.domain = domain + self.model = model + self.offsets = immutable(offsets) + self.scales = immutable(scales) + self.__hash = None + + def __getstate__(self): + state = self.__dict__.copy() + state["__hash"] = None + return state + + def __call__(self, data): + x = data.transform(self.domain).X + cont_x, mask, _, _ = SOM.prepare_data(x, self.offsets, self.scales) + winners = np.full((len(data), 2), np.nan) + distances = np.full(len(data), np.nan) + winners[mask], distances[mask] = self.model.winners(cont_x) + winners += 1 + return winners, distances + + # `offsets` and `scales` are ndarray's (and `model` contains ndarray's) + # Unless we __eq__ by identity, we can't properly define hash. + def __eq__(self, other): + return type(self) is type(other) and \ + self.domain == other.domain \ + and self.model == other.model \ + and np.array_equal(self.offsets, other.offsets) \ + and np.array_equal(self.scales, other.scales) + + def __hash__(self): + if self.__hash is None: + self.__hash = hash(( + self.domain, self.model, + tuple(self.offsets), tuple(self.scales))) + return self.__hash + + +class SomCellCompute(SharedComputeValue): + def __init__(self, compute_shared, dim_x, hexagonal): + super().__init__(compute_shared) + self.dim_x = dim_x + self.hexagonal = hexagonal + + def __eq__(self, other): + return super().__eq__(other) \ + and self.dim_x == other.dim_x \ + and self.hexagonal == other.hexagonal + + def __hash__(self): + return hash((super().__hash__(), self.dim_x, self.hexagonal)) + + def compute(self, _, shared_data): + coords = shared_data[0] + # coords are 1-based, subtract 1 + col = (coords[:, 0] - 1) + (coords[:, 1] - 1) * self.dim_x + if self.hexagonal: + # 1 cell less after every two rows + col -= col // (self.dim_x * 2 - 1) + return col + + +class SomCoordsCompute(SharedComputeValue): + def __init__(self, shared, column): + super().__init__(shared) + self.column = column + + def compute(self, _, shared_data): + return shared_data[0][:, self.column] + + def __eq__(self, other): + return self.column == other.column and super().__eq__(other) + + def __hash__(self): + return hash((super().__hash__(), self.column)) + + +class SomErrorCompute(SharedComputeValue): + InheritEq = True + + def compute(self, _, shared_data): + return shared_data[1] + + +class GetGroups: + # This assigns instances to selection groups; two instances that are not + # same cannot be considered equal, period. + InheritEq = True + + def __init__(self, id_to_group, default, offset): + self.id_to_group = id_to_group + self.default = default + offset + self.offset = offset + + def __call__(self, data, *args, **kwargs): + return np.array([self.id_to_group.get(id, self.default) - self.offset + for id in data.ids]) + + class OWSOM(OWWidget): name = "Self-Organizing Map" description = "Computation of self-organizing map." - icon = "icons/SOM.svg" - keywords = ["SOM"] + icon = "icons/SOM-symbolic.svg" + keywords = "self-organizing map, som" class Inputs: data = Input("Data", Table) @@ -196,7 +320,7 @@ class Outputs: pie_charts = Setting(False) selection = Setting(None, schema_only=True) - graph_name = "view" + graph_name = "view" # QGraphicsView _grid_pen = QPen(QBrush(QColor(224, 224, 224)), 2) _grid_pen.setCosmetic(True) @@ -206,17 +330,24 @@ class Outputs: ("shape", "auto_dim", "spin_x", "spin_y", "initialization", "start") ) + class Information(OWWidget.Information): + modified = Msg( + 'The parameter settings have been changed. Press "Start" to ' + "rerun with the new settings." + ) + class Warning(OWWidget.Warning): ignoring_disc_variables = Msg("SOM ignores categorical variables.") missing_colors = \ Msg("Some data instances have undefined value of '{}'.") - missing_values = \ - Msg("{} data instance{} with undefined value(s) {} not shown.") + no_defined_colors = \ + Msg("'{}' has no defined values.") + missing_values = Msg("{}") single_attribute = Msg("Data contains a single numeric column.") class Error(OWWidget.Error): no_numeric_variables = Msg("Data contains no numeric columns.") - no_defined_rows = Msg("All rows contain at least one undefined value.") + not_enough_data = Msg("SOM needs at least two data rows without missing values.") def __init__(self): super().__init__() @@ -226,14 +357,21 @@ def __init__(self): self.stop_optimization = False self.data = self.cont_x = None - self.cells = self.member_data = None + self.scales = self.offsets = None + self.som = self.cells = self.member_data = None self.selection = None - self.colors = self.thresholds = self.bin_labels = None + + # self.colors holds a palette or None when we need to draw same-colored + # circles. This happens by user's choice or when the color attribute + # is numeric and has no defined values, so we can't construct bins + self.colors: Optional[DiscretePalette] = None + self.thresholds = self.bin_labels = None box = gui.vBox(self.controlArea, box="SOM") shape = gui.comboBox( box, self, "", items=("Hexagonal grid", "Square grid")) shape.setCurrentIndex(1 - self.hexagonal) + shape.currentIndexChanged.connect(self.on_parameter_change) box2 = gui.indentedBox(box, 10) auto_dim = gui.checkBox( @@ -241,27 +379,40 @@ def __init__(self): callback=self.on_auto_dimension_changed) self.manual_box = box3 = gui.hBox(box2) spinargs = dict( - value="", widget=box3, master=self, minv=5, maxv=100, step=5, - alignment=Qt.AlignRight) - spin_x = gui.spin(**spinargs) - spin_x.setValue(self.size_x) + value="", + widget=box3, + master=self, + minv=5, + maxv=100, + step=5, + alignment=Qt.AlignRight, + callback=self.on_parameter_change, + ) + self.spin_x = gui.spin(**spinargs) + with disconnected_spin(self.spin_x): + self.spin_x.setValue(self.size_x) gui.widgetLabel(box3, "×") - spin_y = gui.spin(**spinargs) - spin_y.setValue(self.size_y) + self.spin_y = gui.spin(**spinargs) + with disconnected_spin(self.spin_y): + self.spin_y.setValue(self.size_y) gui.rubber(box3) self.manual_box.setEnabled(not self.auto_dimension) initialization = gui.comboBox( - box, self, "initialization", - items=("Initialize with PCA", "Random initialization", - "Replicable random")) + box, + self, + "initialization", + items=("Initialize with PCA", "Random initialization", "Replicable random"), + callback=self.on_parameter_change, + ) start = gui.button( box, self, "Restart", callback=self.restart_som_pressed, sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)) self.opt_controls = self.OptControls( - shape, auto_dim, spin_x, spin_y, initialization, start) + shape, auto_dim, self.spin_x, self.spin_y, initialization, start + ) box = gui.vBox(self.controlArea, "Color") gui.comboBox( @@ -296,56 +447,58 @@ def __init__(self): self.grid_cells = None self.legend = None + @staticmethod + def _cont_domain(data): + attrs = data.domain.attributes + cont_attrs = [var for var in attrs if var.is_continuous] + if not cont_attrs: + return None + return Domain(cont_attrs) + @Inputs.data def set_data(self, data): - def prepare_data(): - if len(cont_attrs) < len(attrs): - self.Warning.ignoring_disc_variables() - if len(cont_attrs) == 1: - self.Warning.single_attribute() - x = Table.from_table(Domain(cont_attrs), data).X - if sp.issparse(x): - self.data = data - self.cont_x = x.tocsr() - else: - mask = np.all(np.isfinite(x), axis=1) - if not np.any(mask): - self.Error.no_defined_rows() - else: - if np.all(mask): - self.data = data - self.cont_x = x.copy() - else: - self.data = data[mask] - self.cont_x = x[mask] - self.cont_x -= np.min(self.cont_x, axis=0)[None, :] - sums = np.sum(self.cont_x, axis=0)[None, :] - sums[sums == 0] = 1 - self.cont_x /= sums - def set_warnings(): missing = len(data) - len(self.data) - if missing == 1: - self.Warning.missing_values(1, "", "is") - elif missing > 1: - self.Warning.missing_values(missing, "s", "are") - - self.stop_optimization_and_wait() + if missing: + self.Warning.missing_values( + f'{missing} data {pl(missing, "instance")} with undefined value(s) {pl(missing, "is|are")} not shown.') + cont_x = self.cont_x.copy() if self.cont_x is not None else None + self.data = self.cont_x = None + self.offsets = self.scales = None + new_cont_x = None self.closeContext() - self.clear() - self.Error.clear() - self.Warning.clear() + self.clear_messages() - if data is not None: - attrs = data.domain.attributes - cont_attrs = [var for var in attrs if var.is_continuous] - if not cont_attrs: + if data: + cont_domain = self._cont_domain(data) + if cont_domain is None: self.Error.no_numeric_variables() else: - prepare_data() + cont_attrs = cont_domain.attributes + if len(cont_attrs) < len(data.domain.attributes): + self.Warning.ignoring_disc_variables() + if len(cont_attrs) == 1: + self.Warning.single_attribute() + + new_cont_x, mask, self.offsets, self.scales \ + = SOM.prepare_data(data.transform(cont_domain).X) + rows = np.sum(mask) + if rows == len(mask): + self.data = data + elif rows > 1: + self.data = data[mask] + else: + self.Error.not_enough_data() + + invalidated = cont_x is None or new_cont_x is None \ + or not array_equal(cont_x, new_cont_x) + if invalidated: + self.stop_optimization_and_wait() + self.clear() if self.data is not None: + self.cont_x = new_cont_x self.controls.attr_color.model().set_domain(data.domain) self.attr_color = data.domain.class_var set_warnings() @@ -353,11 +506,15 @@ def set_warnings(): self.openContext(self.data) self.set_color_bins() self.create_legend() - self.recompute_dimensions() - self.start_som() + if invalidated: + with disconnected_spin(self.spin_x), disconnected_spin(self.spin_y): + self.recompute_dimensions() + self.start_som() + else: + self._redraw() def clear(self): - self.data = self.cont_x = None + self.som = self.cont_x = None self.cells = self.member_data = None self.attr_color = None self.colors = self.thresholds = self.bin_labels = None @@ -366,8 +523,6 @@ def clear(self): self.elements = None self.clear_selection() self.controls.attr_color.model().set_domain(None) - self.Warning.clear() - self.Error.clear() def recompute_dimensions(self): if not self.auto_dimension or self.cont_x is None: @@ -387,6 +542,7 @@ def on_auto_dimension_changed(self): dimy = int(5 * np.round(spin_y.value() / 5)) spin_x.setValue(dimx) spin_y.setValue(dimy) + self.on_parameter_change() def on_attr_color_change(self): self.controls.pie_charts.setEnabled(self.attr_color is not None) @@ -401,6 +557,9 @@ def on_attr_size_change(self): def on_pie_chart_change(self): self._redraw() + def on_parameter_change(self): + self.Information.modified() + def clear_selection(self): self.selection = None self.redraw_selection() @@ -410,15 +569,18 @@ def on_selection_change(self, selection, action=SomView.SelectionSet): return if self.selection is None: self.selection = np.zeros(self.grid_cells.T.shape, dtype=np.int16) + + selection_np = np.array(self.selection) if action == SomView.SelectionSet: - self.selection[:] = 0 - self.selection[selection] = 1 + selection_np[:] = 0 + selection_np[selection] = 1 elif action == SomView.SelectionAddToGroup: - self.selection[selection] = max(1, np.max(self.selection)) + selection_np[selection] = max(1, np.max(selection_np)) elif action == SomView.SelectionNewGroup: - self.selection[selection] = 1 + np.max(self.selection) + selection_np[selection] = 1 + np.max(selection_np) elif action & SomView.SelectionRemove: - self.selection[selection] = 0 + selection_np[selection] = 0 + self.selection = selection_np.tolist() self.redraw_selection() self.update_output() @@ -433,6 +595,7 @@ def on_selection_move(self, event: QKeyEvent): x, y = np.nonzero(self.selection) if len(x) > 1: return + x, y = x[0], y[0] if event.key() == Qt.Key_Up and y > 0: y -= 1 if event.key() == Qt.Key_Down and y < self.size_y - 1: @@ -443,11 +606,11 @@ def on_selection_move(self, event: QKeyEvent): x += 1 x -= self.hexagonal and x == self.size_x - 1 and y % 2 - if self.selection is not None and self.selection[x, y]: + if self.selection is not None and self.selection[x][y]: return selection = np.zeros(self.grid_cells.shape, dtype=bool) selection[x, y] = True - self.on_selection_change(selection) + self.on_selection_change(selection.tolist()) def on_selection_mark_change(self, marks): self.redraw_selection(marks=marks) @@ -472,7 +635,7 @@ def redraw_selection(self, marks=None): for x in range(self.size_x - (y % 2) * self.hexagonal): cell = self.grid_cells[y, x] marked = marks is not None and marks[x, y] - sel_group = self.selection is not None and self.selection[x, y] + sel_group = self.selection is not None and self.selection[x][y] if marked: cell.setBrush(mark_brush) cell.setPen(mark_pen) @@ -482,6 +645,7 @@ def redraw_selection(self, marks=None): cell.setZValue(marked or sel_group) def restart_som_pressed(self): + self.Information.modified.clear() if self._optimizer_thread is not None: self.stop_optimization = True self._optimizer.stop_optimization = True @@ -536,7 +700,7 @@ def _redraw(self): self.elements = QGraphicsItemGroup() self.scene.addItem(self.elements) - if self.attr_color is None: + if self.colors is None: self._draw_same_color(sizes) elif self.pie_charts: self._draw_pie_charts(sizes) @@ -562,19 +726,18 @@ def _draw_same_color(self, sizes): self.elements.addToGroup(ellipse) def _get_color_column(self): - color_column = \ - self.data.get_column_view(self.attr_color)[0].astype(float, - copy=False) + # if self.colors is None, we use _draw_same_color and don't call + # this function + assert self.colors is not None + + color_column = self.data.get_column(self.attr_color) if self.attr_color.is_discrete: with np.errstate(invalid="ignore"): int_col = color_column.astype(int) int_col[np.isnan(color_column)] = len(self.colors) else: int_col = np.zeros(len(color_column), dtype=int) - # The following line is unnecessary because rows with missing - # numeric data are excluded. Uncomment it if you change SOM to - # tolerate missing values. - # int_col[np.isnan(color_column)] = len(self.colors) + int_col[np.isnan(color_column)] = len(self.colors) for i, thresh in enumerate(self.thresholds, start=1): int_col[color_column >= thresh] = i return int_col @@ -584,6 +747,7 @@ def _tooltip(self, colors, distribution): values = self.attr_color.values else: values = self._bin_names() + values = list(values) + ["(N/A)"] tot = np.sum(distribution) nbhp = "\N{NON-BREAKING HYPHEN}" return '' + "".join(f""" @@ -600,6 +764,8 @@ def _tooltip(self, colors, distribution): + "
      " def _draw_pie_charts(self, sizes): + assert self.colors is not None # if it were, we'd call _draw_same_color + fx, fy = self._grid_factors color_column = self._get_color_column() colors = self.colors.qcolors_w_nan @@ -619,6 +785,8 @@ def _draw_pie_charts(self, sizes): pie.setPos(x + (y % 2) * fx, y * fy) def _draw_colored_circles(self, sizes): + assert self.colors is not None # if it were, we'd call _draw_same_color + fx, fy = self._grid_factors color_column = self._get_color_column() qcolors = self.colors.qcolors_w_nan @@ -665,7 +833,7 @@ def _recompute_som(self): if self.cont_x is None: return - som = SOM( + self.som = SOM( self.size_x, self.size_y, hexagonal=self.hexagonal, pca_init=self.initialization == 0, @@ -703,8 +871,9 @@ def thread_finished(): self._optimizer_thread = None self.progressBarInit() + self.setInvalidated(True) - self._optimizer = Optimizer(self.cont_x, som) + self._optimizer = Optimizer(self.cont_x, self.som) self._optimizer_thread = QThread() self._optimizer_thread.setStackSize(5 * 2 ** 20) self._optimizer.update.connect(self.__update) @@ -726,6 +895,7 @@ def __update(self, _progress, weights, ssum_weights): def __done(self, som): self.enable_controls(True) self.progressBarFinished() + self.setInvalidated(False) self._assign_instances(som.weights, som.ssum_weights) self._redraw() # This is the first time we know what was selected (assuming that @@ -751,7 +921,7 @@ def onDeleteWidget(self): def _assign_instances(self, weights, ssum_weights): if self.cont_x is None: return # the widget is shutting down while signals still processed - assignments = SOM.winner_from_weights( + assignments, _ = SOM.winner_from_weights( self.cont_x, weights, ssum_weights, self.hexagonal) members = defaultdict(list) for i, (x, y) in enumerate(assignments): @@ -786,62 +956,133 @@ def rescale(self): self.view.setTransform(QTransform.fromScale(scale, scale)) if self.hexagonal: self.view.setSceneRect( - 0, -1, self.size_x - 1, - (self.size_y + leg_extra) * sqrt3_2 + leg_height / scale) + # -1.5: 1 is necessary, 0.5 is to add some border + -0.5, -1.5 / np.sqrt(3), self.size_x, + (self.size_y + leg_extra) * sqrt3_2 + leg_height / scale - 1 / np.sqrt(3)) else: self.view.setSceneRect( -0.25, -0.25, self.size_x - 0.5, self.size_y - 0.5 + leg_height / scale) def update_output(self): - if self.data is None: + if self.som is None: self.Outputs.selected_data.send(None) self.Outputs.annotated_data.send(None) return + ngroups = int(self.selection is not None) and np.max(self.selection) indices = np.zeros(len(self.data), dtype=int) + id_to_group = {} if self.selection is not None and np.any(self.selection): for y in range(self.size_y): for x in range(self.size_x): rows = self.get_member_indices(x, y) - indices[rows] = self.selection[x, y] + group = self.selection[x][y] + indices[rows] = group + if group > 0: + for id_ in self.data.ids[rows]: + id_to_group[id_] = group + + cont_domain = self._cont_domain(self.data) + shared_compute = SomSharedValueCompute( + cont_domain, self.som, self.offsets, self.scales) + cell = DiscreteVariable( + "som_cell", + values=tuple( + f"r{row + 1}c{col + 1}" + for row in range(self.size_y) + for col in range(self.size_x - (self.hexagonal and row % 2))), + compute_value=SomCellCompute(shared_compute, + self.size_x, self.hexagonal)) + coordx = ContinuousVariable( + "som_row", + number_of_decimals=0, + compute_value=SomCoordsCompute(shared_compute, 1)) + coordy = ContinuousVariable( + "som_col", + number_of_decimals=0, + compute_value=SomCoordsCompute(shared_compute, 0)) + error = ContinuousVariable( + "som_error", + compute_value=SomErrorCompute(shared_compute) + ) + som_attrs = (cell, coordx, coordy, error) + + grp_values, _ = group_values( + indices, include_unselected=False, values=None) + + def make_domain(values, default_grp, offset): + grp_var = DiscreteVariable( + get_unique_names(self.data.domain, ANNOTATED_DATA_FEATURE_NAME), + values, + compute_value=GetGroups(id_to_group, default_grp, offset)) + + if not self.data.domain.class_vars: + class_vars, metas = (grp_var,), som_attrs + else: + class_vars, metas = (), (grp_var,) + som_attrs + return add_columns(self.data.domain, (), class_vars, metas) if np.any(indices): - sel_data = create_groups_table(self.data, indices, False, "Group") - self.Outputs.selected_data.send(sel_data) + sel_domain = make_domain(grp_values, np.nan, 1) + mask = np.flatnonzero(indices) + self.Outputs.selected_data.send( + lazy_table_transform(sel_domain, self.data[mask])) else: self.Outputs.selected_data.send(None) - if np.max(indices) > 1: - annotated = create_groups_table(self.data, indices) + if ngroups > 1: + sel_domain = make_domain(grp_values + ["Unselected", ], ngroups, 1) else: - annotated = create_annotated_table( - self.data, np.flatnonzero(indices)) - self.Outputs.annotated_data.send(annotated) + sel_domain = make_domain(("No", "Yes"), 0, 0) + self.Outputs.annotated_data.send( + lazy_table_transform(sel_domain, self.data)) def set_color_bins(self): + self.Warning.no_defined_colors.clear() + if self.attr_color is None: self.thresholds = self.bin_labels = self.colors = None - elif self.attr_color.is_discrete: + return + + if self.attr_color.is_discrete: self.thresholds = self.bin_labels = None self.colors = self.attr_color.palette + return + + col = self.data.get_column(self.attr_color) + col = col[np.isfinite(col)] + if not col.size: + self.Warning.no_defined_colors(self.attr_color) + self.thresholds = self.bin_labels = self.colors = None + return + + if self.attr_color.is_time: + binning = time_binnings(col, min_bins=4)[-1] else: - col = self.data.get_column_view(self.attr_color)[0].astype(float) - if self.attr_color.is_time: - binning = time_binnings(col, min_bins=4)[-1] - else: - binning = decimal_binnings(col, min_bins=4)[-1] - self.thresholds = binning.thresholds[1:-1] - self.bin_labels = (binning.labels[1:-1], binning.short_labels[1:-1]) - palette = BinnedContinuousPalette.from_palette( - self.attr_color.palette, binning.thresholds) - self.colors = palette + binning = decimal_binnings(col, min_bins=4)[-1] + self.thresholds = binning.thresholds[1:-1] + self.bin_labels = (binning.labels[1:-1], binning.short_labels[1:-1]) + if not self.bin_labels[0] and binning.labels: + # Nan's are already filtered out, but it doesn't hurt much + # to use nanmax/nanmin + if np.nanmin(col) == np.nanmax(col): + # Handle a degenerate case with a single value + # Use the second threshold (because value must be smaller), + # but the first threshold as label (because that's the + # actual value in the data. + self.thresholds = binning.thresholds[1:] + self.bin_labels = (binning.labels[:1], + binning.short_labels[:1]) + palette = BinnedContinuousPalette.from_palette( + self.attr_color.palette, binning.thresholds) + self.colors = palette def create_legend(self): if self.legend is not None: self.scene.removeItem(self.legend) self.legend = None - if self.attr_color is None: + if self.colors is None: return if self.attr_color.is_discrete: @@ -870,7 +1111,9 @@ def create_legend(self): self.set_legend_pos() def _bin_names(self): - labels, short_labels = self.bin_labels + labels, short_labels = self.bin_labels or ([], []) + if len(labels) <= 1: + return labels return \ [f"< {labels[0]}"] \ + [f"{x} - {y}" for x, y in zip(labels, short_labels[1:])] \ @@ -885,10 +1128,18 @@ def set_legend_pos(self): def send_report(self): self.report_plot() - if self.attr_color: + if self.colors: self.report_caption( f"Self-organizing map colored by '{self.attr_color.name}'") + @classmethod + def migrate_settings(cls, settings, _): + # previously selection was saved as np.ndarray which is not supported + # by widget-base, change selection to list + selection = settings.get('selection') + if selection is not None and isinstance(selection, np.ndarray): + settings['selection'] = selection.tolist() + def _draw_hexagon(): path = QPainterPath() diff --git a/Orange/widgets/unsupervised/owtsne.py b/Orange/widgets/unsupervised/owtsne.py index bc6b8443947..0e75afc1d44 100644 --- a/Orange/widgets/unsupervised/owtsne.py +++ b/Orange/widgets/unsupervised/owtsne.py @@ -1,121 +1,204 @@ +import numpy as np import warnings +from AnyQt.QtCore import Qt +from AnyQt.QtWidgets import QFormLayout from functools import partial from types import SimpleNamespace as namespace from typing import Optional # pylint: disable=unused-import -import numpy as np - -from AnyQt.QtCore import Qt -from AnyQt.QtWidgets import QFormLayout - from Orange.data import Table, Domain +from Orange.data.util import array_equal, get_unique_names +from Orange.misc import DistMatrix from Orange.preprocess import preprocess from Orange.projection import PCA from Orange.projection import manifold from Orange.widgets import gui -from Orange.widgets.settings import SettingProvider, ContextSetting +from Orange.widgets.settings import SettingProvider, Setting, ContextSetting from Orange.widgets.utils.concurrent import TaskState, ConcurrentWidgetMixin from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase from Orange.widgets.visualize.utils.widget import OWDataProjectionWidget from Orange.widgets.widget import Msg +from orangewidget.utils.signals import Input _STEP_SIZE = 25 _MAX_PCA_COMPONENTS = 50 _DEFAULT_PCA_COMPONENTS = 20 +INITIALIZATIONS = [("PCA", "pca"), ("Spectral", "spectral")] +DISTANCE_METRICS = [("Euclidean", "l2"), ("Manhattan", "l1"), ("Cosine", "cosine")] + class Task(namespace): """Completely determines the t-SNE task spec and intermediate results.""" - data = None # type: Optional[Table] - normalize = None # type: Optional[bool] - pca_components = None # type: Optional[int] - pca_projection = None # type: Optional[Table] - perplexity = None # type: Optional[float] - multiscale = None # type: Optional[bool] - exaggeration = None # type: Optional[float] - initialization = None # type: Optional[np.ndarray] - affinities = None # type: Optional[openTSNE.affinity.Affinities] - tsne_embedding = None # type: Optional[manifold.TSNEModel] - iterations_done = 0 # type: int + data = None # type: Optional[Table] + distance_matrix = None # type: Optional[DistMatrix] + + preprocessed_data = None # type: Optional[Table] + + normalize = None # type: Optional[bool] + normalized_data = None # type: Optional[Table] + + use_pca_preprocessing = None # type: Optional[bool] + pca_components = None # type: Optional[int] + pca_projection = None # type: Optional[Table] + + distance_metric = None # type: Optional[str] + perplexity = None # type: Optional[float] + multiscale = None # type: Optional[bool] + exaggeration = None # type: Optional[float] + initialization_method = None # type: Optional[str] + initialization = None # type: Optional[np.ndarray] + affinities = None # type: Optional[openTSNE.affinity.Affinities] + tsne_embedding = None # type: Optional[manifold.TSNEModel] + iterations_done = 0 # type: int # These attributes need not be set by the widget - tsne = None # type: Optional[manifold.TSNE] + tsne = None # type: Optional[manifold.TSNE] + # `effective_data` stores the current working matrix which should be used + # for any steps depending on the data matrix. For instance, normalization + # should use the effective data (presumably the original data), and set it + # to the normalized version upon completion. This can then later be used for + # PCA preprocessing + effective_data = None # type: Optional[Table] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Set `effective_data` to `data` if provided and effective data is not + # provided + if self.effective_data is None and self.data is not None: + self.effective_data = self.data + + class ValidationError(ValueError): + pass + + def validate(self) -> "Task": + def error(msg): + raise Task.ValidationError(msg) + + if self.data is None and self.distance_matrix is None: + error("Both `distance_matrix` and `data` cannot be `None`") + + if self.distance_matrix is not None: + if self.distance_metric != "precomputed": + error( + "`distance_metric` must be set to `precomputed` when using " + "a distance matrix" + ) + if self.initialization_method != "spectral": + error( + "`initialization_method` must be set to `spectral` when " + "using a distance matrix" + ) + if self.distance_matrix is None: + if self.distance_metric == "precomputed": + error( + "`distance_metric` cannot be set to `precomputed` when no " + "distance matrix is provided" + ) + + if self.data is not None and self.data.is_sparse(): + if self.normalize: + error("Data normalization is not supported for sparse data") + + return self -def pca_preprocessing(data, n_components, normalize): - projector = PCA(n_components=n_components, random_state=0) - if normalize: - projector.preprocessors += (preprocess.Normalize(),) +def apply_tsne_preprocessing(tsne, data): + return tsne.preprocess(data) + + +def data_normalization(data): + normalization = preprocess.Normalize() + return normalization(data) + + +def pca_preprocessing(data, n_components): + projector = PCA(n_components=n_components, random_state=0) model = projector(data) return model(data) -def prepare_tsne_obj(data, perplexity, multiscale, exaggeration): - # type: (Table, float, bool, float) -> manifold.TSNE +def prepare_tsne_obj(n_samples: int, initialization_method: str, + distance_metric: str, perplexity: float, + multiscale: bool, exaggeration: float): """Automatically determine the best parameters for the given data set.""" # Compute perplexity settings for multiscale - n_samples = data.X.shape[0] if multiscale: perplexity = min((n_samples - 1) / 3, 50), min((n_samples - 1) / 3, 500) else: perplexity = perplexity - # Determine whether to use settings for large data sets - if n_samples > 10_000: - neighbor_method, gradient_method = "approx", "fft" - else: - neighbor_method, gradient_method = "exact", "bh" - - # Larger data sets need a larger number of iterations - if n_samples > 100_000: - early_exagg_iter, n_iter = 500, 1000 - else: - early_exagg_iter, n_iter = 250, 750 - return manifold.TSNE( n_components=2, + initialization=initialization_method, + metric=distance_metric, perplexity=perplexity, multiscale=multiscale, - early_exaggeration_iter=early_exagg_iter, - n_iter=n_iter, exaggeration=exaggeration, - neighbors=neighbor_method, - negative_gradient_method=gradient_method, - theta=0.8, random_state=0, ) class TSNERunner: @staticmethod - def compute_pca(task, state, **_): + def compute_tsne_preprocessing(task: Task, state: TaskState, **_) -> None: + state.set_status("Preprocessing data...") + task.preprocessed_data = apply_tsne_preprocessing(task.tsne, task.effective_data) + task.effective_data = task.preprocessed_data + state.set_partial_result(("preprocessed_data", task)) + + @staticmethod + def compute_normalization(task: Task, state: TaskState, **_) -> None: + state.set_status("Normalizing data...") + task.normalized_data = data_normalization(task.effective_data) + task.effective_data = task.normalized_data + state.set_partial_result(("normalized_data", task)) + + @staticmethod + def compute_pca(task: Task, state: TaskState, **_) -> None: # Perform PCA preprocessing state.set_status("Computing PCA...") - pca_projection = pca_preprocessing( - task.data, task.pca_components, task.normalize - ) + pca_projection = pca_preprocessing(task.effective_data, task.pca_components) # Apply t-SNE's preprocessors to the data task.pca_projection = task.tsne.preprocess(pca_projection) + task.effective_data = task.pca_projection state.set_partial_result(("pca_projection", task)) @staticmethod - def compute_initialization(task, state, **_): + def compute_initialization(task: Task, state: TaskState, **_) -> None: # Prepare initial positions for t-SNE state.set_status("Preparing initialization...") - task.initialization = task.tsne.compute_initialization(task.pca_projection.X) + if task.initialization_method == "pca": + x = task.effective_data.X + elif task.initialization_method == "spectral": + assert task.affinities is not None + x = task.affinities.P + else: + raise RuntimeError( + f"Unrecognized initialization scheme `{task.initialization_method}`!" + ) + task.initialization = task.tsne.compute_initialization(x) state.set_partial_result(("initialization", task)) @staticmethod - def compute_affinities(task, state, **_): - # Compute affinities + def compute_affinities(task: Task, state: TaskState, **_) -> None: state.set_status("Finding nearest neighbors...") - task.affinities = task.tsne.compute_affinities(task.pca_projection.X) + + if task.distance_metric == "precomputed": + assert task.distance_matrix is not None + x = task.distance_matrix + else: + assert task.data is not None + assert task.effective_data is not None + x = task.effective_data.X + + task.affinities = task.tsne.compute_affinities(x) state.set_partial_result(("affinities", task)) @staticmethod - def compute_tsne(task, state, progress_callback=None): + def compute_tsne(task: Task, state: TaskState, progress_callback=None) -> None: tsne = task.tsne state.set_status("Running optimization...") @@ -127,8 +210,15 @@ def compute_tsne(task, state, progress_callback=None): task.tsne_embedding = tsne.prepare_embedding( task.affinities, task.initialization ) + + if task.distance_metric == "precomputed": + x = task.distance_matrix + else: + assert task.effective_data is not None + x = task.effective_data + task.tsne_embedding = tsne.convert_embedding_to_model( - task.pca_projection, task.tsne_embedding + x, task.tsne_embedding ) state.set_partial_result(("tsne_embedding", task)) @@ -138,8 +228,8 @@ def compute_tsne(task, state, progress_callback=None): total_iterations_needed = tsne.early_exaggeration_iter + tsne.n_iter def run_optimization(tsne_params: dict, iterations_needed: int) -> bool: - """Run t-SNE optimization phase. Return value indicates whether or - not the optimization was interrupted.""" + """Run t-SNE optimization phase. Return value indicates whether the + optimization was interrupted.""" while task.iterations_done < iterations_needed: # Step size can't be larger than the remaining number of iterations step_size = min(_STEP_SIZE, iterations_needed - task.iterations_done) @@ -159,7 +249,7 @@ def run_optimization(tsne_params: dict, iterations_needed: int) -> bool: # Run early exaggeration phase was_interrupted = run_optimization( - dict(exaggeration=tsne.early_exaggeration, momentum=0.5, inplace=False), + dict(exaggeration=tsne.early_exaggeration, momentum=0.8, inplace=False), iterations_needed=tsne.early_exaggeration_iter, ) if was_interrupted: @@ -171,35 +261,64 @@ def run_optimization(tsne_params: dict, iterations_needed: int) -> bool: ) @classmethod - def run(cls, task, state): - # type: (Task, TaskState) -> Task + def run(cls, task: Task, state: TaskState) -> Task: + task.validate() # Assign weights to each job indicating how much time will be spent on each - weights = {"pca": 1, "init": 1, "aff": 23, "tsne": 75} + weights = {"preprocessing": 1, "normalization": 1, "pca": 1, "init": 1, "aff": 25, "tsne": 50} total_weight = sum(weights.values()) # Prepare the tsne object and add it to the spec + if task.distance_matrix is not None: + n_samples = task.distance_matrix.shape[0] + else: + assert task.data is not None + n_samples = task.data.X.shape[0] + task.tsne = prepare_tsne_obj( - task.data, task.perplexity, task.multiscale, task.exaggeration + n_samples, + task.initialization_method, + task.distance_metric, + task.perplexity, + task.multiscale, + task.exaggeration, ) job_queue = [] # Add the tasks that still need to be run to the job queue - if task.pca_projection is None: - job_queue.append((cls.compute_pca, weights["pca"])) + if task.distance_metric != "precomputed": + task.effective_data = task.data + if task.preprocessed_data is None: + job_queue.append((cls.compute_tsne_preprocessing, weights["preprocessing"])) - if task.initialization is None: - job_queue.append((cls.compute_initialization, weights["init"])) + if task.normalize and task.normalized_data is None: + job_queue.append((cls.compute_normalization, weights["normalization"])) + + if task.use_pca_preprocessing and task.pca_projection is None: + job_queue.append((cls.compute_pca, weights["pca"])) if task.affinities is None: job_queue.append((cls.compute_affinities, weights["aff"])) + if task.initialization is None: + job_queue.append((cls.compute_initialization, weights["init"])) + total_iterations = task.tsne.early_exaggeration_iter + task.tsne.n_iter if task.tsne_embedding is None or task.iterations_done < total_iterations: job_queue.append((cls.compute_tsne, weights["tsne"])) job_queue = [(partial(f, task, state), w) for f, w in job_queue] + # Ensure the effective data is set to the appropriate, potentially + # precomputed matrix + task.effective_data = task.data + if task.preprocessed_data is not None: + task.effective_data = task.preprocessed_data + if task.normalize and task.normalized_data is not None: + task.effective_data = task.normalized_data + if task.use_pca_preprocessing and task.pca_projection is not None: + task.effective_data = task.pca_projection + # Figure out the total weight of the jobs job_weight = sum(j[1] for j in job_queue) progress_done = total_weight - job_weight @@ -224,6 +343,8 @@ def _progress_callback(val): class OWtSNEGraph(OWScatterPlotBase): + aggregate_dense_regions = Setting(True) + def update_coordinates(self): super().update_coordinates() if self.scatterplot_item is not None: @@ -232,116 +353,242 @@ def update_coordinates(self): class invalidated: # pylint: disable=invalid-name - pca_projection = affinities = tsne_embedding = False + preprocessed_data = normalized_data = pca_projection = initialization = \ + affinities = tsne_embedding = False def __set__(self, instance, value): # `self._invalidate = True` should invalidate everything - self.pca_projection = self.affinities = self.tsne_embedding = value + self.preprocessed_data = value + self.normalized_data = value + self.pca_projection = value + self.initialization = value + self.affinities = value + self.tsne_embedding = value def __bool__(self): # If any of the values are invalidated, this should return true - return self.pca_projection or self.affinities or self.tsne_embedding + return ( + self.preprocessed_data or self.normalized_data or self.pca_projection or + self.initialization or self.affinities or self.tsne_embedding + ) def __str__(self): return "%s(%s)" % (self.__class__.__name__, ", ".join( "=".join([k, str(getattr(self, k))]) - for k in ["pca_projection", "affinities", "tsne_embedding"] + for k in ["preprocessed_data", "normalized_data", "pca_projection", + "initialization", "affinities", "tsne_embedding"] )) class OWtSNE(OWDataProjectionWidget, ConcurrentWidgetMixin): name = "t-SNE" description = "Two-dimensional data projection with t-SNE." - icon = "icons/TSNE.svg" + icon = "icons/TSNE-symbolic.svg" priority = 920 - keywords = ["tsne"] + keywords = "t-sne, tsne" settings_version = 4 perplexity = ContextSetting(30) multiscale = ContextSetting(False) exaggeration = ContextSetting(1) - pca_components = ContextSetting(_DEFAULT_PCA_COMPONENTS) + initialization_method_idx = ContextSetting(0) + distance_metric_idx = ContextSetting(0) + normalize = ContextSetting(True) + use_pca_preprocessing = ContextSetting(True) + pca_components = ContextSetting(_DEFAULT_PCA_COMPONENTS) GRAPH_CLASS = OWtSNEGraph graph = SettingProvider(OWtSNEGraph) embedding_variables_names = ("t-SNE-x", "t-SNE-y") - # Use `invalidated` descriptor so we don't break the usage of + # Use `invalidated` descriptor, so we don't break the usage of # `_invalidated` in `OWDataProjectionWidget`, but still allow finer control # over which parts of the embedding to invalidate _invalidated = invalidated() + class Inputs(OWDataProjectionWidget.Inputs): + distances = Input("Distances", DistMatrix) + class Information(OWDataProjectionWidget.Information): modified = Msg("The parameter settings have been changed. Press " "\"Start\" to rerun with the new settings.") + class Warning(OWDataProjectionWidget.Warning): + consider_using_pca_preprocessing = Msg( + "The input data contains a large number of features, which may slow" + " down t-SNE computation. Consider enabling PCA preprocessing." + ) + class Error(OWDataProjectionWidget.Error): not_enough_rows = Msg("Input data needs at least 2 rows") not_enough_cols = Msg("Input data needs at least 2 attributes") constant_data = Msg("Input data is constant") no_valid_data = Msg("No projection due to no valid data") + distance_matrix_not_symmetric = Msg("Distance matrix is not symmetric") + distance_matrix_too_small = Msg("Input matrix must be at least 2x2") + + dimension_mismatch = Msg("Data and distance dimensions do not match") + def __init__(self): OWDataProjectionWidget.__init__(self) ConcurrentWidgetMixin.__init__(self) - self.pca_projection = None # type: Optional[Table] - self.initialization = None # type: Optional[np.ndarray] - self.affinities = None # type: Optional[openTSNE.affinity.Affinities] - self.tsne_embedding = None # type: Optional[manifold.TSNEModel] - self.iterations_done = 0 # type: int + + # Distance matrix from `Distances` signal + self.distance_matrix = None # type: Optional[DistMatrix] + # Data table from the `self.matrix.row_items` (if present) + self.distance_matrix_data = None # type: Optional[Table] + # Data table from `Data` signal + self.signal_data = None # type: Optional[Table] + + # Intermediate results + self.preprocessed_data = None # type: Optional[Table] + self.normalized_data = None # type: Optional[Table] + self.pca_projection = None # type: Optional[Table] + self.initialization = None # type: Optional[np.ndarray] + self.affinities = None # type: Optional[openTSNE.affinity.Affinities] + self.tsne_embedding = None # type: Optional[manifold.TSNEModel] + self.iterations_done = 0 # type: int + + @property + def normalize_(self): + should_normalize = self.normalize + if self.distance_matrix is not None: + should_normalize = False + if self.data is not None: + if self.data.is_sparse(): + should_normalize = False + return should_normalize + + @property + def use_pca_preprocessing_(self): + should_use_pca_preprocessing = self.use_pca_preprocessing + if self.distance_matrix is not None: + should_use_pca_preprocessing = False + return should_use_pca_preprocessing + + @property + def effective_data(self): + return self.data.transform(Domain(self.effective_variables)) def _add_controls(self): self._add_controls_start_box() super()._add_controls() def _add_controls_start_box(self): - box = gui.vBox(self.controlArea, box="Optimize") + self.preprocessing_box = gui.vBox(self.controlArea, box="Preprocessing") + self.normalize_cbx = gui.checkBox( + self.preprocessing_box, self, "normalize", "Normalize data", + callback=self._normalize_data_changed, stateWhenDisabled=False, + ) + self.pca_preprocessing_cbx = gui.checkBox( + self.preprocessing_box, self, "use_pca_preprocessing", "Apply PCA preprocessing", + callback=self._pca_preprocessing_changed, stateWhenDisabled=False, + ) + self.pca_component_slider = gui.hSlider( + self.preprocessing_box, self, "pca_components", label="PCA Components:", + minValue=2, maxValue=_MAX_PCA_COMPONENTS, step=1, + callback=self._pca_slider_changed, + ) + + self.parameter_box = gui.vBox(self.controlArea, box="Parameters") form = QFormLayout( labelAlignment=Qt.AlignLeft, formAlignment=Qt.AlignLeft, fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow, ) + self.initialization_combo = gui.comboBox( + self.controlArea, self, "initialization_method_idx", + items=[m[0] for m in INITIALIZATIONS], + callback=self._invalidate_initialization, + ) + form.addRow("Initialization:", self.initialization_combo) + + self.distance_metric_combo = gui.comboBox( + self.controlArea, self, "distance_metric_idx", + items=[m[0] for m in DISTANCE_METRICS], + callback=self._invalidate_affinities, + ) + form.addRow("Distance metric:", self.distance_metric_combo) + self.perplexity_spin = gui.spin( - box, self, "perplexity", 1, 500, step=1, alignment=Qt.AlignRight, - callback=self._invalidate_affinities, addToLayout=False + self.controlArea, self, "perplexity", 1, 500, step=1, + alignment=Qt.AlignRight, addToLayout=False, + callback=self._invalidate_affinities, ) - self.controls.perplexity.setDisabled(self.multiscale) form.addRow("Perplexity:", self.perplexity_spin) + form.addRow(gui.checkBox( - box, self, "multiscale", label="Preserve global structure", + self.controlArea, self, "multiscale", label="Preserve global structure", callback=self._multiscale_changed, addToLayout=False )) sbe = gui.hBox(self.controlArea, False, addToLayout=False) gui.hSlider( - sbe, self, "exaggeration", minValue=1, maxValue=4, step=1, + sbe, self, "exaggeration", minValue=1, maxValue=4, step=0.25, + intOnly=False, labelFormat="%.2f", callback=self._invalidate_tsne_embedding, ) form.addRow("Exaggeration:", sbe) - sbp = gui.hBox(self.controlArea, False, addToLayout=False) - gui.hSlider( - sbp, self, "pca_components", minValue=2, maxValue=_MAX_PCA_COMPONENTS, - step=1, callback=self._invalidate_pca_projection, - ) - form.addRow("PCA components:", sbp) + self.parameter_box.layout().addLayout(form) - self.normalize_cbx = gui.checkBox( - box, self, "normalize", "Normalize data", - callback=self._invalidate_pca_projection, addToLayout=False + self.run_button = gui.button( + self.parameter_box, self, "Start", callback=self._toggle_run ) - form.addRow(self.normalize_cbx) - - box.layout().addLayout(form) - self.run_button = gui.button(box, self, "Start", callback=self._toggle_run) + # GUI control callbacks + def _normalize_data_changed(self): + # We only care about the normalization checkbox if there is no distance + # matrix provided and if the data are not sparse. This is not user- + # settable anyway, but is triggered when we programmatically + # enable/disable the checkbox in`enable_controls` + if self.distance_matrix is None and not self.data.is_sparse(): + self._invalidate_normalized_data() + + def _pca_preprocessing_changed(self): + # We only care about the PCA checkbox if there is no distance + # matrix provided. This is not user-settable anyway, but is triggered + # when we programmatically enable/disable the checkbox in + # `enable_controls` + if self.distance_matrix is None: + self.controls.pca_components.box.setEnabled(self.use_pca_preprocessing) + + self._invalidate_pca_projection() + + should_warn_pca = False + if self.data is not None and not self.use_pca_preprocessing: + if len(self.data.domain.attributes) >= _MAX_PCA_COMPONENTS: + should_warn_pca = True + self.Warning.consider_using_pca_preprocessing(shown=should_warn_pca) + + def _pca_slider_changed(self): + # We only care about the PCA slider if there is no distance + # matrix provided. This is not user-settable anyway, but is triggered + # when we programmatically enable/disable the checkbox in + # `enable_controls` + if self.distance_matrix is None: + self._invalidate_pca_projection() def _multiscale_changed(self): + form = self.parameter_box.layout().itemAt(0) + assert isinstance(form, QFormLayout) + form.labelForField(self.perplexity_spin).setDisabled(self.multiscale) self.controls.perplexity.setDisabled(self.multiscale) + self._invalidate_affinities() + # Invalidation cascade + def _invalidate_preprocessed_data(self): + self._invalidated.preprocessed_data = True + self._invalidate_normalized_data() + + def _invalidate_normalized_data(self): + self._invalidated.normalized_data = True + self._invalidate_pca_projection() + def _invalidate_pca_projection(self): self._invalidated.pca_projection = True self._invalidate_affinities() @@ -350,6 +597,10 @@ def _invalidate_affinities(self): self._invalidated.affinities = True self._invalidate_tsne_embedding() + def _invalidate_initialization(self): + self._invalidated.initialization = True + self._invalidate_tsne_embedding() + def _invalidate_tsne_embedding(self): self._invalidated.tsne_embedding = True self._stop_running_task() @@ -367,21 +618,30 @@ def _set_modified(self, state): self.Information.modified(shown=state) def check_data(self): + self.Error.dimension_mismatch.clear() + self.Error.not_enough_rows.clear() + self.Error.not_enough_cols.clear() + self.Error.no_valid_data.clear() + self.Error.constant_data.clear() + + if self.data is None: + return + def error(err): err() self.data = None - # `super().check_data()` clears all messages so we have to remember if - # it was shown - # pylint: disable=assignment-from-no-return - should_show_modified_message = self.Information.modified.is_shown() - super().check_data() + if ( + self.data is not None and self.distance_matrix is not None and + len(self.data) != len(self.distance_matrix) + ): + error(self.Error.dimension_mismatch) - if self.data is None: + # The errors below are relevant only if the distance matrix is not + # provided + if self.distance_matrix is not None: return - self.Information.modified(shown=should_show_modified_message) - if len(self.data) < 2: error(self.Error.not_enough_rows) @@ -393,12 +653,28 @@ def error(err): error(self.Error.no_valid_data) else: with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", "Degrees of freedom .*", RuntimeWarning) - if np.nan_to_num(np.nanstd(self.data.X, axis=0)).sum() \ - == 0: + warnings.filterwarnings("ignore", "Degrees of freedom .*", RuntimeWarning) + if np.nan_to_num(np.nanstd(self.data.X, axis=0)).sum() == 0: error(self.Error.constant_data) + def check_distance_matrix(self): + self.Error.distance_matrix_not_symmetric.clear() + self.Error.distance_matrix_too_small.clear() + + if self.distance_matrix is None: + return + + def error(err): + err() + self.distance_matrix = self.distance_matrix_data = None + + # Check for matrix validity + if self.distance_matrix is not None: + if not self.distance_matrix.is_symmetric(): + error(self.Error.distance_matrix_not_symmetric) + elif len(self.distance_matrix) < 2: + error(self.Error.distance_matrix_too_small) + def get_embedding(self): if self.tsne_embedding is None: self.valid_data = None @@ -408,6 +684,14 @@ def get_embedding(self): self.valid_data = np.ones(len(embedding), dtype=bool) return embedding + def _get_projection_variables(self): + if self.tsne_embedding is None: + return super()._get_projection_variables() + proposed = [a.name for a in self.tsne_embedding.domain.attributes] + names = get_unique_names(self.data.domain, proposed) + return tuple(a.copy(name=n) for a, n in + zip(self.tsne_embedding.domain.attributes, names)) + def _toggle_run(self): # If no data, there's nothing to do if self.data is None: @@ -417,12 +701,94 @@ def _toggle_run(self): if self.task is not None: self.cancel() self.run_button.setText("Resume") - self.commit() + self.commit.deferred() # Resume task else: self.run() + @Inputs.data + def set_data(self, data): + self.signal_data = data + # Data checking will be performed in `handleNewSignals` since the data + # can also be set from the `distance_matrix.row_items` if no additional + # data is provided + + @Inputs.distances + def set_distances(self, matrix: DistMatrix): + had_distance_matrix = self.distance_matrix is not None + prev_distance_matrix = self.distance_matrix + + self.distance_matrix = matrix + self.distance_matrix_data = matrix.row_items if matrix is not None else None + self.check_distance_matrix() + + # If there was no distance matrix before, but there is data now, invalidate + if self.distance_matrix is not None and not had_distance_matrix: + self._invalidated = True + + # If the new distance matrix is invalid or None, invalidate + elif self.distance_matrix is None and had_distance_matrix: + self._invalidated = True + + # If the distance matrix has changed, invalidate + elif ( + had_distance_matrix and self.distance_matrix is not None and + not array_equal(prev_distance_matrix, self.distance_matrix) + ): + self._invalidated = True + def handleNewSignals(self): + had_data = self.data is not None + prev_data = self.effective_data if had_data else None + + self.cancel() # clear any running jobs + self.data = None + self.closeContext() + + if self.signal_data is not None: + self.data = self.signal_data + elif self.distance_matrix_data is not None: + self.data = self.distance_matrix_data + + self.check_data() + + # If we have any errors, there's something wrong with the inputs or + # their combination, so we clear the graph and the outputs + if len(self.Error.active) > 0: + self.clear() + self._invalidated = True + # Set data to None so that the output signal will be cleared + self.data = None + self.init_attr_values() + self.commit.now() + return + + # We only invalidate based on data if there is no distance matrix, as + # otherwise, the embedding will remain in-tact + if self.distance_matrix is None: + # If there was no data before, but there is data now, invalidate + if self.data is not None and not had_data: + self._invalidated = True + + # If the new data is invalid or None, invalidate + elif self.data is None and had_data: + self._invalidated = True + + # If the data table has changed, invalidate + elif ( + had_data and self.data is not None and + not array_equal(prev_data.X, self.effective_data.X) + ): + self._invalidated = True + + self.init_attr_values() + self.openContext(self.data) + self.enable_controls() + + if self._invalidated: + self.clear() + self.input_changed.emit(self.data) + # We don't bother with the granular invalidation flags because # `super().handleNewSignals` will just set all of them to False or will # do nothing. However, it's important we remember its state because we @@ -441,41 +807,104 @@ def init_attr_values(self): if self.data is not None: n_attrs = len(self.data.domain.attributes) max_components = min(_MAX_PCA_COMPONENTS, n_attrs) + should_use_pca = len(self.data.domain.attributes) > 10 else: max_components = _MAX_PCA_COMPONENTS + should_use_pca = False - # We set this to the default number of components here so it resets + # We set this to the default number of components here, so it resets # properly, any previous settings will be restored from context # settings a little later self.controls.pca_components.setMaximum(max_components) self.controls.pca_components.setValue(_DEFAULT_PCA_COMPONENTS) self.exaggeration = 1 + self.normalize = True + self.use_pca_preprocessing = should_use_pca + self.distance_metric_idx = 0 + self.initialization_method_idx = 0 def enable_controls(self): super().enable_controls() - if self.data is not None: - # PCA doesn't support normalization on sparse data, as this would - # require centering and normalizing the matrix - self.normalize_cbx.setDisabled(self.data.is_sparse()) - if self.data.is_sparse(): - self.normalize = False - self.normalize_cbx.setToolTip( - "Data normalization is not supported on sparse matrices." - ) - else: - self.normalize_cbx.setToolTip("") + has_distance_matrix = self.distance_matrix is not None + has_data = self.data is not None + + # When we disable controls in the form layout, we also want to ensure + # the labels are disabled, to be consistent with the preprocessing box + form = self.parameter_box.layout().itemAt(0) + assert isinstance(form, QFormLayout) + + # Reset all tooltips and controls + self.normalize_cbx.setDisabled(False) + self.normalize_cbx.setToolTip("") + + self.pca_preprocessing_cbx.setDisabled(False) + self.pca_preprocessing_cbx.setToolTip("") + + self.initialization_combo.setDisabled(False) + self.initialization_combo.setToolTip("") + form.labelForField(self.initialization_combo).setDisabled(False) + + self.distance_metric_combo.setDisabled(False) + self.distance_metric_combo.setToolTip("") + form.labelForField(self.distance_metric_combo).setDisabled(False) + + if has_distance_matrix: + self.normalize_cbx.setDisabled(True) + self.normalize_cbx.setToolTip( + "Precomputed distances provided. Preprocessing is unnecessary!" + ) + + self.pca_preprocessing_cbx.setDisabled(True) + self.pca_preprocessing_cbx.setToolTip( + "Precomputed distances provided. Preprocessing is unnecessary!" + ) + + # Only spectral init is valid with a precomputed distance matrix + spectral_init_idx = self.initialization_combo.findText("Spectral") + self.initialization_combo.setCurrentIndex(spectral_init_idx) + self.initialization_combo.setDisabled(True) + self.initialization_combo.setToolTip( + "Only spectral intialization is supported with precomputed " + "distance matrices." + ) + form.labelForField(self.initialization_combo).setDisabled(True) + + self.distance_metric_combo.setDisabled(True) + self.distance_metric_combo.setCurrentIndex(-1) + self.distance_metric_combo.setToolTip( + "Precomputed distances provided." + ) + form.labelForField(self.distance_metric_combo).setDisabled(True) + + # Normalization isn't supported on sparse data, as this would + # require centering and normalizing the matrix + if not has_distance_matrix and has_data and self.data.is_sparse(): + self.normalize_cbx.setDisabled(True) + self.normalize_cbx.setToolTip( + "Data normalization is not supported on sparse matrices." + ) + + # Disable slider parent, because we want to disable the labels too + self.pca_component_slider.parent().setEnabled(self.use_pca_preprocessing_) # Disable the perplexity spin box if multiscale is turned on - self.controls.perplexity.setDisabled(self.multiscale) + self.perplexity_spin.setDisabled(self.multiscale) + form.labelForField(self.perplexity_spin).setDisabled(self.multiscale) def run(self): # Reset invalidated values as indicated by the flags + if self._invalidated.preprocessed_data: + self.preprocessed_data = None + if self._invalidated.normalized_data: + self.normalized_data = None if self._invalidated.pca_projection: self.pca_projection = None if self._invalidated.affinities: self.affinities = None + if self._invalidated.initialization: + self.initialization = None if self._invalidated.tsne_embedding: self.iterations_done = 0 self.tsne_embedding = None @@ -491,60 +920,124 @@ def run(self): # Cancel current running task self.cancel() - if self.data is None: + if self.data is None and self.distance_matrix is None: return + initialization_method = INITIALIZATIONS[self.initialization_method_idx][1] + distance_metric = DISTANCE_METRICS[self.distance_metric_idx][1] + if self.distance_matrix is not None: + distance_metric = "precomputed" + initialization_method = "spectral" + task = Task( data=self.data, - normalize=self.normalize, + distance_matrix=self.distance_matrix, + # Preprocessed data + preprocessed_data=self.preprocessed_data, + # Normalization + normalize=self.normalize_, + normalized_data=self.normalized_data, + # PCA preprocessing + use_pca_preprocessing=self.use_pca_preprocessing_, pca_components=self.pca_components, pca_projection=self.pca_projection, + # t-SNE parameters + initialization_method=initialization_method, + initialization=self.initialization, + distance_metric=distance_metric, perplexity=self.perplexity, multiscale=self.multiscale, exaggeration=self.exaggeration, - initialization=self.initialization, affinities=self.affinities, + # Misc tsne_embedding=self.tsne_embedding, iterations_done=self.iterations_done, ) return self.start(TSNERunner.run, task) + def __ensure_task_same_for_preprocessing(self, task: Task): + if task.distance_metric != "precomputed": + assert task.data is self.data + assert isinstance(task.preprocessed_data, Table) and \ + len(task.preprocessed_data) == len(self.data) + + def __ensure_task_same_for_normalization(self, task: Task): + assert task.normalize == self.normalize_ + if task.normalize and task.distance_metric != "precomputed": + assert task.data is self.data + assert isinstance(task.normalized_data, Table) and \ + len(task.normalized_data) == len(self.data) + def __ensure_task_same_for_pca(self, task: Task): - assert self.data is not None - assert task.normalize == self.normalize - assert task.pca_components == self.pca_components - assert isinstance(task.pca_projection, Table) and \ - len(task.pca_projection) == len(self.data) + assert task.use_pca_preprocessing == self.use_pca_preprocessing_ + if task.use_pca_preprocessing and task.distance_metric != "precomputed": + assert task.data is self.data + assert task.pca_components == self.pca_components + assert isinstance(task.pca_projection, Table) and \ + len(task.pca_projection) == len(self.data) def __ensure_task_same_for_initialization(self, task: Task): + if self.distance_matrix is not None: + n_samples = self.distance_matrix.shape[0] + else: + initialization_method = INITIALIZATIONS[self.initialization_method_idx][1] + # If distance matrix is provided, the control value will be set to + # whatever it was from the context, but we will use `spectral` + assert task.initialization_method == initialization_method + assert self.data is not None + n_samples = self.data.X.shape[0] assert isinstance(task.initialization, np.ndarray) and \ - len(task.initialization) == len(self.data) + len(task.initialization) == n_samples def __ensure_task_same_for_affinities(self, task: Task): assert task.perplexity == self.perplexity assert task.multiscale == self.multiscale + distance_metric = DISTANCE_METRICS[self.distance_metric_idx][1] + # Precomputed distances will never match the combo box value + if task.distance_metric != "precomputed": + assert task.distance_metric == distance_metric def __ensure_task_same_for_embedding(self, task: Task): assert task.exaggeration == self.exaggeration + if self.distance_matrix is not None: + n_samples = self.distance_matrix.shape[0] + else: + assert self.data is not None + n_samples = self.data.X.shape[0] assert isinstance(task.tsne_embedding, manifold.TSNEModel) and \ - len(task.tsne_embedding.embedding) == len(self.data) + len(task.tsne_embedding.embedding) == n_samples def on_partial_result(self, value): # type: (Tuple[str, Task]) -> None which, task = value - if which == "pca_projection": + if which == "preprocessed_data": + self.__ensure_task_same_for_preprocessing(task) + self.preprocessed_data = task.preprocessed_data + elif which == "normalized_data": + self.__ensure_task_same_for_preprocessing(task) + self.__ensure_task_same_for_normalization(task) + self.normalized_data = task.normalized_data + elif which == "pca_projection": + self.__ensure_task_same_for_preprocessing(task) + self.__ensure_task_same_for_normalization(task) self.__ensure_task_same_for_pca(task) self.pca_projection = task.pca_projection elif which == "initialization": + self.__ensure_task_same_for_preprocessing(task) + self.__ensure_task_same_for_normalization(task) self.__ensure_task_same_for_pca(task) self.__ensure_task_same_for_initialization(task) self.initialization = task.initialization elif which == "affinities": + self.__ensure_task_same_for_preprocessing(task) + self.__ensure_task_same_for_normalization(task) self.__ensure_task_same_for_pca(task) self.__ensure_task_same_for_affinities(task) self.affinities = task.affinities elif which == "tsne_embedding": + self.__ensure_task_same_for_preprocessing(task) + self.__ensure_task_same_for_normalization(task) self.__ensure_task_same_for_pca(task) self.__ensure_task_same_for_initialization(task) self.__ensure_task_same_for_affinities(task) @@ -553,7 +1046,7 @@ def on_partial_result(self, value): prev_embedding, self.tsne_embedding = self.tsne_embedding, task.tsne_embedding self.iterations_done = task.iterations_done # If this is the first partial result we've gotten, we've got to - # setup the plot + # set up the plot if prev_embedding is None: self.setup_plot() # Otherwise, just update the point positions @@ -569,7 +1062,13 @@ def on_done(self, task): # type: (Task) -> None self.run_button.setText("Start") # NOTE: All of these have already been set by on_partial_result, - # we double check that they are aliases + # we double-check that they are aliases + if task.preprocessed_data is not None: + self.__ensure_task_same_for_preprocessing(task) + assert task.preprocessed_data is self.preprocessed_data + if task.normalized_data is not None: + self.__ensure_task_same_for_normalization(task) + assert task.normalized_data is self.normalized_data if task.pca_projection is not None: self.__ensure_task_same_for_pca(task) assert task.pca_projection is self.pca_projection @@ -582,33 +1081,18 @@ def on_done(self, task): self.__ensure_task_same_for_embedding(task) assert task.tsne_embedding is self.tsne_embedding - self.commit() + self.commit.deferred() - def _get_projection_data(self): - if self.data is None: - return None - - data = self.data.transform( - Domain( - self.data.domain.attributes, - self.data.domain.class_vars, - self.data.domain.metas + self._get_projection_variables() - ) - ) - data.metas[:, -2:] = self.get_embedding() - if self.tsne_embedding is not None: - data.domain = Domain( - self.data.domain.attributes, - self.data.domain.class_vars, - self.data.domain.metas + self.tsne_embedding.domain.attributes, - ) - return data + def cancel(self): + self.run_button.setText("Start") + super().cancel() def clear(self): """Clear widget state. Note that this doesn't clear the data.""" super().clear() - self.run_button.setText("Start") self.cancel() + self.preprocessed_data = None + self.normalized_data = None self.pca_projection = None self.initialization = None self.affinities = None @@ -642,7 +1126,9 @@ def migrate_context(cls, context, version): if __name__ == "__main__": import sys data = Table(sys.argv[1] if len(sys.argv) > 1 else "iris") + from Orange.distance import Euclidean + dist_matrix = Euclidean(data, normalize=True) WidgetPreview(OWtSNE).run( - set_data=data, + set_distances=dist_matrix, set_subset_data=data[np.random.choice(len(data), 10)], ) diff --git a/Orange/widgets/unsupervised/tests/test_owcorrespondence.py b/Orange/widgets/unsupervised/tests/test_owcorrespondence.py index bf61e1ed040..93775564c8a 100644 --- a/Orange/widgets/unsupervised/tests/test_owcorrespondence.py +++ b/Orange/widgets/unsupervised/tests/test_owcorrespondence.py @@ -84,7 +84,7 @@ def test_outputs(self): self.assertTupleEqual(self.get_output(w.Outputs.coordinates).X.shape, (6, 2)) select_rows(w.varview, [0, 1, 2]) - w.commit() + w.commit.now() self.assertTupleEqual(self.get_output(w.Outputs.coordinates).X.shape, (8, 8)) self.send_signal(self.widget.Inputs.data, None) diff --git a/Orange/widgets/unsupervised/tests/test_owdbscan.py b/Orange/widgets/unsupervised/tests/test_owdbscan.py index abbf6fcd986..532feb1805c 100644 --- a/Orange/widgets/unsupervised/tests/test_owdbscan.py +++ b/Orange/widgets/unsupervised/tests/test_owdbscan.py @@ -4,9 +4,8 @@ import numpy as np from scipy.sparse import csr_matrix, csc_matrix -from Orange.data import Table +from Orange.data import Table, Domain from Orange.clustering import DBSCAN -from Orange.distance import Euclidean from Orange.preprocess import Normalize, Continuize, SklImpute from Orange.widgets.tests.base import WidgetTest from Orange.widgets.tests.utils import simulate, possible_duplicate_table @@ -22,6 +21,7 @@ def tearDown(self): self.widgets.remove(self.widget) self.widget.onDeleteWidget() self.widget = None + super().tearDown() def test_cluster(self): w = self.widget @@ -38,6 +38,21 @@ def test_cluster(self): self.assertEqual("Cluster", str(output.domain.metas[0])) self.assertEqual("DBSCAN Core", str(output.domain.metas[1])) + # one feature only + np_array = np.round(np.random.random((20, 1)), 0) + one_feature_table = Table.from_numpy(None, X=np_array) + + self.send_signal(w.Inputs.data, one_feature_table) + output = self.get_output(w.Outputs.annotated_data) + + self.assertIsNotNone(output) + self.assertEqual(len(one_feature_table), len(output)) + self.assertTupleEqual(one_feature_table.X.shape, output.X.shape) + self.assertEqual(2, output.metas.shape[1]) + self.assertEqual("Cluster", str(output.domain.metas[0])) + self.assertEqual("DBSCAN Core", str(output.domain.metas[1])) + + def test_unique_domain(self): w = self.widget data = possible_duplicate_table("Cluster") @@ -57,6 +72,16 @@ def test_bad_input(self): self.send_signal(w.Inputs.data, self.iris) self.assertFalse(w.Error.not_enough_instances.is_shown()) + new_domain = Domain([], self.iris.domain.class_vars, + metas=self.iris.domain.attributes) + iris_all_metas = self.iris.transform(new_domain) + self.send_signal(w.Inputs.data, iris_all_metas) + self.assertTrue(w.Error.no_features.is_shown()) + + self.send_signal(w.Inputs.data, self.iris) + self.assertFalse(w.Error.no_features.is_shown()) + + def test_data_none(self): w = self.widget @@ -132,7 +157,8 @@ def test_change_metric_idx(self): simulate.combobox_activate_index(cbox, 0) # Euclidean def test_sparse_csr_data(self): - self.iris.X = csr_matrix(self.iris.X) + with self.iris.unlocked(): + self.iris.X = csr_matrix(self.iris.X) w = self.widget @@ -149,7 +175,8 @@ def test_sparse_csr_data(self): self.assertEqual("DBSCAN Core", str(output.domain.metas[1])) def test_sparse_csc_data(self): - self.iris.X = csc_matrix(self.iris.X) + with self.iris.unlocked(): + self.iris.X = csc_matrix(self.iris.X) w = self.widget @@ -171,12 +198,6 @@ def test_get_kth_distances(self): # dists must be sorted np.testing.assert_array_equal(dists, np.sort(dists)[::-1]) - # test with different distance - e.g. Orange distance - dists = get_kth_distances(self.iris, Euclidean, k=5) - self.assertEqual(len(self.iris), len(dists)) - # dists must be sorted - np.testing.assert_array_equal(dists, np.sort(dists)[::-1]) - def test_metric_changed(self): w = self.widget @@ -226,7 +247,15 @@ def test_data_retain_ids(self): def test_missing_data(self): w = self.widget - self.iris[1:5, 1] = np.nan + with self.iris.unlocked(): + self.iris[:5, 1] = np.nan + self.send_signal(w.Inputs.data, self.iris) + output = self.get_output(w.Outputs.annotated_data) + self.assertTupleEqual((150, 1), output[:, "Cluster"].metas.shape) + + self.send_signal(w.Inputs.data, None) + with self.iris.unlocked(): + self.iris[5:, 2] = np.nan self.send_signal(w.Inputs.data, self.iris) output = self.get_output(w.Outputs.annotated_data) self.assertTupleEqual((150, 1), output[:, "Cluster"].metas.shape) @@ -244,7 +273,7 @@ def test_normalize_data(self): clusters = DBSCAN(**kwargs)(data) output = self.get_output(self.widget.Outputs.annotated_data) - output_clusters = output.metas[:, 0] + output_clusters = output.metas[:, 0].copy() output_clusters[np.isnan(output_clusters)] = -1 np.testing.assert_array_equal(output_clusters, clusters) @@ -259,7 +288,7 @@ def test_normalize_data(self): clusters = DBSCAN(**kwargs)(data) output = self.get_output(self.widget.Outputs.annotated_data) - output_clusters = output.metas[:, 0] + output_clusters = output.metas[:, 0].copy() output_clusters[np.isnan(output_clusters)] = -1 np.testing.assert_array_equal(output_clusters, clusters) diff --git a/Orange/widgets/unsupervised/tests/test_owdistancefile.py b/Orange/widgets/unsupervised/tests/test_owdistancefile.py new file mode 100644 index 00000000000..12a60ab35f3 --- /dev/null +++ b/Orange/widgets/unsupervised/tests/test_owdistancefile.py @@ -0,0 +1,69 @@ +import os +import unittest +from unittest.mock import patch + +import numpy as np +from AnyQt.QtCore import QMimeData, QUrl + +from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.tests.utils import dragDrop +from Orange.widgets.unsupervised.owdistancefile \ + import OWDistanceFile, OWDistanceFileDropHandler + +import Orange.tests + + +class TestOWDistanceFile(WidgetTest): + def setUp(self): + super().setUp() + self.widget = self.create_widget(OWDistanceFile) + + def open_file(self, filename): + filename = os.path.join(os.path.split(Orange.tests.__file__)[0], + filename) + self.widget.add_path(filename) + self.widget.open_file() + + def test_non_square(self): + self.open_file("xlsx_files/distances_nonsquare.xlsx") + self.assertIsNone(self.get_output(self.widget.Outputs.distances)) + self.assertTrue(self.widget.Error.non_square_matrix.is_shown()) + self.open_file("xlsx_files/distances_with_nans.xlsx") + self.assertFalse(self.widget.Error.non_square_matrix.is_shown()) + + def test_nan_to_num(self): + self.open_file("xlsx_files/distances_with_nans.xlsx") + dist = self.get_output(self.widget.Outputs.distances) + np.testing.assert_equal(dist, [[1, 2, 3], [4, 5, 0], [7, 0, 9]]) + + def test_drop_file(self): + mime = QMimeData() + mime.setUrls([QUrl("https://example.com/a.html")]) + with patch.object(self.widget, "open_file") as r: + self.assertFalse(dragDrop(self.widget, mime)) + r.assert_not_called() + + mime.setUrls([QUrl.fromLocalFile("file.notsupported")]) + with patch.object(self.widget, "open_file") as r: + self.assertFalse(dragDrop(self.widget, mime)) + r.assert_not_called() + + filename = os.path.normpath(os.path.join(__file__, "../test.dst")) + mime.setUrls([QUrl.fromLocalFile(filename)]) + with patch.object(self.widget, "open_file") as r: + self.assertTrue(dragDrop(self.widget, mime)) + self.assertEqual(os.path.normpath(self.widget.last_path()), filename) + r.assert_called() + + +class TestOWDistanceFileDropHandler(unittest.TestCase): + def test_canDropFile(self): + handler = OWDistanceFileDropHandler() + self.assertTrue(handler.canDropFile("test.dst")) + self.assertTrue(handler.canDropFile("test.xlsx")) + self.assertFalse(handler.canDropFile("test.bin")) + + def test_parametersFromFile(self): + handler = OWDistanceFileDropHandler() + r = handler.parametersFromFile("test.dst") + self.assertEqual(r["recent_paths"][0].basename, "test.dst") diff --git a/Orange/widgets/unsupervised/tests/test_owdistancemap.py b/Orange/widgets/unsupervised/tests/test_owdistancemap.py index cb93dcd539f..d0f734223d5 100644 --- a/Orange/widgets/unsupervised/tests/test_owdistancemap.py +++ b/Orange/widgets/unsupervised/tests/test_owdistancemap.py @@ -4,8 +4,10 @@ import unittest from Orange.distance import Euclidean +from Orange.misc import DistMatrix from Orange.widgets.unsupervised.owdistancemap import OWDistanceMap from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin +from Orange.widgets.tests.utils import simulate class TestOWDistanceMap(WidgetTest, WidgetOutputsTestMixin): @@ -14,7 +16,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Distances" + cls.signal_name = OWDistanceMap.Inputs.distances cls.signal_data = Euclidean(cls.data) def setUp(self): @@ -24,7 +26,7 @@ def _select_data(self): random.seed(42) selected_indices = random.sample(range(0, len(self.data)), 20) self.widget._selection = selected_indices - self.widget.commit() + self.widget.commit.now() return selected_indices def test_saved_selection(self): @@ -40,6 +42,30 @@ def test_saved_selection(self): self.send_signal(self.signal_name, self.signal_data, widget=w) self.assertEqual(len(self.get_output(w.Outputs.selected_data, widget=w)), 10) + def test_widget(self): + w = self.widget + self.send_signal(w.Inputs.distances, self.signal_data) + for i in range(w.sorting_cb.count()): + simulate.combobox_activate_index(w.sorting_cb, i) + for i in range(w.annot_combo.count()): + simulate.combobox_activate_index(w.annot_combo, i) + w.grab() + self.send_signal(w.Inputs.distances, None) + + def test_not_symmetric(self): + w = self.widget + self.send_signal(w.Inputs.distances, DistMatrix([[1, 2, 3], [4, 5, 6]])) + self.assertTrue(w.Error.not_symmetric.is_shown()) + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.not_symmetric.is_shown()) + + def test_empty_matrix(self): + w = self.widget + self.send_signal(w.Inputs.distances, DistMatrix([[]])) + self.assertTrue(w.Error.empty_matrix.is_shown()) + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.empty_matrix.is_shown()) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/unsupervised/tests/test_owdistancematrix.py b/Orange/widgets/unsupervised/tests/test_owdistancematrix.py index b59217b42d3..a30b4f42a9b 100644 --- a/Orange/widgets/unsupervised/tests/test_owdistancematrix.py +++ b/Orange/widgets/unsupervised/tests/test_owdistancematrix.py @@ -1,19 +1,19 @@ -# pylint: disable=all -from itertools import product +# pylint: disable=protected-access + +import unittest +from functools import partial from unittest.mock import patch import numpy as np -from AnyQt.QtCore import QSize -from AnyQt.QtGui import QImage, QPainter -from AnyQt.QtWidgets import QStyleOptionViewItem +from AnyQt.QtCore import Qt -from orangewidget.tests.base import GuiTest +from orangewidget.settings import Context -from Orange.data import Table +from Orange.misc import DistMatrix +from Orange.data import Table, Domain, ContinuousVariable, StringVariable from Orange.distance import Euclidean from Orange.widgets.tests.base import WidgetTest -from Orange.widgets.unsupervised.owdistancematrix import OWDistanceMatrix, \ - DistanceMatrixModel, TableBorderItem +from Orange.widgets.unsupervised.owdistancematrix import OWDistanceMatrix class TestOWDistanceMatrix(WidgetTest): @@ -28,11 +28,47 @@ def test_set_distances(self): # Distances with row data self.widget.set_distances(self.distances) self.assertIn(self.iris.domain[0], self.widget.annot_combo.model()) + self.widget.send_report() # Distances without row data self.distances.row_items = None self.widget.set_distances(self.distances) self.assertNotIn(self.iris.domain[0], self.widget.annot_combo.model()) + self.widget.send_report() + + # Non-square distances, no labels + distances = DistMatrix(np.array([[1, 2, 3], [4, 5, 6]])) + self.widget.set_distances(distances) + self.assertEqual(self.widget.annot_combo.model().rowCount(), 2) + self.widget.send_report() + + # Non-square distances, row labels + distances = DistMatrix(np.array([[1, 2, 3], [4, 5, 6]])) + distances.row_items = list("ab") + self.widget.set_distances(distances) + self.assertEqual(self.widget.annot_combo.model().rowCount(), 3) + self.widget.send_report() + + # Non-square distances, column labels + distances = DistMatrix(np.array([[1, 2, 3], [4, 5, 6]])) + distances.col_items = list("def") + self.widget.set_distances(distances) + self.assertEqual(self.widget.annot_combo.model().rowCount(), 3) + self.widget.send_report() + + # Non-square distances, both labels + distances = DistMatrix(np.array([[1, 2, 3], [4, 5, 6]])) + distances.row_items = list("ab") + distances.col_items = list("def") + self.widget.set_distances(distances) + self.assertEqual(self.widget.annot_combo.model().rowCount(), 3) + self.widget.send_report() + + # Non-square distances, no labels + distances = DistMatrix(np.array([[1, 2, 3], [4, 5, 6]])) + self.widget.set_distances(distances) + self.assertEqual(self.widget.annot_combo.model().rowCount(), 2) + self.widget.send_report() def test_context_attribute(self): distances = Euclidean(self.iris, axis=0) @@ -41,38 +77,179 @@ def test_context_attribute(self): self.widget.openContext(distances, annotations) def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_commit') as commit: + with patch.object(self.widget.commit, 'now') as commit: self.widget.auto_commit = False commit.reset_mock() self.send_signal(self.widget.Inputs.distances, self.distances) commit.assert_called() def test_labels(self): - grades = Table.from_url("https://datasets.biolab.si/core/grades-two.tab") + x, y = (ContinuousVariable(c) for c in "xy") + s = StringVariable("s") + grades = Table.from_list( + Domain([x, y], [], [s]), + [[91.0, 89.0, "Bill"], + [51.0, 100.0, "Cynthia"], + [9.0, 61.0, "Demi"], + [49.0, 92.0, "Fred"], + [91.0, 49.0, "George"] + ] + ) + + header = self.widget.tablemodel.headerData + distances = Euclidean(grades) self.widget.set_distances(distances) ac = self.widget.annot_combo idx = ac.model().indexOf(grades.domain.metas[0]) ac.setCurrentIndex(idx) ac.activated.emit(idx) - self.assertIsNone(self.widget.tablemodel.label_colors) - - -class TestDelegates(GuiTest): - def test_delegate(self): - model = DistanceMatrixModel() - matrix = np.array([[0.0, 0.1, 0.2], [0.1, 0.0, 0.1], [0.2, 0.1, 0.0]]) - model.set_data(matrix) - delegate = TableBorderItem() - for row, col in product(range(model.rowCount()), - range(model.columnCount())): - index = model.index(row, col) - option = QStyleOptionViewItem() - size = delegate.sizeHint(option, index).expandedTo(QSize(30, 18)) - delegate.initStyleOption(option, index) - img = QImage(size, QImage.Format_ARGB32_Premultiplied) - painter = QPainter(img) - try: - delegate.paint(painter, option, index) - finally: - painter.end() + self.assertEqual(header(2, Qt.Horizontal, Qt.DisplayRole), "Demi") + self.assertIsNone(header(2, Qt.Horizontal, Qt.BackgroundRole)) + self.assertEqual(header(2, Qt.Vertical, Qt.DisplayRole), "Demi") + self.assertIsNone(header(2, Qt.Vertical, Qt.BackgroundRole)) + + idx = ac.model().indexOf(grades.domain.attributes[0]) + ac.setCurrentIndex(idx) + ac.activated.emit(idx) + self.assertIn("9", header(2, Qt.Horizontal, Qt.DisplayRole)) + self.assertIsNotNone(header(2, Qt.Horizontal, Qt.BackgroundRole)) + self.assertIn("9", header(2, Qt.Vertical, Qt.DisplayRole)) + self.assertIsNotNone(header(2, Qt.Vertical, Qt.BackgroundRole)) + + def test_num_meta_labels_w_nan(self): + x, y = (ContinuousVariable(c) for c in "xy") + s = StringVariable("s") + data = Table.from_list( + Domain([x], [], [y, s]), + [[0, 1, "a"], + [1, np.nan, "b"]] + ) + distances = Euclidean(data) + self.widget.set_distances(distances) + ac = self.widget.annot_combo + idx = ac.model().indexOf(y) + ac.setCurrentIndex(idx) + ac.activated.emit(idx) + + header = self.widget.tablemodel.headerData + self.assertEqual(header(0, Qt.Horizontal, Qt.DisplayRole), "1") + self.assertEqual(header(1, Qt.Horizontal, Qt.DisplayRole), "?") + self.assertIsNotNone(header(1, Qt.Horizontal, Qt.BackgroundRole)) + self.assertEqual(header(0, Qt.Vertical, Qt.DisplayRole), "1") + self.assertEqual(header(1, Qt.Vertical, Qt.DisplayRole), "?") + self.assertIsNotNone(header(1, Qt.Vertical, Qt.BackgroundRole)) + + def test_choose_label(self): + self.assertIs(OWDistanceMatrix._choose_label(self.iris), + self.iris.domain.class_var) + + domain = Domain([ContinuousVariable(x) for x in "xyz"], + ContinuousVariable("t"), + [ContinuousVariable("m")] + + [StringVariable(c) for c in "abc"] + ) + data = Table.from_numpy( + domain, + np.zeros((4, 3), dtype=float), + np.arange(4, dtype=float), + np.array([[0, "a", "a", "a"], + [1, "b", "b", "b"], + [2, "a", "c", "b"], + [0, "b", "a", "a"]]) + ) + self.assertIs(OWDistanceMatrix._choose_label(data), + domain.metas[2]) + domain2 = Domain(domain.attributes, domain.class_var, domain.metas[:-2]) + self.assertIs(OWDistanceMatrix._choose_label(data.transform(domain2)), + domain.metas[1]) + + def test_non_square_labels(self): + widget = self.widget + ac = self.widget.annot_combo + + dist = DistMatrix([[1, 2, 3], [4, 5, 6]]) + dist.row_items = DistMatrix._labels_to_tables(["aa", "bb"]) + dist.col_items = DistMatrix._labels_to_tables(["cc", "dd", "ee"]) + self.send_signal(widget.Inputs.distances, dist) + self.assertEqual(ac.model().rowCount(), 3) + + header = partial(widget.tablemodel.headerData, role=Qt.DisplayRole) + ac.setCurrentIndex(0) + ac.activated.emit(0) + self.assertIsNone(header(1, Qt.Horizontal)) + self.assertIsNone(header(1, Qt.Vertical)) + + ac.setCurrentIndex(1) + ac.activated.emit(1) + self.assertEqual(header(1, Qt.Horizontal), "2") + self.assertEqual(header(1, Qt.Vertical), "2") + + ac.setCurrentIndex(2) + ac.activated.emit(2) + self.assertEqual(header(1, Qt.Horizontal), "dd") + self.assertEqual(header(1, Qt.Vertical), "bb") + + @WidgetTest.skipNonEnglish + def test_migrate_settings_v1_and_use_them(self): + ind = [1, 2, 5, 6, 7, 8] + context = Context( + values={'__version__': 1}, + dim=10, + annotations=['None', 'Enumerate', + 'sepal length', 'sepal width', 'petal length', + 'petal width', 'iris'], + annotation='petal length', + selection=ind) + widget = self.create_widget( + OWDistanceMatrix, stored_settings={"__version__": 1, "context_settings": [context]}) + iris = Table("iris")[:10] + distances = Euclidean(iris) + self.send_signal(widget.Inputs.distances, distances) + self.assertEqual(widget.annotation_idx, 4) + self.assertEqual(widget.tableview.selectionModel().selectedItems(), ind) + outm = self.get_output(widget.Outputs.distances) + np.testing.assert_equal(outm, distances.submatrix(ind, ind)) + + def test_square_settings(self): + widget = self.widget + self.send_signal(widget.Inputs.distances, self.distances) + widget._set_selection([0, 3, 4]) + widget.annotation_idx = 3 + + self.send_signal(widget.Inputs.distances, None) + self.assertEqual(widget._get_selection(), ([], True)) + self.assertEqual(widget.annotation_idx, 0) + + self.send_signal(widget.Inputs.distances, self.distances) + self.assertEqual(widget._get_selection(), ([0, 3, 4], True)) + self.assertEqual(widget.annotation_idx, 3) + + matrix = DistMatrix(np.array([[1, 2, 3], [4, 5, 6]])) + matrix.row_items = list("ab") + + self.send_signal(widget.Inputs.distances,matrix) + self.assertEqual(widget._get_selection(), (([], []), False)) + self.assertEqual(widget.annotation_idx, 2) + + widget._set_selection(([0], [0, 2])) + widget.annotation_idx = 0 + + self.send_signal(widget.Inputs.distances, self.distances) + self.assertEqual(widget._get_selection(), ([0, 3, 4], True)) + self.assertEqual(widget.annotation_idx, 3) + + self.send_signal(widget.Inputs.distances, matrix) + self.assertEqual(widget._get_selection(), (([0], [0, 2]), False)) + self.assertEqual(widget.annotation_idx, 0) + + def test_empty_matrix(self): + matrix = DistMatrix(np.empty((0, 0))) + self.send_signal(self.widget.Inputs.distances, matrix) + self.assertTrue(self.widget.Error.empty_matrix.is_shown()) + self.send_signal(self.widget.Inputs.distances, None) + self.assertFalse(self.widget.Error.empty_matrix.is_shown()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/unsupervised/tests/test_owdistances.py b/Orange/widgets/unsupervised/tests/test_owdistances.py index 7935fc73c85..38f3acb9a2b 100644 --- a/Orange/widgets/unsupervised/tests/test_owdistances.py +++ b/Orange/widgets/unsupervised/tests/test_owdistances.py @@ -1,15 +1,17 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring, protected-access import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch import numpy as np from Orange import distance from Orange.data import Table, Domain, ContinuousVariable from Orange.misc import DistMatrix -from Orange.widgets.unsupervised.owdistances import OWDistances, METRICS, \ - DistanceRunner +from Orange.widgets.unsupervised.owdistances import OWDistances, \ + DistanceRunner, MetricDefs, Cosine, Mahalanobis, Jaccard, MetricDef, \ + ManhattanNormalized, EuclideanNormalized, Manhattan, Spearman, Pearson, \ + Hamming, SpearmanAbsolute, PearsonAbsolute, Euclidean from Orange.widgets.tests.base import WidgetTest @@ -17,23 +19,27 @@ class TestDistanceRunner(unittest.TestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.iris = Table("iris")[::5] - cls.iris.X[0, 2] = np.nan - cls.iris.X[1, 3] = np.nan - cls.iris.X[2, 1] = np.nan - cls.zoo = Table("zoo")[::5] - cls.zoo.X[0, 2] = np.nan - cls.zoo.X[1, 3] = np.nan - cls.zoo.X[2, 1] = np.nan + cls.iris = Table("iris")[::5].copy() + with cls.iris.unlocked(): + cls.iris.X[0, 2] = np.nan + cls.iris.X[1, 3] = np.nan + cls.iris.X[2, 1] = np.nan + + cls.zoo = Table("zoo")[::5].copy() + with cls.zoo.unlocked(): + cls.zoo.X[0, 2] = np.nan + cls.zoo.X[1, 3] = np.nan + cls.zoo.X[2, 1] = np.nan def test_run(self): state = Mock() state.is_interruption_requested = Mock(return_value=False) - for name, metric in METRICS: + for metricdef in MetricDefs.values(): + metric = metricdef.metric data = self.iris - if not metric.supports_missing or name == "Bhattacharyya": + if not metric.supports_missing: data = distance.impute(data) - elif name == "Jaccard": + elif metric == distance.Jaccard: data = self.zoo # between rows, normalized @@ -66,36 +72,37 @@ def assertDistMatrixEqual(self, dist1, dist2): class TestOWDistances(WidgetTest): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.iris = Table("iris")[::5] - cls.titanic = Table("titanic")[::10] - def setUp(self): + super().setUp() + self.iris = Table("iris")[::5].copy() + self.titanic = Table("titanic")[::10].copy() self.widget = self.create_widget(OWDistances) + def _select(self, id_): + buttons = self.widget.metric_buttons + buttons.button(id_).setChecked(True) + buttons.idClicked.emit(id_) + def test_distance_combo(self): """Check distances when the metric changes""" - self.assertEqual(self.widget.metrics_combo.count(), len(METRICS)) self.send_signal(self.widget.Inputs.data, self.iris) - for i, (_, metric) in enumerate(METRICS): - self.widget.metrics_combo.activated.emit(i) - self.widget.metrics_combo.setCurrentIndex(i) + for metricdef in MetricDefs.values(): + if metricdef.metric is distance.Jaccard: + continue + self._select(metricdef.id) self.wait_until_stop_blocking() - if metric.supports_normalization: - expected = metric(self.iris, normalize=self.widget.normalized_dist) - else: - expected = metric(self.iris) - if metric is not distance.Jaccard: - np.testing.assert_array_almost_equal( - expected, self.get_output(self.widget.Outputs.distances)) + kwargs = dict(normalize=True) if metricdef.normalize else {} + expected = metricdef.metric(self.iris, **kwargs) + + np.testing.assert_array_almost_equal( + expected, self.get_output(self.widget.Outputs.distances), + err_msg=f"at {metricdef.name}") def test_error_message(self): """Check if error message appears and then disappears when data is removed from input""" - self.widget.metric_idx = 2 + self._select(Cosine) self.send_signal(self.widget.Inputs.data, self.iris) self.wait_until_stop_blocking() self.assertFalse(self.widget.Error.no_continuous_features.is_shown()) @@ -106,9 +113,7 @@ def test_error_message(self): self.assertFalse(self.widget.Error.no_continuous_features.is_shown()) def test_jaccard_messages(self): - for self.widget.metric_idx, (name, _) in enumerate(METRICS): - if name == "Jaccard": - break + self._select(Jaccard) self.send_signal(self.widget.Inputs.data, self.iris) self.wait_until_stop_blocking() self.assertTrue(self.widget.Error.no_binary_features.is_shown()) @@ -150,35 +155,45 @@ def test_too_big_array(self): """ Users sees an error message when calculating too large arrays and Orange does not crash. - GH-2315 """ self.assertEqual(len(self.widget.Error.active), 0) self.send_signal(self.widget.Inputs.data, self.iris) - mock = Mock(side_effect=ValueError) - self.widget.compute_distances(mock, self.iris) - self.wait_until_finished() - self.assertTrue(self.widget.Error.distances_value_error.is_shown()) - - mock = Mock(side_effect=MemoryError) - self.widget.compute_distances(mock, self.iris) - self.wait_until_finished() - self.assertEqual(len(self.widget.Error.active), 1) - self.assertTrue(self.widget.Error.distances_memory_error.is_shown()) - - def test_migrates_normalized_dist(self): - w = self.create_widget(OWDistances, stored_settings={"metric_idx": 0}) - self.assertFalse(w.normalized_dist) - - def test_negative_values_bhattacharyya(self): - self.iris.X[0, 0] *= -1 - for self.widget.metric_idx, (_, metric) in enumerate(METRICS): - if metric == distance.Bhattacharyya: - break - self.send_signal(self.widget.Inputs.data, self.iris) - self.wait_until_finished() - self.assertTrue(self.widget.Error.distances_value_error.is_shown()) - self.iris.X[0, 0] *= -1 + id_ = self.widget.metric_id + for exc, err in ((ValueError, self.widget.Error.distances_value_error), + (MemoryError, self.widget.Error.distances_memory_error) + ): + with patch.dict( + MetricDefs, + {id_: MetricDef(id_, "", "", Mock(side_effect=exc))}): + self.widget.compute_distances(self.iris) + self.wait_until_finished() + self.assertTrue(err.is_shown(), msg=f"at {exc}") + + def test_migrate_3_to_4(self): + settings = {'__version__': 3} + w = self.create_widget( + OWDistances, + stored_settings=dict(metric_idx=0, normalized_dist=True, **settings)) + self.assertEqual(w.metric_id, EuclideanNormalized) + w = self.create_widget( + OWDistances, + stored_settings=dict(metric_idx=1, normalized_dist=False, **settings)) + self.assertEqual(w.metric_id, Manhattan) + w = self.create_widget( + OWDistances, + stored_settings=dict(metric_idx=1, normalized_dist=True, **settings)) + self.assertEqual(w.metric_id, ManhattanNormalized) + + for old, new in ((2, Cosine), (3, Jaccard), + (4, Spearman), (5, SpearmanAbsolute), + (6, Pearson), (7, PearsonAbsolute), + (8, Hamming), (9, Mahalanobis), + (10, Euclidean)): + settings = dict(metric_idx=old, __version__=3) + w = self.create_widget(OWDistances, stored_settings=settings) + self.assertEqual(w.metric_id, new, + msg=f"at {old} to {MetricDefs[new].name}") def test_limit_mahalanobis(self): def assert_error_shown(): @@ -192,13 +207,7 @@ def assert_no_error(): widget = self.widget axis_buttons = widget.controls.axis.buttons - self.assertEqual(widget.metrics_combo.count(), len(METRICS)) - for i, (_, metric) in enumerate(METRICS): - if metric == distance.Mahalanobis: - widget.metrics_combo.setCurrentIndex(i) - widget.metrics_combo.activated.emit(i) - break - + self._select(Mahalanobis) X = np.random.random((1010, 4)) bigrows = Table.from_numpy(Domain(self.iris.domain.attributes), X) bigcols = Table.from_numpy( @@ -214,20 +223,106 @@ def assert_no_error(): # by columns -- cannot handle too many rows self.send_signal(self.widget.Inputs.data, bigrows) + assert_no_error() + axis_buttons[0].click() + assert_error_shown() + axis_buttons[1].click() + assert_no_error() + + self.send_signal(self.widget.Inputs.data, bigcols) assert_error_shown() axis_buttons[0].click() assert_no_error() axis_buttons[1].click() assert_error_shown() - self.send_signal(self.widget.Inputs.data, bigcols) + self.send_signal(widget.Inputs.data, self.iris) assert_no_error() - axis_buttons[0].click() + + def test_data_too_large(self): + self._select(Cosine) + self.widget.start = Mock() + + def assert_error_shown(): + self.assertTrue(self.widget.Error.data_too_large.is_shown()) + self.assertIsNone(self.widget.start.call_args[0][1]) + + def assert_no_error(): + self.assertFalse(self.widget.Error.data_too_large.is_shown()) + self.assertIsNotNone(self.widget.start.call_args[0][1]) + + self.send_signal( + Table.from_numpy(Domain([ContinuousVariable("x")]), + np.zeros((1000, 1))) + ) + assert_no_error() + + self.send_signal( + Table.from_numpy(Domain([ContinuousVariable("x")]), + np.zeros((20001, 1))) + ) assert_error_shown() - self.send_signal(widget.Inputs.data, self.iris) + self.widget.controls.axis.buttons[1].click() + assert_no_error() + + self.send_signal( + Table.from_numpy(Domain([ContinuousVariable(f"x{i}") + for i in range(20001)]), + np.zeros((1, 20001))) + ) + assert_error_shown() + + self.widget.controls.axis.buttons[0].click() assert_no_error() + def test_discrete_in_metas(self): + domain = self.iris.domain + data = self.iris.transform( + Domain(domain.attributes[:-1] + (domain.class_var, ), + [], + domain.attributes[-1:]) + ) + self._select(Cosine) + self.send_signal(self.widget.Inputs.data, data) + self.wait_until_finished() + out = self.get_output(self.widget.Outputs.distances) + out_domain = out.row_items.domain + self.assertEqual(out_domain.attributes, domain.attributes[:-1]) + self.assertEqual(out_domain.metas, + (domain.attributes[-1], domain.class_var)) + + def test_non_binary_in_metas(self): + self._select(Jaccard) + zoo = Table("zoo")[:20] + self.send_signal(self.widget.Inputs.data, zoo) + self.wait_until_finished() + out = self.get_output(self.widget.Outputs.distances) + domain = zoo.domain + out_domain = out.row_items.domain + self.assertEqual(out_domain.metas, (domain["name"], domain["legs"])) + + def test_no_features(self): + zoo = Table("zoo")[:5, 16:] + + self.send_signal(self.widget.Inputs.data, zoo) + self.wait_until_finished() + out = self.get_output(self.widget.Outputs.distances) + self.assertEqual(out.shape, (5, 5)) + self.assertTrue((out == 0).all()) + self.assertTrue(self.widget.Warning.no_features.is_shown()) + + self.widget.controls.axis.buttons[1].click() + self.wait_until_finished() + out = self.get_output(self.widget.Outputs.distances) + self.assertEqual(out.shape, (0, 0)) + self.assertTrue((out == 0).all()) + self.assertTrue(self.widget.Warning.no_features.is_shown()) + + self.send_signal(self.widget.Inputs.data, None) + self.wait_until_finished() + self.assertFalse(self.widget.Warning.no_features.is_shown()) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/unsupervised/tests/test_owhierarchicalclustering.py b/Orange/widgets/unsupervised/tests/test_owhierarchicalclustering.py index bfd75ed87c9..04afb193e98 100644 --- a/Orange/widgets/unsupervised/tests/test_owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/tests/test_owhierarchicalclustering.py @@ -1,18 +1,21 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring, protected-access +import unittest import warnings import numpy as np from AnyQt.QtCore import QPoint, Qt +from AnyQt.QtGui import QColor from AnyQt.QtTest import QTest import Orange.misc from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable from Orange.distance import Euclidean +from Orange.misc import DistMatrix from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin from Orange.widgets.unsupervised.owhierarchicalclustering import \ - OWHierarchicalClustering + OWHierarchicalClustering, SelectedLabelsModel class TestOWHierarchicalClustering(WidgetTest, WidgetOutputsTestMixin): @@ -22,10 +25,12 @@ def setUpClass(cls): WidgetOutputsTestMixin.init(cls) cls.distances = Euclidean(cls.data) - cls.signal_name = "Distances" + cls.signal_name = OWHierarchicalClustering.Inputs.distances cls.signal_data = cls.distances cls.same_input_output_domain = False + cls.distances_cols = Euclidean(cls.data, axis=0) + def setUp(self): self.widget = self.create_widget(OWHierarchicalClustering) @@ -35,6 +40,11 @@ def _select_data(self): self.widget.dendrogram.set_selected_items([cluster]) return [14, 15, 32, 33] + def _select_data_columns(self): + items = self.widget.dendrogram._items + cluster = items[sorted(list(items.keys()))[5]] + self.widget.dendrogram.set_selected_items([cluster]) + def _compare_selected_annotated_domains(self, selected, annotated): self.assertEqual(annotated.domain.variables, selected.domain.variables) @@ -132,6 +142,20 @@ def test_infinite_distances(self): self.send_signal(self.widget.Inputs.distances, self.distances) self.assertFalse(self.widget.Error.not_finite_distances.is_shown()) + def test_not_symmetric(self): + w = self.widget + self.send_signal(w.Inputs.distances, DistMatrix([[1, 2, 3], [4, 5, 6]])) + self.assertTrue(w.Error.not_symmetric.is_shown()) + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.not_symmetric.is_shown()) + + def test_empty_matrix(self): + w = self.widget + self.send_signal(w.Inputs.distances, DistMatrix([[]])) + self.assertTrue(w.Error.empty_matrix.is_shown()) + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.empty_matrix.is_shown()) + def test_output_cut_ratio(self): self.send_signal(self.widget.Inputs.distances, self.distances) @@ -140,6 +164,7 @@ def test_output_cut_ratio(self): annotated = self.get_output(self.widget.Outputs.annotated_data) self.assertIsNotNone(annotated) + self.widget.grab() # Force layout # selecting clusters with cutoff should select all data QTest.mousePress( self.widget.view.headerView().viewport(), @@ -170,3 +195,49 @@ def test_restore_state(self): self.send_signal(w.Inputs.distances, self.distances, widget=w) ids_2 = self.get_output(w.Outputs.selected_data, widget=w).ids self.assertSequenceEqual(list(ids_1), list(ids_2)) + + def test_column_distances(self): + self.send_signal(self.widget.Inputs.distances, self.distances_cols) + self._select_data_columns() + o = self.get_output(self.widget.Outputs.annotated_data) + annotated = [(a.name, a.attributes['cluster']) for a in o.domain.attributes] + self.assertEqual(annotated, [('sepal width', 1), ('petal length', 1), + ('sepal length', 0), ('petal width', 0)]) + + self.widget.selection_box.buttons[2].click() # top N + o = self.get_output(self.widget.Outputs.annotated_data) + annotated = [(a.name, a.attributes['cluster']) for a in o.domain.attributes] + self.assertEqual(annotated, [('sepal length', 1), ('petal width', 2), + ('sepal width', 3), ('petal length', 3)]) + + def test_many_values_warning(self): + w = self.widget + + self.send_signal(self.widget.Inputs.distances, self.distances) + w.top_n = 21 + w.selection_box.buttons[2].click() + self.assertTrue(w.Warning.many_clusters.is_shown()) + + w.top_n = 20 + w.selection_box.buttons[2].click() + self.assertFalse(w.Warning.many_clusters.is_shown()) + + w.top_n = 21 + w.selection_box.buttons[2].click() + self.assertTrue(w.Warning.many_clusters.is_shown()) + + self.send_signal(self.widget.Inputs.distances, None) + self.assertFalse(w.Warning.many_clusters.is_shown()) + + +class TestSelectedLabelsModel(unittest.TestCase): + def test_model_extend(self): + model = SelectedLabelsModel() + model[:] = ["1"] + model.set_colors([QColor(Qt.blue)]) + index = model.index(0) + self.assertEqual(index.data(Qt.DisplayRole), "1") + self.assertEqual(index.data(Qt.BackgroundRole), QColor(Qt.blue)) + model[:]= ["1", "2"] + index1 = model.index(1) + self.assertEqual(index1.data(Qt.BackgroundRole), QColor()) # should be invalid color diff --git a/Orange/widgets/unsupervised/tests/test_owkmeans.py b/Orange/widgets/unsupervised/tests/test_owkmeans.py index a77b5f8c01f..380b33a3864 100644 --- a/Orange/widgets/unsupervised/tests/test_owkmeans.py +++ b/Orange/widgets/unsupervised/tests/test_owkmeans.py @@ -11,6 +11,7 @@ import Orange.clustering from Orange.data import Table, Domain +from Orange.data.table import DomainTransformationError from Orange.widgets import gui from Orange.widgets.tests.base import WidgetTest from Orange.widgets.unsupervised.owkmeans import OWKMeans, ClusterTableModel @@ -19,21 +20,21 @@ class TestClusterTableModel(unittest.TestCase): def test_model(self): model = ClusterTableModel() - model.set_scores(["bad", 0.250, "another bad"], 3) + model.set_scores(["bad", 0.125, "another bad", 0.5], 3) self.assertEqual(model.start_k, 3) - self.assertEqual(model.rowCount(), 3) + self.assertEqual(model.rowCount(), 4) ind0, ind1 = model.index(0, 0), model.index(1, 0) self.assertEqual(model.flags(ind0), Qt.NoItemFlags) self.assertEqual(model.flags(ind1), Qt.ItemIsEnabled | Qt.ItemIsSelectable) data = model.data self.assertEqual(data(ind0), "NA") - self.assertEqual(data(ind1), "0.250") + self.assertEqual(data(ind1), "0.125") self.assertEqual(data(ind0, Qt.ToolTipRole), "bad") self.assertIsNone(data(ind1, Qt.ToolTipRole)) self.assertIsNone(data(ind0, gui.BarRatioRole)) - self.assertAlmostEqual(data(ind1, gui.BarRatioRole), 0.250) + self.assertAlmostEqual(data(ind1, gui.BarRatioRole), 0.25) self.assertAlmostEqual(data(ind1, Qt.TextAlignmentRole), Qt.AlignVCenter | Qt.AlignLeft) @@ -200,20 +201,41 @@ def test_data_on_output(self): # removing data should have cleared the output self.assertEqual(self.widget.data, None) + def test_clusters_compute_value(self): + orig_data = self.data[:20] + self.send_signal(self.widget.Inputs.data, orig_data, wait=5000) + out = self.get_output(self.widget.Outputs.annotated_data) + orig = out.get_column("Cluster") + + transformed = orig_data.transform(out.domain).get_column("Cluster") + np.testing.assert_equal(orig, transformed) + + new_data = self.data[20:40] + transformed = new_data.transform(out.domain).get_column("Cluster") + np.testing.assert_equal(np.isnan(transformed), False) + + incompatible_data = Table("iris") + with self.assertRaises(DomainTransformationError): + transformed = incompatible_data.transform(out.domain) + def test_centroids_on_output(self): widget = self.widget widget.optimize_k = False widget.k = 4 self.send_signal(widget.Inputs.data, self.data) self.commit_and_wait() - widget.clusterings[widget.k].labels = np.array([0] * 100 + [1] * 203).flatten() - widget.clusterings[widget.k].silhouette_samples = np.arange(303) / 303 - widget.send_data() + km = widget.clusterings[widget.k] + out = self.get_output(widget.Outputs.centroids) - np.testing.assert_array_almost_equal( - np.array([[0, np.mean(np.arctan(np.arange(100) / 303)) / np.pi + 0.5], - [1, np.mean(np.arctan(np.arange(100, 303) / 303)) / np.pi + 0.5], - [2, 0], [3, 0]]), out.metas.astype(float)) + sklearn_centroids = km.centroids + np.testing.assert_equal(sklearn_centroids, out.X) + + scores = np.arctan(km.silhouette_samples) / np.pi + 0.5 + silhouette = [np.mean(scores[km.labels == i]) for i in range(4)] + self.assertTrue(2, len(out.domain.metas)) + np.testing.assert_almost_equal([0, 1, 2, 3], out.get_column("Cluster")) + np.testing.assert_almost_equal(silhouette, out.get_column("Silhouette")) + self.assertEqual(out.name, "heart_disease centroids") def test_centroids_domain_on_output(self): @@ -446,13 +468,13 @@ def test_silhouette_column(self): 100): self.send_signal(self.widget.Inputs.data, table) outtable = self.get_output(widget.Outputs.annotated_data) - outtable = outtable.get_column_view("Silhouette")[0] + outtable = outtable.get_column("Silhouette") self.assertTrue(np.all(np.isnan(outtable))) self.assertTrue(widget.Warning.no_silhouettes.is_shown()) self.send_signal(self.widget.Inputs.data, table[:100]) outtable = self.get_output(widget.Outputs.annotated_data) - outtable = outtable.get_column_view("Silhouette")[0] + outtable = outtable.get_column("Silhouette") np.testing.assert_array_less(outtable, 1.01) np.testing.assert_array_less(-0.01, outtable) self.assertFalse(widget.Warning.no_silhouettes.is_shown()) @@ -463,7 +485,7 @@ def test_invalidate_clusterings_cancels_jobs(self): # Send the data without waiting self.send_signal(widget.Inputs.data, self.data) - widget.unconditional_commit() + widget.commit.now() # Now, invalidate by changing max_iter widget.max_iterations = widget.max_iterations + 1 widget.invalidate() @@ -490,9 +512,10 @@ def test_do_not_recluster_on_same_data(self): ) # X is different, should cause update table3 = table1.copy() - table3.X[:, 0] = 1 + with table3.unlocked(): + table3.X[:, 0] = 1 - with patch.object(self.widget, 'unconditional_commit') as commit: + with patch.object(self.widget.commit, 'now') as commit: self.send_signal(self.widget.Inputs.data, table1) self.commit_and_wait() commit.reset_mock() diff --git a/Orange/widgets/unsupervised/tests/test_owlouvain.py b/Orange/widgets/unsupervised/tests/test_owlouvain.py index 2881d6c9fe1..860a64ff907 100644 --- a/Orange/widgets/unsupervised/tests/test_owlouvain.py +++ b/Orange/widgets/unsupervised/tests/test_owlouvain.py @@ -21,7 +21,7 @@ def setUp(self): self.widget = self.create_widget( OWLouvainClustering, stored_settings={'auto_commit': False} ) - self.iris = Table('iris')[::5] + self.iris = Table('iris')[::5].copy() def tearDown(self): self.widget.onDeleteWidget() @@ -55,7 +55,7 @@ def test_clusters_ordered_by_size(self): self.commit_and_wait() output = self.get_output(self.widget.Outputs.annotated_data) - clustering = output.get_column_view('Cluster')[0].astype(int) + clustering = output.get_column('Cluster').astype(int) counts = np.bincount(clustering) np.testing.assert_equal(counts, sorted(counts, reverse=True)) @@ -64,7 +64,8 @@ def test_empty_dataset(self): meta = np.array([0] * 5) meta_var = ContinuousVariable(name='meta_var') table = Table.from_domain(domain=Domain([], metas=[meta_var]), n_rows=5) - table.get_column_view(meta_var)[0][:] = meta + with table.unlocked(): + table.set_column(meta_var, meta) self.send_signal(self.widget.Inputs.data, table) self.commit_and_wait() @@ -89,7 +90,8 @@ def test_do_not_recluster_on_same_data(self): ) # X is different, should cause update table3 = table1.copy() - table3.X[:, 0] = 1 + with table3.unlocked(): + table3.X[:, 0] = 1 with patch.object(self.widget, '_invalidate_output') as commit: self.send_signal(self.widget.Inputs.data, table1) @@ -216,7 +218,8 @@ def test_dense_and_sparse_return_same_result(self): # Randomly set some values to zero dense_data = self.iris mask = random_state.beta(1, 2, size=self.iris.X.shape) > 0.5 - dense_data.X[mask] = 0 + with dense_data.unlocked(): + dense_data.X[mask] = 0 sparse_data = dense_data.to_sparse() def _compute_clustering(data): diff --git a/Orange/widgets/unsupervised/tests/test_owmanifoldlearning.py b/Orange/widgets/unsupervised/tests/test_owmanifoldlearning.py index 34104939311..6eb996bb9d4 100644 --- a/Orange/widgets/unsupervised/tests/test_owmanifoldlearning.py +++ b/Orange/widgets/unsupervised/tests/test_owmanifoldlearning.py @@ -1,5 +1,6 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring, protected-access +import unittest from unittest import skip from unittest.mock import patch, Mock @@ -22,6 +23,9 @@ def setUp(self): self.widget = self.create_widget( OWManifoldLearning, stored_settings={"auto_apply": False}) # type: OWManifoldLearning + def click_apply(self): + self.widget.apply_button.button.clicked.emit() + def test_input_data(self): """Check widget's data""" self.assertEqual(self.widget.data, None) @@ -34,23 +38,96 @@ def test_output_data(self): """Check if data is on output after apply""" self.assertIsNone(self.get_output(self.widget.Outputs.transformed_data)) self.send_signal(self.widget.Inputs.data, self.iris) - self.widget.apply_button.button.click() + self.click_apply() self.assertIsInstance(self.get_output(self.widget.Outputs.transformed_data), Table) self.send_signal(self.widget.Inputs.data, None) - self.widget.apply_button.button.click() + self.click_apply() self.assertIsNone(self.get_output(self.widget.Outputs.transformed_data)) + def test_output_parts(self): + data = Table("zoo") + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.transformed_data) + + # X + self.assertEqual(data.domain.attributes, output.domain.attributes) + np.testing.assert_array_equal(data.X, output.X) + + # Y + self.assertEqual(data.domain.class_vars, output.domain.class_vars) + np.testing.assert_array_equal(data.Y, output.Y) + + # metas + self.assertEqual(data.domain.metas, output.domain.metas[:-2]) + np.testing.assert_array_equal(data.metas, output.metas[:, :-2]) + self.assertFalse(np.isnan(output.metas[:, -2:].astype(float)).any()) + + def test_output_type(self): + class DummyTable(Table): + @classmethod + def from_file(cls, filename, sheet=None): + table = super().from_file(filename, sheet) + table = cls.from_numpy(table.domain, table.X, table.Y, + table.metas) + table.name = filename + return table + + data = DummyTable("zoo") + self.assertIsInstance(data, DummyTable) + self.send_signal(self.widget.Inputs.data, data) + output = self.get_output(self.widget.Outputs.transformed_data) + self.assertIsInstance(output, DummyTable) + def test_n_components(self): """Check the output for various numbers of components""" self.send_signal(self.widget.Inputs.data, self.iris) - for i in range(self.widget.n_components_spin.minimum(), - self.widget.n_components_spin.maximum()): + for i in range(1, 5): self.assertEqual(self.widget.data, self.iris) - self.widget.n_components_spin.setValue(i) - self.widget.n_components_spin.onEnter() - self.widget.apply_button.button.click() + self.widget.controls.n_components.setValue(i) + self.click_apply() self._compare_tables(self.get_output(self.widget.Outputs.transformed_data), i) + def test_too_few_attributes(self): + widget = self.widget + widget.auto_apply = True + spin = widget.controls.n_components + widget.n_components = 3 + + self.send_signal(self.iris) + self.assertFalse(widget.Warning.less_components.is_shown()) + self.assertEqual(self.get_output().metas.shape[1], 3) + self.assertEqual(widget.act_components, 3) + + self.send_signal(self.iris[:, :2]) + self.assertTrue(widget.Warning.less_components.is_shown()) + self.assertEqual(self.get_output().metas.shape[1], 2) + self.assertEqual(widget.act_components, 2) + + self.send_signal(None) + self.assertFalse(widget.Warning.less_components.is_shown()) + self.assertIsNone(self.get_output()) + + self.send_signal(self.iris[:, :2]) + self.assertTrue(widget.Warning.less_components.is_shown()) + self.assertEqual(self.get_output().metas.shape[1], 2) + self.assertEqual(widget.act_components, 2) + + self.send_signal(self.iris) + self.assertFalse(widget.Warning.less_components.is_shown()) + self.assertEqual(self.get_output().metas.shape[1], 3) + self.assertEqual(widget.act_components, 3) + + spin.setValue(6) + assert widget.n_components == 6 + self.assertTrue(widget.Warning.less_components.is_shown()) + self.assertEqual(self.get_output().metas.shape[1], 4) + self.assertEqual(widget.act_components, 4) + + spin.setValue(4) + self.assertFalse(widget.Warning.less_components.is_shown()) + self.assertEqual(self.get_output().metas.shape[1], 4) + self.assertEqual(widget.act_components, 4) + def test_manifold_methods(self): """Check output for various manifold methods""" self.send_signal(self.widget.Inputs.data, self.iris) @@ -58,14 +135,14 @@ def test_manifold_methods(self): for i in range(len(self.widget.MANIFOLD_METHODS)): self.assertEqual(self.widget.data, self.iris) self.widget.manifold_methods_combo.activated.emit(i) - self.widget.apply_button.button.click() + self.click_apply() self._compare_tables(self.get_output(self.widget.Outputs.transformed_data), n_comp) def _compare_tables(self, _output, n_components): """Helper function for table comparison""" - self.assertEqual((len(self.iris), n_components), _output.X.shape) + np.testing.assert_array_equal(self.iris.X, _output.X) np.testing.assert_array_equal(self.iris.Y, _output.Y) - np.testing.assert_array_equal(self.iris.metas, _output.metas) + self.assertEqual((len(self.iris), n_components), _output.metas.shape) def test_sparse_data(self): data = Table("iris").to_sparse() @@ -74,11 +151,11 @@ def test_sparse_data(self): def __callback(): # Send sparse data to input self.send_signal(self.widget.Inputs.data, data) - self.widget.apply_button.button.click() + self.click_apply() self.assertTrue(self.widget.Error.sparse_not_supported.is_shown()) # Clear input self.send_signal(self.widget.Inputs.data, None) - self.widget.apply_button.button.click() + self.click_apply() self.assertFalse(self.widget.Error.sparse_not_supported.is_shown()) simulate.combobox_run_through_all( @@ -92,12 +169,12 @@ def test_metrics(self): def __callback(): # Send data to input self.send_signal(self.widget.Inputs.data, self.iris) - self.widget.apply_button.button.click() + self.click_apply() self.assertFalse(self.widget.Error.manifold_error.is_shown()) # Clear input self.send_signal(self.widget.Inputs.data, None) - self.widget.apply_button.button.click() + self.click_apply() self.assertFalse(self.widget.Error.manifold_error.is_shown()) simulate.combobox_run_through_all( @@ -108,7 +185,7 @@ def test_unique_domain(self): simulate.combobox_activate_item(self.widget.manifold_methods_combo, "MDS") data = possible_duplicate_table('C0', class_var=True) self.send_signal(self.widget.Inputs.data, data) - self.widget.apply_button.button.click() + self.click_apply() out = self.get_output(self.widget.Outputs.transformed_data) self.assertTrue(out.domain.attributes[0], 'C0 (1)') @@ -136,7 +213,7 @@ def test_singular_matrices(self): self.widget.manifold_methods_combo.activated.emit(0) # t-SNE self.widget.tsne_editor.metric_combo.activated.emit(4) # Mahalanobis self.assertFalse(self.widget.Error.manifold_error.is_shown()) - self.widget.apply_button.button.click() + self.click_apply() self.assertTrue(self.widget.Error.manifold_error.is_shown()) def test_out_of_memory(self): @@ -149,12 +226,32 @@ def test_out_of_memory(self): mock.side_effect = MemoryError self.send_signal("Data", table) self.widget.manifold_methods_combo.activated.emit(1) - self.widget.apply_button.button.click() + self.click_apply() self.assertTrue(self.widget.Error.out_of_memory.is_shown()) def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_apply') as apply: + with patch.object(self.widget.commit, 'now') as apply: self.widget.auto_apply = False apply.reset_mock() self.send_signal(self.widget.Inputs.data, self.iris) apply.assert_called() + + @patch("Orange.widgets.unsupervised.owmanifoldlearning.OWManifoldLearning.report_items") + def test_report(self, mocked_report: Mock): + for i in range(len(self.widget.MANIFOLD_METHODS)): + self.send_signal(self.widget.Inputs.data, self.iris) + self.widget.manifold_methods_combo.activated.emit(i) + self.wait_until_finished() + self.widget.send_report() + mocked_report.assert_called() + self.assertEqual(mocked_report.call_count, 3) + mocked_report.reset_mock() + + self.send_signal(self.widget.Inputs.data, None) + self.widget.send_report() + self.assertEqual(mocked_report.call_count, 2) + mocked_report.reset_mock() + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/unsupervised/tests/test_owmds.py b/Orange/widgets/unsupervised/tests/test_owmds.py index 104d7129738..2a4c97f9a1a 100644 --- a/Orange/widgets/unsupervised/tests/test_owmds.py +++ b/Orange/widgets/unsupervised/tests/test_owmds.py @@ -1,5 +1,5 @@ # Test methods with long descriptive names can omit docstrings -# pylint: disable=missing-docstring +# pylint: disable=missing-docstring,protected-access import os from itertools import chain import unittest @@ -26,7 +26,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Distances" + cls.signal_name = OWMDS.Inputs.distances cls.signal_data = Euclidean(cls.data) cls.same_input_output_domain = False @@ -53,18 +53,18 @@ def test_plot_once(self): # pylint: disable=arguments-differ """Test if data is plotted only once but committed on every input change""" table = Table("heart_disease") self.widget.setup_plot = Mock() - self.widget.commit = self.widget.unconditional_commit = Mock() + self.widget.commit.deferred = self.widget.commit.now = Mock() self.send_signal(self.widget.Inputs.data, table) - self.widget.commit.reset_mock() + self.widget.commit.deferred.reset_mock() self.wait_until_finished() self.widget.setup_plot.assert_called_once() - self.widget.commit.assert_called_once() + self.widget.commit.deferred.assert_called_once() - self.widget.commit.reset_mock() + self.widget.commit.deferred.reset_mock() self.send_signal(self.widget.Inputs.data_subset, table[::10]) self.wait_until_stop_blocking() self.widget.setup_plot.assert_called_once() - self.widget.commit.assert_called_once() + self.widget.commit.deferred.assert_called_once() def test_pca_init(self): self.send_signal(self.signal_name, self.signal_data) @@ -75,7 +75,7 @@ def test_pca_init(self): [-2.90244761, -0.13630526], [-2.75281107, -0.33854819]] ) - np.testing.assert_array_almost_equal(output.metas[:4, :2], expected) + np.testing.assert_allclose(output.metas[:4, :2], expected, rtol=1e-1, atol=2e-2) def test_nan_plot(self): def combobox_run_through_all(): @@ -92,9 +92,10 @@ def combobox_run_through_all(): self.send_signal(self.widget.Inputs.data, None) combobox_run_through_all() - data.X[:, 0] = np.nan - data.Y[:] = np.nan - data.metas[:, 1] = np.nan + with data.unlocked(): + data.X[:, 0] = np.nan + data.Y[:] = np.nan + data.metas[:, 1] = np.nan self.send_signal(self.widget.Inputs.data, data, wait=1000) combobox_run_through_all() @@ -113,6 +114,21 @@ def test_other_error(self): hook.assert_not_called() self.assertTrue(self.widget.Error.optimization_error.is_shown()) + def test_matrix_not_symmetric(self): + widget = self.widget + self.send_signal(self.widget.Inputs.distances, + DistMatrix([[1, 2, 3], [4, 5, 6]])) + self.assertTrue(widget.Error.matrix_not_symmetric.is_shown()) + self.send_signal(self.widget.Inputs.distances, None) + self.assertFalse(widget.Error.matrix_not_symmetric.is_shown()) + + def test_matrix_too_small(self): + widget = self.widget + self.send_signal(self.widget.Inputs.distances, DistMatrix([[1]])) + self.assertTrue(widget.Error.matrix_too_small.is_shown()) + self.send_signal(self.widget.Inputs.distances, None) + self.assertFalse(widget.Error.matrix_too_small.is_shown()) + def test_distances_without_data_0(self): """ Only distances and no data. @@ -120,7 +136,7 @@ def test_distances_without_data_0(self): """ signal_data = Euclidean(self.data, axis=0) signal_data.row_items = None - self.send_signal("Distances", signal_data) + self.send_signal(self.widget.Inputs.distances, signal_data) def test_distances_without_data_1(self): """ @@ -129,7 +145,7 @@ def test_distances_without_data_1(self): """ signal_data = Euclidean(self.data, axis=1) signal_data.row_items = None - self.send_signal("Distances", signal_data) + self.send_signal(self.widget.Inputs.distances, signal_data) def test_small_data(self): data = self.data[:1] @@ -143,6 +159,7 @@ def test_run(self): self.widget.initialization = 0 self.widget._OWMDS__invalidate_embedding() # pylint: disable=protected-access + @WidgetTest.skipNonEnglish def test_migrate_settings_from_version_1(self): context_settings = [ Context(attributes={'iris': 1, @@ -304,6 +321,27 @@ def test_matrix_columns_default_label(self): label_text = self.widget.controls.attr_label.currentText() self.assertEqual(label_text, "labels") + def test_update_stress(self): + w = self.widget + w.effective_matrix = np.array([[0, 4, 1], + [4, 0, 1], + [1, 1, 0]]) # sum of squares is 36 + w.embedding = np.array([[0, 0], + [0, 3], + [4, 3]]) + # dists [[0, 3, 5], diff [[0, 1, 4], sqr [[0, 1, 16], sum = 52 + # [3, 0, 4], [1, 0, 3], [1, 0, 9], + # [5, 4, 0]] [4, 3, 0]] [16, 9, 0]] + w.update_stress() + expected = np.sqrt(52 / 36) + self.assertAlmostEqual(w._compute_stress(), expected) + self.assertIn(f"{expected:.3f}", w.stress_label.text()) + + w.embedding = None + w.update_stress() + self.assertIsNone(w._compute_stress()) + self.assertIn("-", w.stress_label.text()) + class TestOWMDSRunner(unittest.TestCase): @classmethod @@ -326,7 +364,7 @@ def test_run_mds(self): [-2.9022707, -0.13465859], [-2.75267253, -0.33899134], [-2.74108069, 0.35393209]]) - np.testing.assert_almost_equal(array, result.embedding[:5]) + np.testing.assert_allclose(array, result.embedding[:5], rtol=1e-2, atol=1e-4) state.set_status.assert_called_once_with("Running...") self.assertGreater(state.set_partial_result.call_count, 2) self.assertGreater(state.set_progress_value.call_count, 2) diff --git a/Orange/widgets/unsupervised/tests/test_owpca.py b/Orange/widgets/unsupervised/tests/test_owpca.py index 55ced0798d7..2e3297b214d 100644 --- a/Orange/widgets/unsupervised/tests/test_owpca.py +++ b/Orange/widgets/unsupervised/tests/test_owpca.py @@ -4,16 +4,15 @@ from unittest.mock import patch, Mock import numpy as np +from sklearn.utils import check_random_state +from sklearn.utils.extmath import svd_flip from Orange.data import Table, Domain, ContinuousVariable, TimeVariable from Orange.preprocess import preprocess -from Orange.preprocess.preprocess import Normalize from Orange.widgets.tests.base import WidgetTest from Orange.widgets.tests.utils import table_dense_sparse, possible_duplicate_table from Orange.widgets.unsupervised.owpca import OWPCA from Orange.tests import test_filename -from sklearn.utils import check_random_state -from sklearn.utils.extmath import svd_flip class TestOWPCA(WidgetTest): @@ -27,12 +26,13 @@ def test_set_variance100(self): self.widget._update_selection_variance_spin() def test_constant_data(self): - data = self.iris[::5] - data.X[:, :] = 1.0 + data = self.iris[::5].copy() + with data.unlocked(): + data.X[:, :] = 1.0 # Ignore the warning: the test checks whether the widget shows # Warning.trivial_components when this happens with np.errstate(invalid="ignore"): - self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.data, data, wait=5000) self.assertTrue(self.widget.Warning.trivial_components.is_shown()) self.assertIsNone(self.get_output(self.widget.Outputs.transformed_data)) self.assertIsNone(self.get_output(self.widget.Outputs.components)) @@ -55,27 +55,27 @@ def test_limit_components(self): X = np.random.RandomState(0).rand(101, 101) data = Table.from_numpy(None, X) self.widget.ncomponents = 100 - self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.data, data, wait=5000) tran = self.get_output(self.widget.Outputs.transformed_data) self.assertEqual(len(tran.domain.attributes), 100) self.widget.ncomponents = 101 # should not be accesible with self.assertRaises(IndexError): - self.send_signal(self.widget.Inputs.data, data) + self.widget._setup_plot() # pylint: disable=protected-access def test_migrate_settings_limits_components(self): - settings = dict(ncomponents=10) + settings = {"ncomponents": 10} OWPCA.migrate_settings(settings, 0) self.assertEqual(settings['ncomponents'], 10) - settings = dict(ncomponents=101) + settings = {"ncomponents": 101} OWPCA.migrate_settings(settings, 0) self.assertEqual(settings['ncomponents'], 100) def test_migrate_settings_changes_variance_covered_to_int(self): - settings = dict(variance_covered=17.5) + settings = {"variance_covered": 17.5} OWPCA.migrate_settings(settings, 0) self.assertEqual(settings["variance_covered"], 17) - settings = dict(variance_covered=float('nan')) + settings = {"variance_covered": float('nan')} OWPCA.migrate_settings(settings, 0) self.assertEqual(settings["variance_covered"], 100) @@ -83,9 +83,11 @@ def test_variance_shown(self): self.send_signal(self.widget.Inputs.data, self.iris) self.widget.maxp = 2 self.widget._setup_plot() + self.wait_until_finished() var2 = self.widget.variance_covered self.widget.ncomponents = 3 self.widget._update_selection_component_spin() + self.wait_until_finished() var3 = self.widget.variance_covered self.assertGreater(var3, var2) @@ -95,6 +97,27 @@ def test_unique_domain_components(self): out = self.get_output(self.widget.Outputs.components) self.assertEqual(out.domain.metas[0].name, 'components (1)') + def test_variance_attr(self): + self.widget.ncomponents = 2 + self.send_signal(self.widget.Inputs.data, self.iris, wait=5000) + self.wait_until_stop_blocking() + self.widget._variance_ratio = np.array([0.5, 0.25, 0.2, 0.05]) + self.widget.commit.now() + self.wait_until_finished() + + result = self.get_output(self.widget.Outputs.transformed_data) + pc1, pc2 = result.domain.attributes + self.assertEqual(pc1.attributes["variance"], 0.5) + self.assertEqual(pc2.attributes["variance"], 0.25) + + result = self.get_output(self.widget.Outputs.data) + pc1, pc2 = result.domain.metas + self.assertEqual(pc1.attributes["variance"], 0.5) + self.assertEqual(pc2.attributes["variance"], 0.25) + + result = self.get_output(self.widget.Outputs.components) + np.testing.assert_almost_equal(result.get_column("variance"), [0.5, 0.25]) + def test_sparse_data(self): """Check that PCA returns the same results for both dense and sparse data.""" dense_data, sparse_data = self.iris, self.iris.to_sparse() @@ -141,8 +164,8 @@ def test_normalize_data(self, prepare_table): # Enable checkbox self.widget.controls.normalize.setChecked(True) self.assertTrue(self.widget.controls.normalize.isChecked()) - with patch.object(preprocess, "Normalize", wraps=Normalize) as normalize: - self.send_signal(self.widget.Inputs.data, data) + with patch.object(preprocess.Normalize, "__call__", wraps=lambda x: x) as normalize: + self.send_signal(self.widget.Inputs.data, data, wait=5000) self.wait_until_stop_blocking() self.assertTrue(self.widget.controls.normalize.isEnabled()) normalize.assert_called_once() @@ -150,8 +173,8 @@ def test_normalize_data(self, prepare_table): # Disable checkbox self.widget.controls.normalize.setChecked(False) self.assertFalse(self.widget.controls.normalize.isChecked()) - with patch.object(preprocess, "Normalize", wraps=Normalize) as normalize: - self.send_signal(self.widget.Inputs.data, data) + with patch.object(preprocess.Normalize, "__call__", wraps=lambda x: x) as normalize: + self.send_signal(self.widget.Inputs.data, data, wait=5000) self.wait_until_stop_blocking() self.assertTrue(self.widget.controls.normalize.isEnabled()) normalize.assert_not_called() @@ -164,13 +187,14 @@ def test_normalization_variance(self, prepare_table): # Enable normalization self.widget.controls.normalize.setChecked(True) self.assertTrue(self.widget.normalize) - self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.data, data, wait=5000) self.wait_until_stop_blocking() variance_normalized = self.widget.variance_covered # Disable normalization self.widget.controls.normalize.setChecked(False) self.assertFalse(self.widget.normalize) + self.wait_until_finished() self.wait_until_stop_blocking() variance_unnormalized = self.widget.variance_covered @@ -183,7 +207,8 @@ def test_normalized_gives_correct_result(self, prepare_table): # Randomly set some values to zero random_state = check_random_state(42) mask = random_state.beta(1, 2, size=self.iris.X.shape) > 0.5 - self.iris.X[mask] = 0 + with self.iris.unlocked(): + self.iris.X[mask] = 0 data = prepare_table(self.iris) @@ -198,10 +223,12 @@ def test_normalized_gives_correct_result(self, prepare_table): x = (x - x.mean(0)) / x.std(0) U, S, Va = np.linalg.svd(x) U, S, Va = U[:, :2], S[:2], Va[:2] - U, Va = svd_flip(U, Va) - pca_embedding = U * S + x_pca = U * S - np.testing.assert_almost_equal(widget_result.X, pca_embedding) + x_pca, _ = svd_flip(x_pca, None, u_based_decision=True) + x_widget, _ = svd_flip(widget_result.X.copy(), None, u_based_decision=True) + + np.testing.assert_almost_equal(x_widget, x_pca) def test_do_not_mask_features(self): # the widget used to replace cached variables when creating the @@ -252,6 +279,21 @@ def test_output_data(self): output = self.get_output(widget.Outputs.data) self.assertIsNone(output) + def test_table_subclass(self): + """ + When input table is instance of Table's subclass (e.g. Corpus) resulting + tables should also be an instance subclasses + """ + class TableSub(Table): # pylint: disable=abstract-method + pass + + table_subclass = TableSub(self.iris) + self.send_signal(self.widget.Inputs.data, table_subclass) + data_out = self.get_output(self.widget.Outputs.data) + trans_data_out = self.get_output(self.widget.Outputs.transformed_data) + self.assertIsInstance(data_out, TableSub) + self.assertIsInstance(trans_data_out, TableSub) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/unsupervised/tests/test_owsavedistances.py b/Orange/widgets/unsupervised/tests/test_owsavedistances.py index 09eadea21d0..0e9204bc333 100644 --- a/Orange/widgets/unsupervised/tests/test_owsavedistances.py +++ b/Orange/widgets/unsupervised/tests/test_owsavedistances.py @@ -18,11 +18,11 @@ def setUp(self): self.widget = self.create_widget(OWSaveDistances) self.distances = Euclidean(Table("iris")) - def _save_and_load(self): + def _save_and_load(self, suffix=".dst"): widget = self.widget widget.auto_save = False - with named_file("", suffix=".dst") as filename: + with named_file("", suffix=suffix) as filename: widget.get_save_filename = Mock( return_value=(filename, widget.filters[0])) @@ -102,6 +102,11 @@ def test_save_trivial_labels(self): self.assertFalse(widget.Warning.table_not_saved.is_shown()) self.assertFalse(widget.Warning.part_not_saved.is_shown()) + def test_nonsquare(self): + self.distances = DistMatrix([[1, 2, 3], [4, 5, 6]]) + distances = self._save_and_load(".xlsx") + np.testing.assert_equal(distances, self.distances) + def test_send_report(self): widget = self.widget diff --git a/Orange/widgets/unsupervised/tests/test_owsom.py b/Orange/widgets/unsupervised/tests/test_owsom.py index c3666d463a5..4551efd5203 100644 --- a/Orange/widgets/unsupervised/tests/test_owsom.py +++ b/Orange/widgets/unsupervised/tests/test_owsom.py @@ -5,11 +5,15 @@ import numpy as np import scipy.sparse as sp +from AnyQt.QtWidgets import QComboBox, QPushButton, QCheckBox +from AnyQt.QtCore import Qt from Orange.data import Table, Domain from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.tests.utils import simulate from Orange.widgets.utils.annotated_data import ANNOTATED_DATA_FEATURE_NAME -from Orange.widgets.unsupervised.owsom import OWSOM, SomView, SOM +from Orange.widgets.unsupervised.owsom import OWSOM, SomView, SOM, \ + SomSharedValueCompute, SomCellCompute, SomCoordsCompute, SomErrorCompute def _patch_recompute_som(meth): @@ -18,11 +22,14 @@ def winners_from_weights(cont_x, *_1, **_2): w = np.zeros((n, 2), dtype=int) w[n // 5:] = [0, 1] w[n // 3:] = [1, 2] - return w + return w, np.arange(n) / n def recompute(self): if not self.data: return + + self.som = Mock() + self.som.winners = winners_from_weights self._assign_instances(None, None) self._redraw() self.update_output() @@ -80,11 +87,12 @@ def test_missing_all_data(self): self.send_signal(widget.Inputs.data, Table("heart_disease")) self.assertTrue(widget.Warning.ignoring_disc_variables.is_shown()) - for i in range(150): - self.iris.X[i, i % 4] = np.nan + with self.iris.unlocked(): + for i in range(150): + self.iris.X[i, i % 4] = np.nan self.send_signal(widget.Inputs.data, self.iris) - self.assertTrue(widget.Error.no_defined_rows.is_shown()) + self.assertTrue(widget.Error.not_enough_data.is_shown()) self.assertFalse(widget.Warning.ignoring_disc_variables.is_shown()) self.assertIsNone(widget.data) self.assertIsNone(widget.cont_x) @@ -94,10 +102,11 @@ def test_missing_all_data(self): def test_missing_some_data(self): widget = self.widget - self.iris.X[:50, 0] = np.nan + with self.iris.unlocked(): + self.iris.X[:50, 0] = np.nan self.send_signal(widget.Inputs.data, self.iris) - self.assertFalse(widget.Error.no_defined_rows.is_shown()) + self.assertFalse(widget.Error.not_enough_data.is_shown()) self.assertTrue(widget.Warning.missing_values.is_shown()) np.testing.assert_almost_equal( widget.data.Y.flatten(), [1] * 50 + [2] * 50) @@ -108,19 +117,47 @@ def test_missing_some_data(self): def test_missing_one_row_data(self): widget = self.widget - self.iris.X[5, 0] = np.nan + with self.iris.unlocked(): + self.iris.X[5, 0] = np.nan self.send_signal(widget.Inputs.data, self.iris) - self.assertFalse(widget.Error.no_defined_rows.is_shown()) + self.assertFalse(widget.Error.not_enough_data.is_shown()) self.assertTrue(widget.Warning.missing_values.is_shown()) self.send_signal(widget.Inputs.data, None) self.assertFalse(widget.Warning.missing_values.is_shown()) + def test_run_actual_optimization(self): + # ther tests that compute something use _patch_recompute_som + self.send_signal(self.widget.Inputs.data, self.iris) + out = self.get_output(self.widget.Outputs.annotated_data) + self.assertEqual(len(out), 150) + + @_patch_recompute_som + def test_single_row_data(self): + widget = self.widget + with self.iris.unlocked(): + self.iris.X[:-1] = np.nan + + self.send_signal(widget.Inputs.data, self.iris) + self.assertTrue(widget.Error.not_enough_data.is_shown()) + + self.send_signal(widget.Inputs.data, Table("heart_disease")) + self.assertFalse(widget.Error.not_enough_data.is_shown()) + self.assertTrue(widget.Warning.ignoring_disc_variables.is_shown()) + + self.send_signal(widget.Inputs.data, self.iris) + self.assertTrue(widget.Error.not_enough_data.is_shown()) + self.assertFalse(widget.Warning.ignoring_disc_variables.is_shown()) + + self.send_signal(widget.Inputs.data, None) + self.assertFalse(widget.Error.not_enough_data.is_shown()) + @_patch_recompute_som def test_sparse_data(self): widget = self.widget - self.iris.X = sp.csc_matrix(self.iris.X) + with self.iris.unlocked(): + self.iris.X = sp.csc_matrix(self.iris.X) # Table.from_table can decide to return dense data with patch.object(Table, "from_table", lambda _, x: x): @@ -131,6 +168,7 @@ def test_sparse_data(self): self.assertTrue(sp.isspmatrix_csr(widget.cont_x)) self.assertEqual(widget.cont_x.shape, (150, 4)) + @_patch_recompute_som def test_auto_compute_dimensions(self): widget = self.widget self.send_signal(widget.Inputs.data, self.iris) @@ -216,6 +254,42 @@ def test_attr_color_change(self): self.assertIsNotNone(widget.thresholds) widget._redraw.assert_called() + def test_colored_circles_with_constant(self): + domain = self.iris.domain + self.widget.pie_charts = False + + with self.iris.unlocked(): + self.iris.X[:, 0] = 1 + self.send_signal(self.widget.Inputs.data, self.iris) + attr0 = domain.attributes[0] + + combo = self.widget.controls.attr_color + simulate.combobox_activate_index(combo, combo.model().indexOf(attr0)) + self.assertIsNotNone(self.widget.colors) + self.assertFalse(self.widget.Warning.no_defined_colors.is_shown()) + + dom1 = Domain(domain.attributes[1:], domain.class_var, + domain.attributes[:1]) + iris = self.iris.transform(dom1).copy() + with iris.unlocked(iris.metas): + iris.metas[::2, 0] = np.nan + self.send_signal(self.widget.Inputs.data, iris) + simulate.combobox_activate_index(combo, combo.model().indexOf(attr0)) + self.assertIsNotNone(self.widget.colors) + self.assertFalse(self.widget.Warning.no_defined_colors.is_shown()) + + iris = self.iris.transform(dom1).copy() + with iris.unlocked(iris.metas): + iris.metas[:, 0] = np.nan + self.send_signal(self.widget.Inputs.data, iris) + simulate.combobox_activate_index(combo, combo.model().indexOf(attr0)) + self.assertIsNone(self.widget.colors) + self.assertTrue(self.widget.Warning.no_defined_colors.is_shown()) + + simulate.combobox_activate_index(combo, 0) + self.assertIsNone(self.widget.colors) + self.assertFalse(self.widget.Warning.no_defined_colors.is_shown()) + @_patch_recompute_som def test_cell_sizes(self): widget = self.widget @@ -386,11 +460,13 @@ def test_pie_charts(self): self.assertEqual(e.y(), y) self.assertEqual(e.r, r / 2) - self.iris.Y[:15] = np.nan + with self.iris.unlocked(): + self.iris.Y[:15] = np.nan self.send_signal(widget.Inputs.data, self.iris) a = widget.elements.childItems()[0] np.testing.assert_equal(a.dist, [0.5, 0, 0, 0.5]) + @_patch_recompute_som def test_get_color_column(self): widget = self.widget @@ -398,28 +474,28 @@ def test_get_color_column(self): domain = table.domain new_domain = Domain( domain.attributes[3:], domain.class_var, domain.attributes[:3]) - new_table = table.transform(new_domain) - new_table.metas = new_table.metas.astype(object) + new_table = table.transform(new_domain).copy() + with new_table.unlocked(new_table.metas): + new_table.metas = new_table.metas.astype(object) self.send_signal(widget.Inputs.data, new_table) # discrete attribute widget.attr_color = domain["rest ECG"] np.testing.assert_equal( widget._get_color_column(), - widget.data.get_column_view("rest ECG")[0].astype(int)) + widget.data.get_column("rest ECG").astype(int)) # discrete meta widget.attr_color = domain["gender"] np.testing.assert_equal( widget._get_color_column(), - widget.data.get_column_view("gender")[0].astype(int)) - + widget.data.get_column("gender").astype(int)) # numeric attribute widget.thresholds = np.array([120, 150]) widget.attr_color = domain["max HR"] for c, d in zip(widget._get_color_column(), - widget.data.get_column_view("max HR")[0]): + widget.data.get_column("max HR")): if d < 120: self.assertEqual(c, 0) if 120 <= d < 150: @@ -431,7 +507,7 @@ def test_get_color_column(self): widget.thresholds = np.array([50, 60]) widget.attr_color = domain["age"] for c, d in zip(widget._get_color_column(), - widget.data.get_column_view("age")[0]): + widget.data.get_column("age")): if d < 50: self.assertEqual(c, 0) if 50 <= d < 60: @@ -441,15 +517,16 @@ def test_get_color_column(self): # discrete meta with missing values widget.attr_color = domain["gender"] - col = widget.data.get_column_view("gender")[0] - col[:5] = np.nan - col = col.copy() + with widget.data.unlocked(): + col = widget.data.get_column("gender", copy=True) + widget.data.metas[:5, 1] = np.nan # gender col[:5] = 2 np.testing.assert_equal(widget._get_color_column(), col) @_patch_recompute_som def test_colored_circles_with_missing_values(self): - self.iris.get_column_view("iris")[0][:5] = np.nan + with self.iris.unlocked(): + self.iris.Y[:6] = np.nan self.send_signal(self.widget.Inputs.data, self.iris) self.assertTrue(self.widget.Warning.missing_colors.is_shown()) @@ -479,24 +556,29 @@ def selm(*cells): m = selm((0, 0)).astype(int) widget.on_selection_change(selm((0, 0))) np.testing.assert_equal(widget.selection, m) + self.assertIsInstance(widget.selection, list) widget.redraw_selection.assert_called_once() widget.update_output.assert_called_once() m = selm((0, 1)).astype(int) widget.on_selection_change(selm((0, 1))) np.testing.assert_equal(widget.selection, m) + self.assertIsInstance(widget.selection, list) m[0, 0] = 1 widget.on_selection_change(selm((0, 0)), SomView.SelectionAddToGroup) np.testing.assert_equal(widget.selection, m) + self.assertIsInstance(widget.selection, list) m[0, 0] = 0 widget.on_selection_change(selm((0, 0)), SomView.SelectionRemove) np.testing.assert_equal(widget.selection, m) + self.assertIsInstance(widget.selection, list) m[0, 0] = 2 widget.on_selection_change(selm((0, 0)), SomView.SelectionNewGroup) np.testing.assert_equal(widget.selection, m) + self.assertIsInstance(widget.selection, list) @_patch_recompute_som def test_on_selection_change_on_empty(self): @@ -505,7 +587,7 @@ def test_on_selection_change_on_empty(self): widget.on_selection_change([]) @_patch_recompute_som - def test_output(self): + def test_output_selection(self): widget = self.widget self.send_signal(self.widget.Inputs.data, self.iris) @@ -513,7 +595,12 @@ def test_output(self): out = self.get_output(widget.Outputs.annotated_data) self.assertEqual(len(out), 150) self.assertTrue( - np.all(out.get_column_view(ANNOTATED_DATA_FEATURE_NAME)[0] == 0)) + np.all(out.get_column(ANNOTATED_DATA_FEATURE_NAME) == 0)) + + self.widget.cells = np.array( + [[[0, 30], [30, 50]] + [[50, 50]] * 6, + [[50, 50], [50, 50], [50, 150]] + [[150, 150]] * 5] + + [[[150, 150]] * 8] * 6) m = np.zeros((widget.size_x, widget.size_y), dtype=bool) m[0, 0] = True @@ -523,7 +610,7 @@ def test_output(self): out = self.get_output(widget.Outputs.annotated_data) np.testing.assert_equal( - out.get_column_view(ANNOTATED_DATA_FEATURE_NAME)[0], + out.get_column(ANNOTATED_DATA_FEATURE_NAME), [1] * 30 + [0] * 120) m[0, 0] = False @@ -534,13 +621,164 @@ def test_output(self): out = self.get_output(widget.Outputs.annotated_data) np.testing.assert_equal( - out.get_column_view(ANNOTATED_DATA_FEATURE_NAME)[0], + out.get_column(ANNOTATED_DATA_FEATURE_NAME), [0] * 30 + [1] * 20 + [2] * 100) self.send_signal(self.widget.Inputs.data, None) self.assertIsNone(self.get_output(widget.Outputs.selected_data)) self.assertIsNone(self.get_output(widget.Outputs.annotated_data)) + @_patch_recompute_som + def test_output_columns(self): + widget = self.widget + self.send_signal(self.widget.Inputs.data, self.iris) + + m = np.zeros((widget.size_x, widget.size_y), dtype=bool) + m[0, 0] = True + m[1, 2] = True + widget.on_selection_change(m) + + out = self.get_output(widget.Outputs.annotated_data) + + np.testing.assert_equal(out.get_column("som_row"), + [1] * 30 + [2] * 20 + [3] * 100) + np.testing.assert_equal(out.get_column("som_col"), + [1] * 50 + [2] * 100) + cell_var = out.domain["som_cell"] + np.testing.assert_equal([cell_var.repr_val(v) + for v in out.get_column("som_cell")], + ["r1c1"] * 30 + ["r2c1"] * 20 + ["r3c2"] * 100) + np.testing.assert_equal(out.get_column("som_error"), + np.arange(150) / 150) + + def test_invalidated(self): + heart = Table("heart_disease") + self.widget._recompute_som = Mock() + + # New data - replot + self.send_signal(self.widget.Inputs.data, heart) + self.widget._recompute_som.assert_called_once() + + # Same data - no replot + self.widget._recompute_som.reset_mock() + self.send_signal(self.widget.Inputs.data, heart) + self.widget._recompute_som.assert_not_called() + + # Same data.X - no replot + domain = heart.domain + domain = Domain(domain.attributes, metas=domain.class_vars) + heart_with_metas = self.iris.transform(domain) + self.widget._recompute_som.reset_mock() + self.send_signal(self.widget.Inputs.data, heart_with_metas) + self.widget._recompute_som.assert_not_called() + + # Different data, same set of cont. vars - no replot + attrs = [a for a in heart.domain.attributes if a.is_continuous] + domain = Domain(attrs) + heart_with_cont_features = self.iris.transform(domain) + self.widget._recompute_som.reset_mock() + self.send_signal(self.widget.Inputs.data, heart_with_cont_features) + self.widget._recompute_som.assert_not_called() + + # Different data.X - replot + domain = Domain(heart.domain.attributes[:5]) + heart_with_less_features = heart.transform(domain) + self.widget._recompute_som.reset_mock() + self.send_signal(self.widget.Inputs.data, heart_with_less_features) + self.widget._recompute_som.assert_called_once() + + def test_modified_info(self): + w = self.widget + self.assertFalse(w.Information.modified.is_shown()) + self.send_signal(w.Inputs.data, self.iris) + self.assertFalse(w.Information.modified.is_shown()) + restart_button = w.controlArea.findChild(QPushButton) + + # modify grid + simulate.combobox_activate_index(w.controlArea.findChild(QComboBox), 1) + self.assertTrue(w.Information.modified.is_shown()) + restart_button.click() + self.assertFalse(w.Information.modified.is_shown()) + + # modify set dimensions automatically + w.controlArea.findChild(QCheckBox).setCheckState(Qt.Unchecked) + self.assertTrue(w.Information.modified.is_shown()) + restart_button.click() + self.assertFalse(w.Information.modified.is_shown()) + + # modify dimension spins + w.spin_x.setValue(7) + self.assertTrue(w.Information.modified.is_shown()) + restart_button.click() + self.assertFalse(w.Information.modified.is_shown()) + + w.spin_y.setValue(7) + self.assertTrue(w.Information.modified.is_shown()) + restart_button.click() + self.assertFalse(w.Information.modified.is_shown()) + + # modify initialization + simulate.combobox_activate_index(w.controlArea.findChildren(QComboBox)[1], 1) + self.assertTrue(w.Information.modified.is_shown()) + restart_button.click() + self.assertFalse(w.Information.modified.is_shown()) + + def test_make_domain_without_class_vars(self): + widget = self.widget + data = self.iris.transform(Domain(self.iris.domain.attributes)) + self.send_signal(self.widget.Inputs.data, data) + + domain = self.get_output((widget.Outputs.annotated_data)).domain + self.assertEqual(domain.attributes, data.domain.attributes) + self.assertEqual(domain.class_var.name, ANNOTATED_DATA_FEATURE_NAME) + self.assertEqual([var.name for var in domain.metas], + ["som_cell", "som_row", "som_col", "som_error"]) + + + +class TestComputeValues(unittest.TestCase): + def test_eq_hash(self): + def equ(obj2): + self.assertEqual(obj1, obj2) + self.assertEqual(hash(obj1), hash(obj2)) + + def neq(obj2): + self.assertNotEqual(obj1, obj2) + self.assertNotEqual(hash(obj1), hash(obj2)) + + som1 = Mock() + som2 = Mock() + domain1 = Table("iris").domain + domain2 = Table("iris")[:, :4].domain + assert domain1 != domain2 + offsets1, scales1 = np.array([1, 2, 3]), np.array([4, 5, 6]) + offsets2, scales2 = np.array([2, 3, 4]), np.array([5, 6, 7]) + + shared1 = SomSharedValueCompute(domain1, som1, offsets1, scales1) + shared2 = SomSharedValueCompute(domain2, som1, offsets1, scales1) + + obj1 = shared1 + equ(SomSharedValueCompute(domain1, som1, offsets1, scales1)) + neq(shared2) + neq(SomSharedValueCompute(domain1, som2, offsets1, scales1)) + neq(SomSharedValueCompute(domain1, som1, offsets2, scales1)) + neq(SomSharedValueCompute(domain1, som1, offsets1, scales2)) + + obj1 = SomCellCompute(shared1, 8, False) + equ(SomCellCompute(shared1, 8, False)) + neq(SomCellCompute(shared2, 8, False)) + neq(SomCellCompute(shared1, 5, False)) + neq(SomCellCompute(shared1, 8, True)) + + obj1 = SomCoordsCompute(shared1, 0) + equ(SomCoordsCompute(shared1, 0)) + neq(SomCoordsCompute(shared2, 0)) + neq(SomCoordsCompute(shared1, 1)) + + obj1 = SomErrorCompute(shared1) + equ(SomErrorCompute(shared1)) + neq(SomErrorCompute(shared2)) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/unsupervised/tests/test_owtsne.py b/Orange/widgets/unsupervised/tests/test_owtsne.py index 19d1a8552ea..8a6f5417f77 100644 --- a/Orange/widgets/unsupervised/tests/test_owtsne.py +++ b/Orange/widgets/unsupervised/tests/test_owtsne.py @@ -1,26 +1,52 @@ +import os + import unittest from unittest.mock import patch, Mock, call import numpy as np +import scipy.sparse as sp +import openTSNE.affinity from Orange.data import DiscreteVariable, ContinuousVariable, Domain, Table +from Orange.distance import Euclidean +from Orange.misc import DistMatrix from Orange.preprocess import Normalize from Orange.projection import manifold, TSNE from Orange.projection.manifold import TSNEModel from Orange.widgets.tests.base import ( WidgetTest, WidgetOutputsTestMixin, ProjectionWidgetTestMixin ) +from Orange.widgets.tests.utils import simulate from Orange.widgets.unsupervised.owtsne import OWtSNE, TSNERunner, Task, prepare_tsne_obj class DummyTSNE(manifold.TSNE): + def compute_affinities(self, X): + + class DummyAffinities(openTSNE.affinity.Affinities): + def __init__(self, data=None, *args, **kwargs): + n_samples = data.shape[0] + self.P = sp.random(n_samples, n_samples, density=0.1) + self.P /= self.P.sum() + + def to_new(self, data, return_distances=False): + ones = np.ones((len(data), 2), float) + if return_distances: + return ones, ones + return ones + + return DummyAffinities(X) + + def compute_initialization(self, X): + return np.ones((X.shape[0], 2), float) + def fit(self, X, Y=None): - return np.ones((len(X), 2), float) + return np.ones((X.shape[0], 2), float) class DummyTSNEModel(manifold.TSNEModel): def transform(self, X, **kwargs): - return np.ones((len(X), 2), float) + return np.ones((X.shape[0], 2), float) def optimize(self, n_iter, **kwargs): return self @@ -33,10 +59,24 @@ def setUpClass(cls): WidgetOutputsTestMixin.init(cls) cls.same_input_output_domain = False - cls.signal_name = "Data" + cls.signal_name = OWtSNE.Inputs.data cls.signal_data = cls.data + cls.iris = Table("iris") + cls.iris_distances = Euclidean(cls.iris) + cls.housing = Table("housing")[:200] + cls.housing_distances = Euclidean(cls.housing) + + # Load distance-only DistMatrix, without accompanying `.row_items` + my_dir = os.path.dirname(__file__) + datasets_dir = os.path.join(my_dir, '..', '..', '..', 'datasets') + cls.datasets_dir = os.path.realpath(datasets_dir) + cls.towns = DistMatrix.from_file( + os.path.join(cls.datasets_dir, "slovenian-towns.dst") + ) + def setUp(self): + super().setUp() # For almost all the tests, we won't need to verify t-SNE validity and # the tests will run much faster if we dummy them out self.tsne = patch("Orange.projection.manifold.TSNE", new=DummyTSNE) @@ -63,6 +103,7 @@ def tearDown(self): except RuntimeError as e: if str(e) != "stop called on unstarted patcher": raise e + super().tearDown() def restore_mocked_functions(self): self.tsne.stop() @@ -220,7 +261,7 @@ def _check_exaggeration(call, exaggeration): def test_plot_once(self): """Test if data is plotted only once but committed on every input change""" self.widget.setup_plot = Mock() - self.widget.commit = self.widget.unconditional_commit = Mock() + self.widget.commit.deferred = self.widget.commit.now = Mock() self.send_signal(self.widget.Inputs.data, self.data) # TODO: The base widget immediately calls `setup_plot` and `commit` @@ -229,18 +270,18 @@ def test_plot_once(self): # so as a temporary fix, we reset the mocks, so they reflect the calls # when the result was available. self.widget.setup_plot.reset_mock() - self.widget.commit.reset_mock() + self.widget.commit.deferred.reset_mock() self.wait_until_finished() self.widget.setup_plot.assert_called_once() - self.widget.commit.assert_called_once() + self.widget.commit.deferred.assert_called_once() - self.widget.commit.reset_mock() + self.widget.commit.deferred.reset_mock() self.send_signal(self.widget.Inputs.data_subset, self.data[::10]) self.wait_until_stop_blocking() self.widget.setup_plot.assert_called_once() - self.widget.commit.assert_called_once() + self.widget.commit.deferred.assert_called_once() def test_modified_info_message_behaviour(self): """Information messages should be cleared if the data changes or if @@ -297,6 +338,14 @@ def test_modified_info_message_behaviour(self): "The information message was not cleared on no data" ) + self.send_signal(self.widget.Inputs.data, self.data) + self.wait_until_stop_blocking() + self.assertFalse( + self.widget.Information.modified.is_shown(), + "The modified info message should be hidden after the widget " + "computes the embedding" + ) + def test_invalidation_flow(self): # pylint: disable=protected-access w = self.widget @@ -304,13 +353,24 @@ def test_invalidation_flow(self): # set global structure "on" (after the embedding is computed) w.controls.multiscale.setChecked(False) self.send_signal(w.Inputs.data, self.data) + + # By default, t-SNE is smart and disables PCA preprocessing if the + # number of features is too low. Since we are testing with the iris + # data set, we want to force t-SNE to use PCA preprocessing. + w.controls.use_pca_preprocessing.setChecked(True) + self.widget.run_button.click() + self.wait_until_finished() self.assertFalse(self.widget.Information.modified.is_shown()) - # All the embedding components should computed + # All the embedding components should be computed + self.assertIsNotNone(w.preprocessed_data) + self.assertIsNotNone(w.normalized_data) self.assertIsNotNone(w.pca_projection) self.assertIsNotNone(w.affinities) self.assertIsNotNone(w.tsne_embedding) # All the invalidation flags should be set to false + self.assertFalse(w._invalidated.preprocessed_data) + self.assertFalse(w._invalidated.normalized_data) self.assertFalse(w._invalidated.pca_projection) self.assertFalse(w._invalidated.affinities) self.assertFalse(w._invalidated.tsne_embedding) @@ -320,12 +380,16 @@ def test_invalidation_flow(self): self.assertTrue(self.widget.Information.modified.is_shown()) # Setting `multiscale` to true should set the invalidate flags for # the affinities and embedding, but not the pca_projection + self.assertFalse(w._invalidated.preprocessed_data) + self.assertFalse(w._invalidated.normalized_data) self.assertFalse(w._invalidated.pca_projection) self.assertTrue(w._invalidated.affinities) self.assertTrue(w._invalidated.tsne_embedding) # The flags should now be set, but the embedding should still be # available when selecting a subset of data and such + self.assertIsNotNone(w.preprocessed_data) + self.assertIsNotNone(w.normalized_data) self.assertIsNotNone(w.pca_projection) self.assertIsNotNone(w.affinities) self.assertIsNotNone(w.tsne_embedding) @@ -333,7 +397,7 @@ def test_invalidation_flow(self): # We should still be able to send a data subset to the input and have # the points be highlighted self.send_signal(w.Inputs.data_subset, self.data[:10]) - self.wait_until_stop_blocking() + self.wait_until_finished() subset = [brush.color().name() == "#46befa" for brush in w.graph.scatterplot_item.data["brush"][:10]] other = [brush.color().name() == "#000000" for brush in @@ -345,31 +409,608 @@ def test_invalidation_flow(self): self.send_signal(w.Inputs.data_subset, None) # Run the optimization - self.widget.run_button.clicked.emit() - self.wait_until_stop_blocking() + self.widget.run_button.click() + self.wait_until_finished() # All of the inavalidation flags should have been cleared self.assertFalse(w._invalidated) + def test_pca_preprocessing_warning_with_large_number_of_features(self): + self.assertFalse( + self.widget.Warning.consider_using_pca_preprocessing.is_shown(), + "The PCA warning should be hidden by default" + ) + + self.widget.controls.use_pca_preprocessing.setChecked(False) + self.assertFalse( + self.widget.Warning.consider_using_pca_preprocessing.is_shown(), + "The PCA warning should be hidden even after toggling options if " + "no data is on input" + ) + + # Setup data classes + x_small = np.random.normal(0, 1, size=(50, 4)) + data_small = Table.from_numpy(Domain.from_numpy(x_small), x_small) + x_large = np.random.normal(0, 1, size=(50, 250)) + data_large = Table.from_numpy(Domain.from_numpy(x_large), x_large) + + # SMALL data with PCA preprocessing ENABLED + self.send_signal(self.widget.Inputs.data, data_small) + self.widget.controls.use_pca_preprocessing.setChecked(True) + self.widget.run_button.click(), self.wait_until_stop_blocking() + self.assertFalse( + self.widget.Warning.consider_using_pca_preprocessing.is_shown(), + "The PCA warning should be hidden when PCA preprocessing enabled " + "when the data has <50 features" + ) + + # SMALL data with PCA preprocessing DISABLED + self.send_signal(self.widget.Inputs.data, data_small) + self.widget.controls.use_pca_preprocessing.setChecked(False) + self.widget.run_button.click(), self.wait_until_stop_blocking() + self.assertFalse( + self.widget.Warning.consider_using_pca_preprocessing.is_shown(), + "The PCA warning should be hidden with disabled PCA preprocessing " + "when the data has <50 features" + ) + + # LARGE data with PCA preprocessing ENABLED + self.send_signal(self.widget.Inputs.data, data_large) + self.widget.controls.use_pca_preprocessing.setChecked(True) + self.widget.run_button.click(), self.wait_until_stop_blocking() + self.assertFalse( + self.widget.Warning.consider_using_pca_preprocessing.is_shown(), + "The PCA warning should be hidden when PCA preprocessing enabled " + "when has >50 features" + ) + + # LARGE data with PCA preprocessing DISABLED + self.send_signal(self.widget.Inputs.data, data_large) + self.widget.controls.use_pca_preprocessing.setChecked(False) + self.widget.run_button.click(), self.wait_until_stop_blocking() + self.assertTrue( + self.widget.Warning.consider_using_pca_preprocessing.is_shown(), + "The PCA warning should be shown when PCA preprocessing disabled " + "when the data has >50 features" + ) + + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse( + self.widget.Information.modified.is_shown(), + "The PCA warning should be cleared when problematic data is removed" + ) + + def test_distance_matrix_not_symmetric(self): + w = self.widget + self.assertFalse(w.Error.distance_matrix_not_symmetric.is_shown()) + + self.send_signal(w.Inputs.distances, DistMatrix([[1, 2, 3], [4, 5, 6]])) + self.assertTrue(w.Error.distance_matrix_not_symmetric.is_shown()) + + self.send_signal(w.Inputs.distances, DistMatrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]])) + self.assertTrue(w.Error.distance_matrix_not_symmetric.is_shown()) + + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.distance_matrix_not_symmetric.is_shown()) + + def test_matrix_too_small(self): + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, DistMatrix([[1]])) + self.assertTrue(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + def test_mismatching_distances_and_data_size(self): + w = self.widget + self.assertFalse(w.Error.dimension_mismatch.is_shown()) + + # Send incompatible combination + self.send_signal(w.Inputs.distances, self.iris_distances) + self.send_signal(w.Inputs.data, self.housing) + self.wait_until_finished() + self.assertTrue(w.Error.dimension_mismatch.is_shown()) + + # Remove offending data + self.send_signal(w.Inputs.data, None) + self.wait_until_finished() + self.assertFalse(w.Error.dimension_mismatch.is_shown()) + + # Send incompatible combination + self.send_signal(w.Inputs.distances, None) + self.send_signal(w.Inputs.distances, self.iris_distances) + self.send_signal(w.Inputs.data, self.housing) + self.wait_until_finished() + + # Remove offending distance matrix + self.send_signal(w.Inputs.distances, None) + self.wait_until_finished() + self.assertFalse(w.Error.dimension_mismatch.is_shown()) + + # Clear any data + self.send_signal(w.Inputs.distances, None) + self.assertFalse(w.Error.dimension_mismatch.is_shown()) + + def test_invalid_distance_matrix_with_valid_data_signal_1(self): + """Provide valid data table and an invalid distance matrix at the + same time.""" + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, DistMatrix([[1]])) + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + + self.assertIsNone(w.graph.scatterplot_item) + self.assertIsNone(self.get_output(w.Outputs.annotated_data)) + + def test_invalid_distance_matrix_with_valid_data_signal_2(self): + """Provide an invalid distance matrix, wait to finish, then provide a + valid data table.""" + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, DistMatrix([[1]])) + self.wait_until_finished() + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + + self.assertIsNone(w.graph.scatterplot_item) + self.assertIsNone(self.get_output(w.Outputs.annotated_data)) + + def test_invalid_distance_matrix_with_valid_data_signal_3(self): + """Provide a valid data table, wait to finish, then provide an + invalid distance matrix.""" + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + self.send_signal(w.Inputs.distances, DistMatrix([[1]])) + self.wait_until_finished() + + self.assertIsNone(w.graph.scatterplot_item) + self.assertIsNone(self.get_output(w.Outputs.annotated_data)) + + def test_valid_distance_matrix_with_mismatching_data_signal_1(self): + """Provide valid distance matrix and a mismatching data table at the + same time.""" + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, self.iris_distances) + self.send_signal(w.Inputs.data, self.iris[:5]) + self.wait_until_finished() + + self.assertIsNone(w.graph.scatterplot_item) + self.assertIsNone(self.get_output(w.Outputs.annotated_data)) + + def test_valid_distance_matrix_with_mismatching_data_signal_2(self): + """Provide valid distance matrix, wait to finish, then provide a + mismatching data table.""" + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, self.iris_distances) + self.wait_until_finished() + self.send_signal(w.Inputs.data, self.iris[:5]) + self.wait_until_finished() + + self.assertIsNone(w.graph.scatterplot_item) + self.assertIsNone(self.get_output(w.Outputs.annotated_data)) + + def test_invalid_combination_followed_by_valid_combination(self): + """Provide valid distance matrix and a mismatching data table at the + same time.""" + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, self.iris_distances) + self.send_signal(w.Inputs.data, self.iris[:5]) + self.wait_until_finished() + + self.assertIsNone(w.graph.scatterplot_item) + self.assertIsNone(self.get_output(w.Outputs.annotated_data)) + + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + self.assertIsNotNone(w.graph.scatterplot_item) + self.assertIsNotNone(self.get_output(w.Outputs.annotated_data)) + + def test_invalid_combination_followed_by_valid_distance_only(self): + """Provide valid distance matrix and a mismatching data table at the + same time.""" + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, self.iris_distances) + self.send_signal(w.Inputs.data, self.iris[:5]) + self.wait_until_finished() + + self.assertIsNone(w.graph.scatterplot_item) + self.assertIsNone(self.get_output(w.Outputs.annotated_data)) + + self.send_signal(w.Inputs.data, None) + self.wait_until_finished() + self.assertIsNotNone(w.graph.scatterplot_item) + self.assertIsNotNone(self.get_output(w.Outputs.annotated_data)) + + def test_invalid_combination_followed_by_valid_data_only(self): + """Provide valid distance matrix and a mismatching data table at the + same time.""" + w = self.widget + self.assertFalse(w.Error.distance_matrix_too_small.is_shown()) + + self.send_signal(w.Inputs.distances, DistMatrix([[1]])) + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + + self.assertIsNone(w.graph.scatterplot_item) + self.assertIsNone(self.get_output(w.Outputs.annotated_data)) + + self.send_signal(w.Inputs.distances, None) + self.wait_until_finished() + self.assertIsNotNone(w.graph.scatterplot_item) + self.assertIsNotNone(self.get_output(w.Outputs.annotated_data)) + + def test_adding_data_table_to_distance_matrix_doesnt_trigger_rerun(self): + """If the embedding is already constructed, and we just update the data + signal, then we don't need to recompute the embedding.""" + w = self.widget + + with patch("Orange.widgets.unsupervised.owtsne.TSNERunner.run", wraps=TSNERunner.run) as runner: + self.send_signal(w.Inputs.distances, Euclidean(self.housing[:150])) + self.wait_until_finished() + + housing_colors = [ + brush.color().name() for brush in + w.graph.scatterplot_item.data["brush"] + ] + + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + iris_colors = [ + brush.color().name() for brush in + w.graph.scatterplot_item.data["brush"] + ] + + # Ensure the colors have changed + self.assertTrue(all(c1 != c2 for c1, c2 in zip(housing_colors, iris_colors))) + # And that the embedding has not been recomputed + runner.assert_called_once() + + def test_data_change_doesnt_crash(self): + w = self.widget + + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + + self.send_signal(w.Inputs.data, self.housing) + self.wait_until_finished() + + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + + def test_distance_change_doesnt_crash(self): + w = self.widget + + self.send_signal(w.Inputs.distances, self.iris_distances) + self.wait_until_finished() + + self.send_signal(w.Inputs.distances, self.housing_distances) + self.wait_until_finished() + + self.send_signal(w.Inputs.distances, self.iris_distances) + self.wait_until_finished() + + def test_distances_without_data_axis_0(self): + w = self.widget + signal_data = Euclidean(self.data, axis=0) + signal_data.row_items = None + self.send_signal(w.Inputs.distances, signal_data) + + def test_distances_without_data_axis_1(self): + signal_data = Euclidean(self.data, axis=1) + signal_data.row_items = None + self.send_signal("Distances", signal_data) + + def test_data_table_with_no_attributes_with_distance_matrix_works(self): + w = self.widget + self.send_signal(w.Inputs.distances, self.towns) + self.wait_until_finished() + # No errors should be shown + self.assertEqual(len(w.Error.active), 0) + + def test_controls_are_properly_disabled_with_distance_matrix_1(self): + """Send both signals first, then disconnect distances.""" + w = self.widget + + disabled_fields = [ + "normalize", "use_pca_preprocessing", "pca_components", + "initialization_method_idx", "distance_metric_idx", + ] + + self.send_signal(w.Inputs.distances, self.iris_distances) + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + + for field in disabled_fields: + self.assertFalse(getattr(w.controls, field).isEnabled()) + + # Remove distance matrix, can use data, so fields should be enabled + self.send_signal(w.Inputs.distances, None) + # Ensure PCA checkbox is ticked, to enable slider + w.controls.use_pca_preprocessing.setChecked(True) + self.wait_until_finished() + + for field in disabled_fields: + self.assertTrue(getattr(w.controls, field).isEnabled()) + + def test_controls_are_properly_disabled_with_distance_matrix_2(self): + """Send distances first, disconnect distances, then send data.""" + w = self.widget + + disabled_fields = [ + "normalize", "use_pca_preprocessing", "pca_components", + "initialization_method_idx", "distance_metric_idx", + ] + + self.send_signal(w.Inputs.distances, self.iris_distances) + self.wait_until_finished() + + for field in disabled_fields: + self.assertFalse(getattr(w.controls, field).isEnabled()) + + # Remove distance matrix + self.send_signal(w.Inputs.distances, None) + self.wait_until_finished() + # Send data + self.send_signal(w.Inputs.data, self.iris) + # Ensure PCA checkbox is ticked, to enable slider + w.controls.use_pca_preprocessing.setChecked(True) + self.wait_until_finished() + + # Should now be enabled + for field in disabled_fields: + self.assertTrue(getattr(w.controls, field).isEnabled()) + + def test_controls_ignored_by_distance_matrix_retain_values_on_table_signal(self): + """The controls for `normalize`, `pca_preprocessing`, `metric`, and + `initialization` are overridden/ignored when using a distance matrix + signal. However, we want to remember their values when using Data + table signals.""" + w = self.widget + + # SEND IRIS DATA + # Set some parameters + self.send_signal(w.Inputs.data, self.iris) + w.normalize_cbx.setChecked(False) + w.pca_preprocessing_cbx.setChecked(True) + w.pca_component_slider.setValue(3) + simulate.combobox_activate_index(w.initialization_combo, 0) + simulate.combobox_activate_index(w.distance_metric_combo, 2) + w.perplexity_spin.setValue(42) + + # Disconnect data + self.send_signal(w.Inputs.data, None) + + # SEND IRIS DISTANCES + self.send_signal(w.Inputs.distances, self.iris_distances) + # Check that distance-related controls are disabled + self.assertFalse(w.normalize_cbx.isEnabled()) + + self.assertFalse(w.pca_preprocessing_cbx.isEnabled()) + self.assertFalse(w.pca_component_slider.isEnabled()) + + self.assertFalse(w.initialization_combo.isEnabled()) + # Only spectral layout is supported when we have distances + self.assertEqual(w.initialization_combo.currentText(), "Spectral") + + self.assertFalse(w.distance_metric_combo.isEnabled()) + self.assertEqual(w.distance_metric_combo.currentText(), "") + + self.assertTrue(w.perplexity_spin.isEnabled()) + self.assertEqual(w.perplexity_spin.value(), 42) + + # Disconnect distances + self.send_signal(w.Inputs.distances, None) + + # SEND IRIS DATA + # The distance-related settings should be restored from when we sent in + # the data, and not overridden by the settings automatically set by the + # widget when we passed in the distances signal + self.send_signal(w.Inputs.data, self.iris) + + # Check that the parameters are restored + self.assertTrue(w.normalize_cbx.isEnabled()) + self.assertFalse(w.normalize_cbx.isChecked()) + + self.assertTrue(w.pca_preprocessing_cbx.isEnabled()) + self.assertTrue(w.pca_preprocessing_cbx.isChecked()) + self.assertTrue(w.pca_component_slider.isEnabled()) + + self.assertTrue(w.initialization_combo.isEnabled()) + self.assertTrue(w.initialization_combo.currentText(), "PCA") + + self.assertTrue(w.distance_metric_combo.isEnabled()) + self.assertEqual(w.distance_metric_combo.currentIndex(), 2) + + self.assertTrue(w.perplexity_spin.isEnabled()) + self.assertEqual(w.perplexity_spin.value(), 42) + + def test_controls_are_properly_disabled_with_sparse_matrix(self): + w = self.widget + + # Normalizing sparse matrix is disabled, since this would require + # centering + disabled_fields = ["normalize"] + # PCA preprocessing and supported distance metrics are enable for sparse + # matrices + enabled_fields = [ + "use_pca_preprocessing", "distance_metric_idx", "initialization_method_idx" + ] + + self.send_signal(w.Inputs.data, self.iris.to_sparse()) + self.wait_until_finished() + + for field in disabled_fields: + self.assertFalse(getattr(w.controls, field).isEnabled()) + for field in enabled_fields: + self.assertTrue(getattr(w.controls, field).isEnabled()) + + # Send dense table, shoule enable disabled fields + self.send_signal(w.Inputs.data, self.iris) + self.wait_until_finished() + + for field in disabled_fields: + self.assertTrue(getattr(w.controls, field).isEnabled()) + for field in enabled_fields: + self.assertTrue(getattr(w.controls, field).isEnabled()) + + def test_data_containing_nans(self): + x = np.random.normal(0, 1, size=(150, 50)) + # Randomly sprinkle a few NaNs into the matrix + num_nans = 20 + x[np.random.randint(0, 150, num_nans), np.random.randint(0, 50, num_nans)] = np.nan + + nan_data = Table.from_numpy(Domain.from_numpy(x), x) + + w = self.widget + + self.send_signal(w.Inputs.data, nan_data) + self.assertTrue(w.controls.normalize.isChecked()) + self.assertTrue(w.controls.use_pca_preprocessing.isChecked()) + self.widget.run_button.click(), self.wait_until_finished() + + # Disable only normalization + w.controls.normalize.setChecked(False) + self.widget.run_button.click(), self.wait_until_finished() + + # Disable only PCA preprocessing + w.controls.normalize.setChecked(True) + w.controls.use_pca_preprocessing.setChecked(False) + self.widget.run_button.click(), self.wait_until_finished() + + # Disable both normalization and PCA preprocessing + w.controls.normalize.setChecked(False) + w.controls.use_pca_preprocessing.setChecked(False) + self.widget.run_button.click(), self.wait_until_finished() + + def test_output_compute_value(self): + self.send_signal(self.widget.Inputs.data, self.data) + self.wait_until_finished() + + data = self.get_output(self.widget.Outputs.annotated_data) + self.assertIsNotNone(data.domain.metas[0].compute_value) + self.assertIsNotNone(data.domain.metas[1].compute_value) + + transformed = self.data.transform(data.domain) + self.assert_table_equal(transformed[:, ["t-SNE-x", "t-SNE-y"]], + data[:, ["t-SNE-x", "t-SNE-y"]]) + + def test_output_repeating_names(self): + data = self.data.transform(Domain(self.data.domain.attributes + + (ContinuousVariable("t-SNE-x"),))) + self.send_signal(self.widget.Inputs.data, data) + self.wait_until_finished() + + out = self.get_output(self.widget.Outputs.annotated_data) + meta_names = {a.name for a in out.domain.metas} + self.assertIn("t-SNE-x (1)", meta_names) + self.assertIn("t-SNE-y (1)", meta_names) + class TestTSNERunner(unittest.TestCase): @classmethod def setUpClass(cls): cls.data = Table("iris") + cls.distances = Euclidean(cls.data) - def test_run(self): + def test_run_with_normalization_and_pca_preprocessing(self): state = Mock() state.is_interruption_requested = Mock(return_value=False) - task = TSNERunner.run(Task(data=self.data, perplexity=30), state) + task = Task( + normalize=True, + use_pca_preprocessing=True, + data=self.data, + perplexity=30, + initialization_method="pca", + distance_metric="l2", + ) + task = TSNERunner.run(task, state) - self.assertEqual(len(state.set_status.mock_calls), 4) + self.assertEqual(len(state.set_status.mock_calls), 6) state.set_status.assert_has_calls([ + call("Preprocessing data..."), + call("Normalizing data..."), call("Computing PCA..."), + call("Finding nearest neighbors..."), call("Preparing initialization..."), + call("Running optimization..."), + ]) + + self.assertIsInstance(task.normalized_data, Table) + self.assertIsInstance(task.pca_projection, Table) + self.assertIsInstance(task.tsne, TSNE) + self.assertIsInstance(task.tsne_embedding, TSNEModel) + + def test_run_with_normalization(self): + state = Mock() + state.is_interruption_requested = Mock(return_value=False) + + task = Task( + normalize=True, + use_pca_preprocessing=False, + data=self.data, + initialization_method="pca", + distance_metric="l2", + perplexity=30, + ) + task = TSNERunner.run(task, state) + + self.assertEqual(len(state.set_status.mock_calls), 5) + state.set_status.assert_has_calls([ + call("Preprocessing data..."), + call("Normalizing data..."), call("Finding nearest neighbors..."), + call("Preparing initialization..."), call("Running optimization..."), ]) + self.assertIsNone(task.pca_projection, Table) + + self.assertIsInstance(task.normalized_data, Table) + self.assertIsInstance(task.tsne, TSNE) + self.assertIsInstance(task.tsne_embedding, TSNEModel) + + def test_run_with_pca_preprocessing(self): + state = Mock() + state.is_interruption_requested = Mock(return_value=False) + + task = Task( + normalize=False, + use_pca_preprocessing=True, + data=self.data, + initialization_method="pca", + distance_metric="l2", + perplexity=30, + ) + task = TSNERunner.run(task, state) + + self.assertEqual(len(state.set_status.mock_calls), 5) + state.set_status.assert_has_calls([ + call("Preprocessing data..."), + call("Computing PCA..."), + call("Finding nearest neighbors..."), + call("Preparing initialization..."), + call("Running optimization..."), + ]) + state.set_status.assert_has_calls + + self.assertIsNone(task.normalized_data, Table) + self.assertIsInstance(task.pca_projection, Table) self.assertIsInstance(task.tsne, TSNE) self.assertIsInstance(task.tsne_embedding, TSNEModel) @@ -378,11 +1019,24 @@ def test_run_do_not_modify_model_inplace(self): state = Mock() state.is_interruption_requested.return_value = True - task = Task(data=self.data, perplexity=30, multiscale=False, exaggeration=1) + task = Task( + data=self.data, + initialization_method="pca", + distance_metric="l2", + perplexity=30, + multiscale=False, + exaggeration=1, + ) # Run through all the steps to prepare the t-SNE object task.tsne = prepare_tsne_obj( - task.data, task.perplexity, task.multiscale, task.exaggeration + task.data.X.shape[0], + task.initialization_method, + task.distance_metric, + task.perplexity, + task.multiscale, + task.exaggeration, ) + TSNERunner.compute_normalization(task, state) TSNERunner.compute_pca(task, state) TSNERunner.compute_initialization(task, state) TSNERunner.compute_affinities(task, state) @@ -398,6 +1052,155 @@ def test_run_do_not_modify_model_inplace(self): state.set_partial_result.assert_called_once() self.assertIsNot(tsne_obj_before, tsne_obj_after) + def test_run_with_distance_matrix(self): + state = Mock() + state.is_interruption_requested = Mock(return_value=False) + + task = Task( + normalize=False, + use_pca_preprocessing=False, + distance_matrix=self.distances, + perplexity=30, + initialization_method="spectral", + distance_metric="precomputed", + ) + task = TSNERunner.run(task, state) + + self.assertEqual(len(state.set_status.mock_calls), 3) + state.set_status.assert_has_calls([ + call("Finding nearest neighbors..."), + call("Preparing initialization..."), + call("Running optimization..."), + ]) + + self.assertIsNone(task.normalized_data) + self.assertIsNone(task.pca_projection) + self.assertIsInstance(task.initialization, np.ndarray) + self.assertIsInstance(task.tsne, TSNE) + self.assertIsInstance(task.tsne_embedding, TSNEModel) + + def test_task_validation(self): + # distance matrix with no data table + Task( + normalize=True, + use_pca_preprocessing=True, + distance_matrix=self.distances, + perplexity=30, + initialization_method="spectral", + distance_metric="precomputed", + ).validate() + + # both distance matrix and data table are provided + Task( + normalize=True, + use_pca_preprocessing=True, + data=self.data, + distance_matrix=self.distances, + perplexity=30, + initialization_method="spectral", + distance_metric="precomputed", + ).validate() + + # data table with no distance matrix + Task( + normalize=True, + use_pca_preprocessing=True, + data=self.data, + perplexity=30, + initialization_method="pca", + distance_metric="cosine", + ).validate() + + # distance_metric="precomputed" with no distance matrix + with self.assertRaises(Task.ValidationError): + Task( + normalize=True, + use_pca_preprocessing=True, + data=self.data, + perplexity=30, + initialization_method="spectral", + distance_metric="precomputed", + ).validate() + + # initialization_method="pca" with distance matrix + with self.assertRaises(Task.ValidationError): + Task( + normalize=True, + use_pca_preprocessing=True, + data=self.data, + distance_matrix=self.distances, + perplexity=30, + initialization_method="pca", + distance_metric="precomputed", + ).validate() + + # distance_metric="l2" with distance matrix + with self.assertRaises(Task.ValidationError): + Task( + normalize=True, + use_pca_preprocessing=True, + data=self.data, + distance_matrix=self.distances, + perplexity=30, + initialization_method="spectral", + distance_metric="l2", + ).validate() + + def test_run_with_distance_matrix_ignores_preprocessing(self): + state = Mock() + state.is_interruption_requested = Mock(return_value=False) + + task = Task( + normalize=True, + use_pca_preprocessing=True, + distance_matrix=self.distances, + perplexity=30, + initialization_method="spectral", + distance_metric="precomputed", + ) + task = TSNERunner.run(task, state) + + self.assertEqual(len(state.set_status.mock_calls), 3) + state.set_status.assert_has_calls([ + call("Finding nearest neighbors..."), + call("Preparing initialization..."), + call("Running optimization..."), + ]) + + self.assertIsNone(task.normalized_data) + self.assertIsNone(task.pca_projection) + self.assertIsInstance(task.initialization, np.ndarray) + self.assertIsInstance(task.tsne, TSNE) + self.assertIsInstance(task.tsne_embedding, TSNEModel) + + def test_run_with_sparse_matrix_ignores_normalization(self): + state = Mock() + state.is_interruption_requested = Mock(return_value=False) + + task = Task( + normalize=False, + use_pca_preprocessing=True, + data=self.data.to_sparse(), + perplexity=30, + initialization_method="spectral", + distance_metric="cosine", + ) + task = TSNERunner.run(task, state) + self.assertEqual(len(state.set_status.mock_calls), 5) + state.set_status.assert_has_calls([ + call("Preprocessing data..."), + call("Computing PCA..."), + call("Finding nearest neighbors..."), + call("Preparing initialization..."), + call("Running optimization..."), + ]) + + self.assertIsNone(task.normalized_data) + self.assertIsInstance(task.pca_projection, Table) + self.assertIsInstance(task.initialization, np.ndarray) + self.assertIsInstance(task.tsne, TSNE) + self.assertIsInstance(task.tsne_embedding, TSNEModel) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/utils/__init__.py b/Orange/widgets/utils/__init__.py index e974de1cc2f..92fafd4c342 100644 --- a/Orange/widgets/utils/__init__.py +++ b/Orange/widgets/utils/__init__.py @@ -1,16 +1,18 @@ -import enum import inspect import sys from collections import deque +from contextlib import contextmanager +from enum import Enum, IntEnum from typing import ( - TypeVar, Callable, Any, Iterable, Optional, Hashable, Type, Union + TypeVar, Callable, Any, Iterable, Optional, Hashable, Type, Union, TYPE_CHECKING ) from xml.sax.saxutils import escape -from AnyQt.QtCore import QObject - from Orange.data.variable import TimeVariable -from Orange.util import deepgetattr +from Orange.util import deepgetattr, ftry # pylint: disable=unused-import + +if TYPE_CHECKING: + from AnyQt.QtCore import QObject, Qt def vartype(var): @@ -37,9 +39,8 @@ def getdeepattr(obj, attr, *arg, **kwarg): def to_html(s): - return s.replace("<=", "≤").replace(">=", "≥"). \ - replace("<", "<").replace(">", ">").replace("=\\=", "≠") - + return s.replace("<=", "≤").replace(">=", "≥"). \ + replace("<", "<").replace(">", ">").replace("=\\=", "≠") getHtmlCompatibleString = to_html @@ -57,12 +58,11 @@ def get_variable_values_sorted(variable): return variable.values -def dumpObjectTree(obj, _indent=0): +def dumpObjectTree(obj: "QObject", _indent=0): """ Dumps Qt QObject tree. Aids in debugging internals. See also: QObject.dumpObjectTree() """ - assert isinstance(obj, QObject) print('{indent}{type} "{name}"'.format(indent=' ' * (_indent * 4), type=type(obj).__name__, name=obj.objectName()), @@ -92,7 +92,9 @@ def qname(type_: type) -> str: _T1 = TypeVar("_T1") # pylint: disable=invalid-name -_E = TypeVar("_E", bound=enum.Enum) # pylint: disable=invalid-name +_E = TypeVar("_E", bound=Enum) # pylint: disable=invalid-name +_A = TypeVar("_A") # pylint: disable=invalid-name +_B = TypeVar("_B") # pylint: disable=invalid-name def apply_all(seq, op): @@ -150,7 +152,7 @@ def show_part(_point_data, singular, plural, max_shown, _vars): return "" n_vars = len(_vars) if n_vars > max_shown: - cols[-1] = "... and {} others".format(n_vars - max_shown + 1) + cols[-1] = f'... and {n_vars - max_shown + 1} others' return \ "{}:
      ".format(singular if n_vars < 2 else plural) \ + "
      ".join(cols) @@ -159,3 +161,34 @@ def show_part(_point_data, singular, plural, max_shown, _vars): ("Meta", "Metas", 4, domain.metas), ("Feature", "Features", 10, domain.attributes)) return "
      ".join(show_part(row, *columns) for columns in parts) + + +def enum2int(enum: Union[Enum, IntEnum]) -> int: + """ + PyQt5 uses IntEnum like object for settings, for example SortOrder while + PyQt6 uses Enum. PyQt5's IntEnum also does not support value attribute. + This function transform both settings objects to int. + + Parameters + ---------- + enum + IntEnum like object or Enum object with Qt's settings + + Returns + ------- + Settings transformed to int + """ + return int(enum) if isinstance(enum, int) else enum.value + + +@contextmanager +def disconnected(signal, slot, connection_type=None): + if connection_type is None: + from AnyQt.QtCore import Qt + connection_type = Qt.ConnectionType.AutoConnection + + signal.disconnect(slot) + try: + yield + finally: + signal.connect(slot, connection_type) diff --git a/Orange/widgets/utils/_grid_density.cpp b/Orange/widgets/utils/_grid_density.cpp index 8eb1fb74e8e..211be179d12 100644 --- a/Orange/widgets/utils/_grid_density.cpp +++ b/Orange/widgets/utils/_grid_density.cpp @@ -61,8 +61,8 @@ void compute_density(int r, double *gx, double *gy, int n, double *dx, double *d color_decode(ind2color[main_color], rgba+offset); } } - delete color; - delete f; + delete[] color; + delete[] f; } diff --git a/Orange/widgets/utils/annotated_data.py b/Orange/widgets/utils/annotated_data.py index fea91dae790..d03144e3fa8 100644 --- a/Orange/widgets/utils/annotated_data.py +++ b/Orange/widgets/utils/annotated_data.py @@ -1,5 +1,8 @@ +from typing import Union + import numpy as np -from Orange.data import Domain, DiscreteVariable + +from Orange.data import Domain, DiscreteVariable, Table from Orange.data.util import get_unique_names ANNOTATED_DATA_SIGNAL_NAME = "Data" @@ -30,16 +33,29 @@ def add_columns(domain, attributes=(), class_vars=(), metas=()): return Domain(attributes, class_vars, metas) -def _table_with_annotation_column(data, values, column_data, var_name): - var = DiscreteVariable(get_unique_names(data.domain, var_name), values) - class_vars, metas = data.domain.class_vars, data.domain.metas - if not data.domain.class_vars: +def domain_with_annotation_column( + data: Union[Table, Domain], + values=("No", "Yes"), + var_name=ANNOTATED_DATA_FEATURE_NAME): + domain = data if isinstance(data, Domain) else data.domain + var = DiscreteVariable(get_unique_names(domain, var_name), values) + class_vars, metas = domain.class_vars, domain.metas + if not domain.class_vars: class_vars += (var, ) else: metas += (var, ) - domain = Domain(data.domain.attributes, class_vars, metas) + return Domain(domain.attributes, class_vars, metas), var + + +def _table_with_annotation_column(data, values, column_data, var_name): + domain, var = domain_with_annotation_column(data, values, var_name) + if not data.domain.class_vars: + column_data = column_data.reshape((len(data), )) + else: + column_data = column_data.reshape((len(data), 1)) table = data.transform(domain) - table[:, var] = column_data.reshape((len(data), 1)) + with table.unlocked(table.Y if not data.domain.class_vars else table.metas): + table[:, var] = column_data return table @@ -62,17 +78,21 @@ def create_annotated_table(data, selected_indices): data, ("No", "Yes"), annotated, ANNOTATED_DATA_FEATURE_NAME) +def lazy_annotated_table(data, selected_indices): + domain, _ = domain_with_annotation_column(data) + from orangewidget.utils.signals import LazyValue + return LazyValue[Table]( + lambda: create_annotated_table(data, selected_indices), + length=len(data), domain=domain) + + def create_groups_table(data, selection, include_unselected=True, var_name=ANNOTATED_DATA_FEATURE_NAME, values=None): if data is None: return None - max_sel = np.max(selection) - if values is None: - values = ["G{}".format(i + 1) for i in range(max_sel)] - if include_unselected: - values.append("Unselected") + values, max_sel = group_values(selection, include_unselected, values) if include_unselected: # Place Unselected instances in the "last group", so that the group # colors and scatter diagram marker colors will match @@ -85,3 +105,25 @@ def create_groups_table(data, selection, data = data[mask] selection = selection[mask] - 1 return _table_with_annotation_column(data, values, selection, var_name) + + +def lazy_groups_table(data, selection, include_unselected=True, + var_name=ANNOTATED_DATA_FEATURE_NAME, values=None): + length = len(data) if include_unselected else np.sum(selection != 0) + values, _ = group_values(selection, include_unselected, values) + domain, _ = domain_with_annotation_column(data, values, var_name) + from orangewidget.utils.signals import LazyValue + return LazyValue[Table]( + lambda: create_groups_table(data, selection, include_unselected, + var_name, values), + length=length, domain=domain + ) + + +def group_values(selection, include_unselected, values): + max_sel = np.max(selection) + if values is None: + values = ["G{}".format(i + 1) for i in range(max_sel)] + if include_unselected: + values.append("Unselected") + return values, max_sel diff --git a/Orange/widgets/utils/classdensity.py b/Orange/widgets/utils/classdensity.py index 7a5ac3aeb5b..ae0b6b08633 100644 --- a/Orange/widgets/utils/classdensity.py +++ b/Orange/widgets/utils/classdensity.py @@ -59,6 +59,7 @@ def compute_density(x_grid, y_grid, x_data, y_data, rgb_data): # sample k data points from a uniformly spaced g*g grid of buckets def grid_sample(x_data, y_data, k=1000, g=10): + rgen = np.random.RandomState(0) n = len(x_data) min_x, max_x = min(x_data), max(x_data) min_y, max_y = min(y_data), max(y_data) @@ -70,12 +71,12 @@ def grid_sample(x_data, y_data, k=1000, g=10): grid[y][x].append(i) for y in range(g): for x in range(g): - np.random.shuffle(grid[y][x]) + rgen.shuffle(grid[y][x]) sample = [] while len(sample) < k: for y in range(g): for x in range(g): if len(grid[y][x]) != 0: sample.append(grid[y][x].pop()) - np.random.shuffle(sample) + rgen.shuffle(sample) return sample[:k] diff --git a/Orange/widgets/utils/colorbrewer.py b/Orange/widgets/utils/colorbrewer.py deleted file mode 100644 index 30267353f89..00000000000 --- a/Orange/widgets/utils/colorbrewer.py +++ /dev/null @@ -1,668 +0,0 @@ -import warnings - -warnings.warn("Module 'colorbrewer' is obsolete and will be removed.\n" - "Use palettes from 'Orange.widget.utils.colorpalettes'.", - DeprecationWarning) - -colorSchemes = { - 'diverging': { - 'RdYlGn': {3: [(252, 141, 89), (255, 255, 191), (145, 207, 96)], - 4: [(215, 25, 28), (253, 174, 97), (166, 217, 106), - (26, 150, 65)], - 5: [(215, 25, 28), (253, 174, 97), (255, 255, 191), - (166, 217, 106), (26, 150, 65)], - 6: [(215, 48, 39), (252, 141, 89), (254, 224, 139), - (217, 239, 139), (145, 207, 96), (26, 152, 80)], - 7: [(215, 48, 39), (252, 141, 89), (254, 224, 139), - (255, 255, 191), (217, 239, 139), (145, 207, 96), - (26, 152, 80)], - 8: [(215, 48, 39), (244, 109, 67), (253, 174, 97), - (254, 224, 139), (217, 239, 139), (166, 217, 106), - (102, 189, 99), (26, 152, 80)], - 9: [(215, 48, 39), (244, 109, 67), (253, 174, 97), - (254, 224, 139), (255, 255, 191), (217, 239, 139), - (166, 217, 106), (102, 189, 99), (26, 152, 80)], - 10: [(165, 0, 38), (215, 48, 39), (244, 109, 67), - (253, 174, 97), (254, 224, 139), (217, 239, 139), - (166, 217, 106), (102, 189, 99), (26, 152, 80), - (0, 104, 55)], - 11: [(165, 0, 38), (215, 48, 39), (244, 109, 67), - (253, 174, 97), (254, 224, 139), (255, 255, 191), - (217, 239, 139), (166, 217, 106), (102, 189, 99), - (26, 152, 80), (0, 104, 55)]}, - 'PRGn': {3: [(175, 141, 195), (247, 247, 247), (127, 191, 123)], - 4: [(123, 50, 148), (194, 165, 207), (166, 219, 160), - (0, 136, 55)], - 5: [(123, 50, 148), (194, 165, 207), (247, 247, 247), - (166, 219, 160), (0, 136, 55)], - 6: [(118, 42, 131), (175, 141, 195), (231, 212, 232), - (217, 240, 211), (127, 191, 123), (27, 120, 55)], - 7: [(118, 42, 131), (175, 141, 195), (231, 212, 232), - (247, 247, 247), (217, 240, 211), (127, 191, 123), - (27, 120, 55)], - 8: [(118, 42, 131), (153, 112, 171), (194, 165, 207), - (231, 212, 232), (217, 240, 211), (166, 219, 160), - (90, 174, 97), (27, 120, 55)], - 9: [(118, 42, 131), (153, 112, 171), (194, 165, 207), - (231, 212, 232), (247, 247, 247), (217, 240, 211), - (166, 219, 160), (90, 174, 97), (27, 120, 55)], - 10: [(64, 0, 75), (118, 42, 131), (153, 112, 171), - (194, 165, 207), (231, 212, 232), (217, 240, 211), - (166, 219, 160), (90, 174, 97), (27, 120, 55), - (0, 68, 27)], - 11: [(64, 0, 75), (118, 42, 131), (153, 112, 171), - (194, 165, 207), (231, 212, 232), (247, 247, 247), - (217, 240, 211), (166, 219, 160), (90, 174, 97), - (27, 120, 55), (0, 68, 27)]}, - 'RdBu': {3: [(239, 138, 98), (247, 247, 247), (103, 169, 207)], - 4: [(202, 0, 32), (244, 165, 130), (146, 197, 222), - (5, 113, 176)], - 5: [(202, 0, 32), (244, 165, 130), (247, 247, 247), - (146, 197, 222), (5, 113, 176)], - 6: [(178, 24, 43), (239, 138, 98), (253, 219, 199), - (209, 229, 240), (103, 169, 207), (33, 102, 172)], - 7: [(178, 24, 43), (239, 138, 98), (253, 219, 199), - (247, 247, 247), (209, 229, 240), (103, 169, 207), - (33, 102, 172)], - 8: [(178, 24, 43), (214, 96, 77), (244, 165, 130), - (253, 219, 199), (209, 229, 240), (146, 197, 222), - (67, 147, 195), (33, 102, 172)], - 9: [(178, 24, 43), (214, 96, 77), (244, 165, 130), - (253, 219, 199), (247, 247, 247), (209, 229, 240), - (146, 197, 222), (67, 147, 195), (33, 102, 172)], - 10: [(103, 0, 31), (178, 24, 43), (214, 96, 77), - (244, 165, 130), (253, 219, 199), (209, 229, 240), - (146, 197, 222), (67, 147, 195), (33, 102, 172), - (5, 48, 97)], - 11: [(103, 0, 31), (178, 24, 43), (214, 96, 77), - (244, 165, 130), (253, 219, 199), (247, 247, 247), - (209, 229, 240), (146, 197, 222), (67, 147, 195), - (33, 102, 172), (5, 48, 97)]}, - 'RdGy': {3: [(239, 138, 98), (255, 255, 255), (153, 153, 153)], - 4: [(202, 0, 32), (244, 165, 130), (186, 186, 186), - (64, 64, 64)], - 5: [(202, 0, 32), (244, 165, 130), (255, 255, 255), - (186, 186, 186), (64, 64, 64)], - 6: [(178, 24, 43), (239, 138, 98), (253, 219, 199), - (224, 224, 224), (153, 153, 153), (77, 77, 77)], - 7: [(178, 24, 43), (239, 138, 98), (253, 219, 199), - (255, 255, 255), (224, 224, 224), (153, 153, 153), - (77, 77, 77)], - 8: [(178, 24, 43), (214, 96, 77), (244, 165, 130), - (253, 219, 199), (224, 224, 224), (186, 186, 186), - (135, 135, 135), (77, 77, 77)], - 9: [(178, 24, 43), (214, 96, 77), (244, 165, 130), - (253, 219, 199), (255, 255, 255), (224, 224, 224), - (186, 186, 186), (135, 135, 135), (77, 77, 77)], - 10: [(103, 0, 31), (178, 24, 43), (214, 96, 77), - (244, 165, 130), (253, 219, 199), (224, 224, 224), - (186, 186, 186), (135, 135, 135), (77, 77, 77), - (26, 26, 26)], - 11: [(103, 0, 31), (178, 24, 43), (214, 96, 77), - (244, 165, 130), (253, 219, 199), (255, 255, 255), - (224, 224, 224), (186, 186, 186), (135, 135, 135), - (77, 77, 77), (26, 26, 26)]}, - 'RdYlBu': {3: [(252, 141, 89), (255, 255, 191), (145, 191, 219)], - 4: [(215, 25, 28), (253, 174, 97), (171, 217, 233), - (44, 123, 182)], - 5: [(215, 25, 28), (253, 174, 97), (255, 255, 191), - (171, 217, 233), (44, 123, 182)], - 6: [(215, 48, 39), (252, 141, 89), (254, 224, 144), - (224, 243, 248), (145, 191, 219), (69, 117, 180)], - 7: [(215, 48, 39), (252, 141, 89), (254, 224, 144), - (255, 255, 191), (224, 243, 248), (145, 191, 219), - (69, 117, 180)], - 8: [(215, 48, 39), (244, 109, 67), (253, 174, 97), - (254, 224, 144), (224, 243, 248), (171, 217, 233), - (116, 173, 209), (69, 117, 180)], - 9: [(215, 48, 39), (244, 109, 67), (253, 174, 97), - (254, 224, 144), (255, 255, 191), (224, 243, 248), - (171, 217, 233), (116, 173, 209), (69, 117, 180)], - 10: [(165, 0, 38), (215, 48, 39), (244, 109, 67), - (253, 174, 97), (254, 224, 144), (224, 243, 248), - (171, 217, 233), (116, 173, 209), (69, 117, 180), - (49, 54, 149)], - 11: [(165, 0, 38), (215, 48, 39), (244, 109, 67), - (253, 174, 97), (254, 224, 144), (255, 255, 191), - (224, 243, 248), (171, 217, 233), (116, 173, 209), - (69, 117, 180), (49, 54, 149)]}, - 'PiYG': {3: [(233, 163, 201), (247, 247, 247), (161, 215, 106)], - 4: [(208, 28, 139), (241, 182, 218), (184, 225, 134), - (77, 172, 38)], - 5: [(208, 28, 139), (241, 182, 218), (247, 247, 247), - (184, 225, 134), (77, 172, 38)], - 6: [(197, 27, 125), (233, 163, 201), (253, 224, 239), - (230, 245, 208), (161, 215, 106), (77, 146, 33)], - 7: [(197, 27, 125), (233, 163, 201), (253, 224, 239), - (247, 247, 247), (230, 245, 208), (161, 215, 106), - (77, 146, 33)], - 8: [(197, 27, 125), (222, 119, 174), (241, 182, 218), - (253, 224, 239), (230, 245, 208), (184, 225, 134), - (127, 188, 65), (77, 146, 33)], - 9: [(197, 27, 125), (222, 119, 174), (241, 182, 218), - (253, 224, 239), (247, 247, 247), (230, 245, 208), - (184, 225, 134), (127, 188, 65), (77, 146, 33)], - 10: [(142, 1, 82), (197, 27, 125), (222, 119, 174), - (241, 182, 218), (253, 224, 239), (230, 245, 208), - (184, 225, 134), (127, 188, 65), (77, 146, 33), - (39, 100, 25)], - 11: [(142, 1, 82), (197, 27, 125), (222, 119, 174), - (241, 182, 218), (253, 224, 239), (247, 247, 247), - (230, 245, 208), (184, 225, 134), (127, 188, 65), - (77, 146, 33), (39, 100, 25)]}, - 'PuOr': {3: [(241, 163, 64), (247, 247, 247), (153, 142, 195)], - 4: [(230, 97, 1), (253, 184, 99), (178, 171, 210), - (94, 60, 153)], - 5: [(230, 97, 1), (253, 184, 99), (247, 247, 247), - (178, 171, 210), (94, 60, 153)], - 6: [(179, 88, 6), (241, 163, 64), (254, 224, 182), - (216, 218, 235), (153, 142, 195), (84, 39, 136)], - 7: [(179, 88, 6), (241, 163, 64), (254, 224, 182), - (247, 247, 247), (216, 218, 235), (153, 142, 195), - (84, 39, 136)], - 8: [(179, 88, 6), (224, 130, 20), (253, 184, 99), - (254, 224, 182), (216, 218, 235), (178, 171, 210), - (128, 115, 172), (84, 39, 136)], - 9: [(179, 88, 6), (224, 130, 20), (253, 184, 99), - (254, 224, 182), (247, 247, 247), (216, 218, 235), - (178, 171, 210), (128, 115, 172), (84, 39, 136)], - 10: [(127, 59, 8), (179, 88, 6), (224, 130, 20), - (253, 184, 99), (254, 224, 182), (216, 218, 235), - (178, 171, 210), (128, 115, 172), (84, 39, 136), - (45, 0, 75)], - 11: [(127, 59, 8), (179, 88, 6), (224, 130, 20), - (253, 184, 99), (254, 224, 182), (247, 247, 247), - (216, 218, 235), (178, 171, 210), (128, 115, 172), - (84, 39, 136), (45, 0, 75)]}, - 'BrBG': {3: [(216, 179, 101), (245, 245, 245), (90, 180, 172)], - 4: [(166, 97, 26), (223, 194, 125), (128, 205, 193), - (1, 133, 113)], - 5: [(166, 97, 26), (223, 194, 125), (245, 245, 245), - (128, 205, 193), (1, 133, 113)], - 6: [(140, 81, 10), (216, 179, 101), (246, 232, 195), - (199, 234, 229), (90, 180, 172), (1, 102, 94)], - 7: [(140, 81, 10), (216, 179, 101), (246, 232, 195), - (245, 245, 245), (199, 234, 229), (90, 180, 172), - (1, 102, 94)], - 8: [(140, 81, 10), (191, 129, 45), (223, 194, 125), - (246, 232, 195), (199, 234, 229), (128, 205, 193), - (53, 151, 143), (1, 102, 94)], - 9: [(140, 81, 10), (191, 129, 45), (223, 194, 125), - (246, 232, 195), (245, 245, 245), (199, 234, 229), - (128, 205, 193), (53, 151, 143), (1, 102, 94)], - 10: [(84, 48, 5), (140, 81, 10), (191, 129, 45), - (223, 194, 125), (246, 232, 195), (199, 234, 229), - (128, 205, 193), (53, 151, 143), (1, 102, 94), - (0, 60, 48)], - 11: [(84, 48, 5), (140, 81, 10), (191, 129, 45), - (223, 194, 125), (246, 232, 195), (245, 245, 245), - (199, 234, 229), (128, 205, 193), (53, 151, 143), - (1, 102, 94), (0, 60, 48)]}}, - - 'spectral': { - 'Spectral': {3: [(252, 141, 89), (255, 255, 191), (153, 213, 148)], - 4: [(215, 25, 28), (253, 174, 97), (171, 221, 164), - (43, 131, 186)], - 5: [(215, 25, 28), (253, 174, 97), (255, 255, 191), - (171, 221, 164), (43, 131, 186)], - 6: [(213, 62, 79), (252, 141, 89), (254, 224, 139), - (230, 245, 152), (153, 213, 148), (50, 136, 189)], - 7: [(213, 62, 79), (252, 141, 89), (254, 224, 139), - (255, 255, 191), (230, 245, 152), (153, 213, 148), - (50, 136, 189)], - 8: [(213, 62, 79), (244, 109, 67), (253, 174, 97), - (254, 224, 139), (230, 245, 152), (171, 221, 164), - (102, 194, 165), (50, 136, 189)], - 9: [(213, 62, 79), (244, 109, 67), (253, 174, 97), - (254, 224, 139), (255, 255, 191), (230, 245, 152), - (171, 221, 164), (102, 194, 165), (50, 136, 189)], - 10: [(158, 1, 66), (213, 62, 79), (244, 109, 67), - (253, 174, 97), (254, 224, 139), (230, 245, 152), - (171, 221, 164), (102, 194, 165), (50, 136, 189), - (94, 79, 162)], - 11: [(158, 1, 66), (213, 62, 79), (244, 109, 67), - (253, 174, 97), (254, 224, 139), (255, 255, 191), - (230, 245, 152), (171, 221, 164), (102, 194, 165), - (50, 136, 189), (94, 79, 162)]}}, - - 'qualitative': { - 'Pastel2': {3: [(179, 226, 205), (253, 205, 172), (203, 213, 232)], - 4: [(179, 226, 205), (253, 205, 172), (203, 213, 232), - (244, 202, 228)], - 5: [(179, 226, 205), (253, 205, 172), (203, 213, 232), - (244, 202, 228), (230, 245, 201)], - 6: [(179, 226, 205), (253, 205, 172), (203, 213, 232), - (244, 202, 228), (230, 245, 201), (255, 242, 174)], - 7: [(179, 226, 205), (253, 205, 172), (203, 213, 232), - (244, 202, 228), (230, 245, 201), (255, 242, 174), - (241, 226, 204)], - 8: [(179, 226, 205), (253, 205, 172), (203, 213, 232), - (244, 202, 228), (230, 245, 201), (255, 242, 174), - (241, 226, 204), (204, 204, 204)]}, - 'Pastel1': {3: [(251, 180, 174), (179, 205, 227), (204, 235, 197)], - 4: [(251, 180, 174), (179, 205, 227), (204, 235, 197), - (222, 203, 228)], - 5: [(251, 180, 174), (179, 205, 227), (204, 235, 197), - (222, 203, 228), (254, 217, 166)], - 6: [(251, 180, 174), (179, 205, 227), (204, 235, 197), - (222, 203, 228), (254, 217, 166), (255, 255, 204)], - 7: [(251, 180, 174), (179, 205, 227), (204, 235, 197), - (222, 203, 228), (254, 217, 166), (255, 255, 204), - (229, 216, 189)], - 8: [(251, 180, 174), (179, 205, 227), (204, 235, 197), - (222, 203, 228), (254, 217, 166), (255, 255, 204), - (229, 216, 189), (253, 218, 236)], - 9: [(251, 180, 174), (179, 205, 227), (204, 235, 197), - (222, 203, 228), (254, 217, 166), (255, 255, 204), - (229, 216, 189), (253, 218, 236), (242, 242, 242)]}, - 'Dark2': {3: [(27, 158, 119), (217, 95, 2), (117, 112, 179)], - 4: [(27, 158, 119), (217, 95, 2), (117, 112, 179), - (231, 41, 138)], - 5: [(27, 158, 119), (217, 95, 2), (117, 112, 179), - (231, 41, 138), (102, 166, 30)], - 6: [(27, 158, 119), (217, 95, 2), (117, 112, 179), - (231, 41, 138), (102, 166, 30), (230, 171, 2)], - 7: [(27, 158, 119), (217, 95, 2), (117, 112, 179), - (231, 41, 138), (102, 166, 30), (230, 171, 2), - (166, 118, 29)], - 8: [(27, 158, 119), (217, 95, 2), (117, 112, 179), - (231, 41, 138), (102, 166, 30), (230, 171, 2), - (166, 118, 29), (102, 102, 102)]}, - 'Accent': {3: [(127, 201, 127), (190, 174, 212), (253, 192, 134)], - 4: [(127, 201, 127), (190, 174, 212), (253, 192, 134), - (255, 255, 153)], - 5: [(127, 201, 127), (190, 174, 212), (253, 192, 134), - (255, 255, 153), (56, 108, 176)], - 6: [(127, 201, 127), (190, 174, 212), (253, 192, 134), - (255, 255, 153), (56, 108, 176), (240, 2, 127)], - 7: [(127, 201, 127), (190, 174, 212), (253, 192, 134), - (255, 255, 153), (56, 108, 176), (240, 2, 127), - (191, 91, 23)], - 8: [(127, 201, 127), (190, 174, 212), (253, 192, 134), - (255, 255, 153), (56, 108, 176), (240, 2, 127), - (191, 91, 23), (102, 102, 102)]}, - 'Paired': {3: [(166, 206, 227), (31, 120, 180), (178, 223, 138)], - 4: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44)], - 5: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44), (251, 154, 153)], - 6: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44), (251, 154, 153), (227, 26, 28)], - 7: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44), (251, 154, 153), (227, 26, 28), - (253, 191, 111)], - 8: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44), (251, 154, 153), (227, 26, 28), - (253, 191, 111), (255, 127, 0)], - 9: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44), (251, 154, 153), (227, 26, 28), - (253, 191, 111), (255, 127, 0), (202, 178, 214)], - 10: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44), (251, 154, 153), (227, 26, 28), - (253, 191, 111), (255, 127, 0), (202, 178, 214), - (106, 61, 154)], - 11: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44), (251, 154, 153), (227, 26, 28), - (253, 191, 111), (255, 127, 0), (202, 178, 214), - (106, 61, 154), (255, 255, 153)], - 12: [(166, 206, 227), (31, 120, 180), (178, 223, 138), - (51, 160, 44), (251, 154, 153), (227, 26, 28), - (253, 191, 111), (255, 127, 0), (202, 178, 214), - (106, 61, 154), (255, 255, 153), (177, 89, 40)]}, - 'Set1': {3: [(228, 26, 28), (55, 126, 184), (77, 175, 74)], - 4: [(228, 26, 28), (55, 126, 184), (77, 175, 74), - (152, 78, 163)], - 5: [(228, 26, 28), (55, 126, 184), (77, 175, 74), - (152, 78, 163), (255, 127, 0)], - 6: [(228, 26, 28), (55, 126, 184), (77, 175, 74), - (152, 78, 163), (255, 127, 0), (255, 255, 51)], - 7: [(228, 26, 28), (55, 126, 184), (77, 175, 74), - (152, 78, 163), (255, 127, 0), (255, 255, 51), - (166, 86, 40)], - 8: [(228, 26, 28), (55, 126, 184), (77, 175, 74), - (152, 78, 163), (255, 127, 0), (255, 255, 51), - (166, 86, 40), (247, 129, 191)], - 9: [(152, 78, 163), (247, 129, 191), (228, 26, 28), - (55, 126, 184), (77, 175, 74), (255, 127, 0), - (166, 86, 40), (153, 153, 153), (255, 255, 51)]}, - 'Set2': {3: [(102, 194, 165), (252, 141, 98), (141, 160, 203)], - 4: [(102, 194, 165), (252, 141, 98), (141, 160, 203), - (231, 138, 195)], - 5: [(102, 194, 165), (252, 141, 98), (141, 160, 203), - (231, 138, 195), (166, 216, 84)], - 6: [(102, 194, 165), (252, 141, 98), (141, 160, 203), - (231, 138, 195), (166, 216, 84), (255, 217, 47)], - 7: [(102, 194, 165), (252, 141, 98), (141, 160, 203), - (231, 138, 195), (166, 216, 84), (255, 217, 47), - (229, 196, 148)], - 8: [(102, 194, 165), (252, 141, 98), (141, 160, 203), - (231, 138, 195), (166, 216, 84), (255, 217, 47), - (229, 196, 148), (179, 179, 179)]}, - 'Set3': {3: [(141, 211, 199), (255, 255, 179), (190, 186, 218)], - 4: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114)], - 5: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114), (128, 177, 211)], - 6: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114), (128, 177, 211), (253, 180, 98)], - 7: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114), (128, 177, 211), (253, 180, 98), - (179, 222, 105)], - 8: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114), (128, 177, 211), (253, 180, 98), - (179, 222, 105), (252, 205, 229)], - 9: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114), (128, 177, 211), (253, 180, 98), - (179, 222, 105), (252, 205, 229), (217, 217, 217)], - 10: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114), (128, 177, 211), (253, 180, 98), - (179, 222, 105), (252, 205, 229), (217, 217, 217), - (188, 128, 189)], - 11: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114), (128, 177, 211), (253, 180, 98), - (179, 222, 105), (252, 205, 229), (217, 217, 217), - (188, 128, 189), (204, 235, 197)], - 12: [(141, 211, 199), (255, 255, 179), (190, 186, 218), - (251, 128, 114), (128, 177, 211), (253, 180, 98), - (179, 222, 105), (252, 205, 229), (217, 217, 217), - (188, 128, 189), (204, 235, 197), (255, 237, 111)]}}, - - 'sequential': { - 'Reds': {3: [(254, 224, 210), (252, 146, 114), (222, 45, 38)], - 4: [(254, 229, 217), (252, 174, 145), (251, 106, 74), - (203, 24, 29)], - 5: [(254, 229, 217), (252, 174, 145), (251, 106, 74), - (222, 45, 38), (165, 15, 21)], - 6: [(254, 229, 217), (252, 187, 161), (252, 146, 114), - (251, 106, 74), (222, 45, 38), (165, 15, 21)], - 7: [(254, 229, 217), (252, 187, 161), (252, 146, 114), - (251, 106, 74), (239, 59, 44), (203, 24, 29), - (153, 0, 13)], - 8: [(255, 245, 240), (254, 224, 210), (252, 187, 161), - (252, 146, 114), (251, 106, 74), (239, 59, 44), - (203, 24, 29), (153, 0, 13)], - 9: [(255, 245, 240), (254, 224, 210), (252, 187, 161), - (252, 146, 114), (251, 106, 74), (239, 59, 44), - (203, 24, 29), (165, 15, 21), (103, 0, 13)]}, - 'YlOrRd': {3: [(255, 237, 160), (254, 178, 76), (240, 59, 32)], - 4: [(255, 255, 178), (254, 204, 92), (253, 141, 60), - (227, 26, 28)], - 5: [(255, 255, 178), (254, 204, 92), (253, 141, 60), - (240, 59, 32), (189, 0, 38)], - 6: [(255, 255, 178), (254, 217, 118), (254, 178, 76), - (253, 141, 60), (240, 59, 32), (189, 0, 38)], - 7: [(255, 255, 178), (254, 217, 118), (254, 178, 76), - (253, 141, 60), (252, 78, 42), (227, 26, 28), - (177, 0, 38)], - 8: [(255, 255, 204), (255, 237, 160), (254, 217, 118), - (254, 178, 76), (253, 141, 60), (252, 78, 42), - (227, 26, 28), (177, 0, 38)], - 9: [(255, 255, 204), (255, 237, 160), (254, 217, 118), - (254, 178, 76), (253, 141, 60), (252, 78, 42), - (227, 26, 28), (189, 0, 38), (128, 0, 38)]}, - 'RdPu': {3: [(253, 224, 221), (250, 159, 181), (197, 27, 138)], - 4: [(254, 235, 226), (251, 180, 185), (247, 104, 161), - (174, 1, 126)], - 5: [(254, 235, 226), (251, 180, 185), (247, 104, 161), - (197, 27, 138), (122, 1, 119)], - 6: [(254, 235, 226), (252, 197, 192), (250, 159, 181), - (247, 104, 161), (197, 27, 138), (122, 1, 119)], - 7: [(254, 235, 226), (252, 197, 192), (250, 159, 181), - (247, 104, 161), (221, 52, 151), (174, 1, 126), - (122, 1, 119)], - 8: [(255, 247, 243), (253, 224, 221), (252, 197, 192), - (250, 159, 181), (247, 104, 161), (221, 52, 151), - (174, 1, 126), (122, 1, 119)], - 9: [(255, 247, 243), (253, 224, 221), (252, 197, 192), - (250, 159, 181), (247, 104, 161), (221, 52, 151), - (174, 1, 126), (122, 1, 119), (73, 0, 106)]}, - 'YlOrBr': {3: [(255, 247, 188), (254, 196, 79), (217, 95, 14)], - 4: [(255, 255, 212), (254, 217, 142), (254, 153, 41), - (204, 76, 2)], - 5: [(255, 255, 212), (254, 217, 142), (254, 153, 41), - (217, 95, 14), (153, 52, 4)], - 6: [(255, 255, 212), (254, 227, 145), (254, 196, 79), - (254, 153, 41), (217, 95, 14), (153, 52, 4)], - 7: [(255, 255, 212), (254, 227, 145), (254, 196, 79), - (254, 153, 41), (236, 112, 20), (204, 76, 2), - (140, 45, 4)], - 8: [(255, 255, 229), (255, 247, 188), (254, 227, 145), - (254, 196, 79), (254, 153, 41), (236, 112, 20), - (204, 76, 2), (140, 45, 4)], - 9: [(255, 255, 229), (255, 247, 188), (254, 227, 145), - (254, 196, 79), (254, 153, 41), (236, 112, 20), - (204, 76, 2), (153, 52, 4), (102, 37, 6)]}, - 'Greens': {3: [(229, 245, 224), (161, 217, 155), (49, 163, 84)], - 4: [(237, 248, 233), (186, 228, 179), (116, 196, 118), - (35, 139, 69)], - 5: [(237, 248, 233), (186, 228, 179), (116, 196, 118), - (49, 163, 84), (0, 109, 44)], - 6: [(237, 248, 233), (199, 233, 192), (161, 217, 155), - (116, 196, 118), (49, 163, 84), (0, 109, 44)], - 7: [(237, 248, 233), (199, 233, 192), (161, 217, 155), - (116, 196, 118), (65, 171, 93), (35, 139, 69), - (0, 90, 50)], - 8: [(247, 252, 245), (229, 245, 224), (199, 233, 192), - (161, 217, 155), (116, 196, 118), (65, 171, 93), - (35, 139, 69), (0, 90, 50)], - 9: [(247, 252, 245), (229, 245, 224), (199, 233, 192), - (161, 217, 155), (116, 196, 118), (65, 171, 93), - (35, 139, 69), (0, 109, 44), (0, 68, 27)]}, - 'YlGnBu': {3: [(237, 248, 177), (127, 205, 187), (44, 127, 184)], - 4: [(255, 255, 204), (161, 218, 180), (65, 182, 196), - (34, 94, 168)], - 5: [(255, 255, 204), (161, 218, 180), (65, 182, 196), - (44, 127, 184), (37, 52, 148)], - 6: [(255, 255, 204), (199, 233, 180), (127, 205, 187), - (65, 182, 196), (44, 127, 184), (37, 52, 148)], - 7: [(255, 255, 204), (199, 233, 180), (127, 205, 187), - (65, 182, 196), (29, 145, 192), (34, 94, 168), - (12, 44, 132)], - 8: [(255, 255, 217), (237, 248, 177), (199, 233, 180), - (127, 205, 187), (65, 182, 196), (29, 145, 192), - (34, 94, 168), (12, 44, 132)], - 9: [(255, 255, 217), (237, 248, 177), (199, 233, 180), - (127, 205, 187), (65, 182, 196), (29, 145, 192), - (34, 94, 168), (37, 52, 148), (8, 29, 88)]}, - 'GnBu': {3: [(224, 243, 219), (168, 221, 181), (67, 162, 202)], - 4: [(240, 249, 232), (186, 228, 188), (123, 204, 196), - (43, 140, 190)], - 5: [(240, 249, 232), (186, 228, 188), (123, 204, 196), - (67, 162, 202), (8, 104, 172)], - 6: [(240, 249, 232), (204, 235, 197), (168, 221, 181), - (123, 204, 196), (67, 162, 202), (8, 104, 172)], - 7: [(240, 249, 232), (204, 235, 197), (168, 221, 181), - (123, 204, 196), (78, 179, 211), (43, 140, 190), - (8, 88, 158)], - 8: [(247, 252, 240), (224, 243, 219), (204, 235, 197), - (168, 221, 181), (123, 204, 196), (78, 179, 211), - (43, 140, 190), (8, 88, 158)], - 9: [(247, 252, 240), (224, 243, 219), (204, 235, 197), - (168, 221, 181), (123, 204, 196), (78, 179, 211), - (43, 140, 190), (8, 104, 172), (8, 64, 129)]}, - 'BuPu': {3: [(224, 236, 244), (158, 188, 218), (136, 86, 167)], - 4: [(237, 248, 251), (179, 205, 227), (140, 150, 198), - (136, 65, 157)], - 5: [(237, 248, 251), (179, 205, 227), (140, 150, 198), - (136, 86, 167), (129, 15, 124)], - 6: [(237, 248, 251), (191, 211, 230), (158, 188, 218), - (140, 150, 198), (136, 86, 167), (129, 15, 124)], - 7: [(237, 248, 251), (191, 211, 230), (158, 188, 218), - (140, 150, 198), (140, 107, 177), (136, 65, 157), - (110, 1, 107)], - 8: [(247, 252, 253), (224, 236, 244), (191, 211, 230), - (158, 188, 218), (140, 150, 198), (140, 107, 177), - (136, 65, 157), (110, 1, 107)], - 9: [(247, 252, 253), (224, 236, 244), (191, 211, 230), - (158, 188, 218), (140, 150, 198), (140, 107, 177), - (136, 65, 157), (129, 15, 124), (77, 0, 75)]}, - 'Greys': {3: [(240, 240, 240), (189, 189, 189), (99, 99, 99)], - 4: [(247, 247, 247), (204, 204, 204), (150, 150, 150), - (82, 82, 82)], - 5: [(247, 247, 247), (204, 204, 204), (150, 150, 150), - (99, 99, 99), (37, 37, 37)], - 6: [(247, 247, 247), (217, 217, 217), (189, 189, 189), - (150, 150, 150), (99, 99, 99), (37, 37, 37)], - 7: [(247, 247, 247), (217, 217, 217), (189, 189, 189), - (150, 150, 150), (115, 115, 115), (82, 82, 82), - (37, 37, 37)], - 8: [(255, 255, 255), (240, 240, 240), (217, 217, 217), - (189, 189, 189), (150, 150, 150), (115, 115, 115), - (82, 82, 82), (37, 37, 37)], - 9: [(255, 255, 255), (240, 240, 240), (217, 217, 217), - (189, 189, 189), (150, 150, 150), (115, 115, 115), - (82, 82, 82), (37, 37, 37), (0, 0, 0)]}, - 'Oranges': {3: [(254, 230, 206), (253, 174, 107), (230, 85, 13)], - 4: [(254, 237, 222), (253, 190, 133), (253, 141, 60), - (217, 71, 1)], - 5: [(254, 237, 222), (253, 190, 133), (253, 141, 60), - (230, 85, 13), (166, 54, 3)], - 6: [(254, 237, 222), (253, 208, 162), (253, 174, 107), - (253, 141, 60), (230, 85, 13), (166, 54, 3)], - 7: [(254, 237, 222), (253, 208, 162), (253, 174, 107), - (253, 141, 60), (241, 105, 19), (217, 72, 1), - (140, 45, 4)], - 8: [(255, 245, 235), (254, 230, 206), (253, 208, 162), - (253, 174, 107), (253, 141, 60), (241, 105, 19), - (217, 72, 1), (140, 45, 4)], - 9: [(255, 245, 235), (254, 230, 206), (253, 208, 162), - (253, 174, 107), (253, 141, 60), (241, 105, 19), - (217, 72, 1), (166, 54, 3), (127, 39, 4)]}, - 'OrRd': {3: [(254, 232, 200), (253, 187, 132), (227, 74, 51)], - 4: [(254, 240, 217), (253, 204, 138), (252, 141, 89), - (215, 48, 31)], - 5: [(254, 240, 217), (253, 204, 138), (252, 141, 89), - (227, 74, 51), (179, 0, 0)], - 6: [(254, 240, 217), (253, 212, 158), (253, 187, 132), - (252, 141, 89), (227, 74, 51), (179, 0, 0)], - 7: [(254, 240, 217), (253, 212, 158), (253, 187, 132), - (252, 141, 89), (239, 101, 72), (215, 48, 31), - (153, 0, 0)], - 8: [(255, 247, 236), (254, 232, 200), (253, 212, 158), - (253, 187, 132), (252, 141, 89), (239, 101, 72), - (215, 48, 31), (153, 0, 0)], - 9: [(255, 247, 236), (254, 232, 200), (253, 212, 158), - (253, 187, 132), (252, 141, 89), (239, 101, 72), - (215, 48, 31), (179, 0, 0), (127, 0, 0)]}, - 'BuGn': {3: [(229, 245, 249), (153, 216, 201), (44, 162, 95)], - 4: [(237, 248, 251), (178, 226, 226), (102, 194, 164), - (35, 139, 69)], - 5: [(237, 248, 251), (178, 226, 226), (102, 194, 164), - (44, 162, 95), (0, 109, 44)], - 6: [(237, 248, 251), (204, 236, 230), (153, 216, 201), - (102, 194, 164), (44, 162, 95), (0, 109, 44)], - 7: [(237, 248, 251), (204, 236, 230), (153, 216, 201), - (102, 194, 164), (65, 174, 118), (35, 139, 69), - (0, 88, 36)], - 8: [(247, 252, 253), (229, 245, 249), (204, 236, 230), - (153, 216, 201), (102, 194, 164), (65, 174, 118), - (35, 139, 69), (0, 88, 36)], - 9: [(247, 252, 253), (229, 245, 249), (204, 236, 230), - (153, 216, 201), (102, 194, 164), (65, 174, 118), - (35, 139, 69), (0, 109, 44), (0, 68, 27)]}, - 'PuBu': {3: [(236, 231, 242), (166, 189, 219), (43, 140, 190)], - 4: [(241, 238, 246), (189, 201, 225), (116, 169, 207), - (5, 112, 176)], - 5: [(241, 238, 246), (189, 201, 225), (116, 169, 207), - (43, 140, 190), (4, 90, 141)], - 6: [(241, 238, 246), (208, 209, 230), (166, 189, 219), - (116, 169, 207), (43, 140, 190), (4, 90, 141)], - 7: [(241, 238, 246), (208, 209, 230), (166, 189, 219), - (116, 169, 207), (54, 144, 192), (5, 112, 176), - (3, 78, 123)], - 8: [(255, 247, 251), (236, 231, 242), (208, 209, 230), - (166, 189, 219), (116, 169, 207), (54, 144, 192), - (5, 112, 176), (3, 78, 123)], - 9: [(255, 247, 251), (236, 231, 242), (208, 209, 230), - (166, 189, 219), (116, 169, 207), (54, 144, 192), - (5, 112, 176), (4, 90, 141), (2, 56, 88)]}, - 'PuRd': {3: [(231, 225, 239), (201, 148, 199), (221, 28, 119)], - 4: [(241, 238, 246), (215, 181, 216), (223, 101, 176), - (206, 18, 86)], - 5: [(241, 238, 246), (215, 181, 216), (223, 101, 176), - (221, 28, 119), (152, 0, 67)], - 6: [(241, 238, 246), (212, 185, 218), (201, 148, 199), - (223, 101, 176), (221, 28, 119), (152, 0, 67)], - 7: [(241, 238, 246), (212, 185, 218), (201, 148, 199), - (223, 101, 176), (231, 41, 138), (206, 18, 86), - (145, 0, 63)], - 8: [(247, 244, 249), (231, 225, 239), (212, 185, 218), - (201, 148, 199), (223, 101, 176), (231, 41, 138), - (206, 18, 86), (145, 0, 63)], - 9: [(247, 244, 249), (231, 225, 239), (212, 185, 218), - (201, 148, 199), (223, 101, 176), (231, 41, 138), - (206, 18, 86), (152, 0, 67), (103, 0, 31)]}, - 'Blues': {3: [(222, 235, 247), (158, 202, 225), (49, 130, 189)], - 4: [(239, 243, 255), (189, 215, 231), (107, 174, 214), - (33, 113, 181)], - 5: [(239, 243, 255), (189, 215, 231), (107, 174, 214), - (49, 130, 189), (8, 81, 156)], - 6: [(239, 243, 255), (198, 219, 239), (158, 202, 225), - (107, 174, 214), (49, 130, 189), (8, 81, 156)], - 7: [(239, 243, 255), (198, 219, 239), (158, 202, 225), - (107, 174, 214), (66, 146, 198), (33, 113, 181), - (8, 69, 148)], - 8: [(247, 251, 255), (222, 235, 247), (198, 219, 239), - (158, 202, 225), (107, 174, 214), (66, 146, 198), - (33, 113, 181), (8, 69, 148)], - 9: [(247, 251, 255), (222, 235, 247), (198, 219, 239), - (158, 202, 225), (107, 174, 214), (66, 146, 198), - (33, 113, 181), (8, 81, 156), (8, 48, 107)]}, - 'PuBuGn': {3: [(236, 226, 240), (166, 189, 219), (28, 144, 153)], - 4: [(246, 239, 247), (189, 201, 225), (103, 169, 207), - (2, 129, 138)], - 5: [(246, 239, 247), (189, 201, 225), (103, 169, 207), - (28, 144, 153), (1, 108, 89)], - 6: [(246, 239, 247), (208, 209, 230), (166, 189, 219), - (103, 169, 207), (28, 144, 153), (1, 108, 89)], - 7: [(246, 239, 247), (208, 209, 230), (166, 189, 219), - (103, 169, 207), (54, 144, 192), (2, 129, 138), - (1, 100, 80)], - 8: [(255, 247, 251), (236, 226, 240), (208, 209, 230), - (166, 189, 219), (103, 169, 207), (54, 144, 192), - (2, 129, 138), (1, 100, 80)], - 9: [(255, 247, 251), (236, 226, 240), (208, 209, 230), - (166, 189, 219), (103, 169, 207), (54, 144, 192), - (2, 129, 138), (1, 108, 89), (1, 70, 54)]}, - 'YlGn': {3: [(247, 252, 185), (173, 221, 142), (49, 163, 84)], - 4: [(255, 255, 204), (194, 230, 153), (120, 198, 121), - (35, 132, 67)], - 5: [(255, 255, 204), (194, 230, 153), (120, 198, 121), - (49, 163, 84), (0, 104, 55)], - 6: [(255, 255, 204), (217, 240, 163), (173, 221, 142), - (120, 198, 121), (49, 163, 84), (0, 104, 55)], - 7: [(255, 255, 204), (217, 240, 163), (173, 221, 142), - (120, 198, 121), (65, 171, 93), (35, 132, 67), - (0, 90, 50)], - 8: [(255, 255, 229), (247, 252, 185), (217, 240, 163), - (173, 221, 142), (120, 198, 121), (65, 171, 93), - (35, 132, 67), (0, 90, 50)], - 9: [(255, 255, 229), (247, 252, 185), (217, 240, 163), - (173, 221, 142), (120, 198, 121), (65, 171, 93), - (35, 132, 67), (0, 104, 55), (0, 69, 41)]}, - 'Purples': {3: [(239, 237, 245), (188, 189, 220), (117, 107, 177)], - 4: [(242, 240, 247), (203, 201, 226), (158, 154, 200), - (106, 81, 163)], - 5: [(242, 240, 247), (203, 201, 226), (158, 154, 200), - (117, 107, 177), (84, 39, 143)], - 6: [(242, 240, 247), (218, 218, 235), (188, 189, 220), - (158, 154, 200), (117, 107, 177), (84, 39, 143)], - 7: [(242, 240, 247), (218, 218, 235), (188, 189, 220), - (158, 154, 200), (128, 125, 186), (106, 81, 163), - (74, 20, 134)], - 8: [(252, 251, 253), (239, 237, 245), (218, 218, 235), - (188, 189, 220), (158, 154, 200), (128, 125, 186), - (106, 81, 163), (74, 20, 134)], - 9: [(252, 251, 253), (239, 237, 245), (218, 218, 235), - (188, 189, 220), (158, 154, 200), (128, 125, 186), - (106, 81, 163), (84, 39, 143), (63, 0, 125)]}}, - - 'pastels': { - 'Custom': {22: [(230, 230, 250), (238, 223, 204), (255, 248, 220), - (238, 232, 205), (220, 220, 220), (240, 255, 240), - (244, 238, 224), (238, 238, 224), (255, 240, 245), - (255, 250, 205), (240, 240, 230), (245, 255, 250), - (255, 228, 225), (255, 228, 181), (255, 239, 213), - (255, 218, 185), (255, 250, 240), (255, 245, 238), - (245, 245, 245), (255, 255, 240), (248, 248, 255)]} - }} diff --git a/Orange/widgets/utils/colorgradientselection.py b/Orange/widgets/utils/colorgradientselection.py index e96356eaa08..718fdbafac0 100644 --- a/Orange/widgets/utils/colorgradientselection.py +++ b/Orange/widgets/utils/colorgradientselection.py @@ -2,12 +2,13 @@ from AnyQt.QtCore import Qt, QSize, QAbstractItemModel, Property from AnyQt.QtWidgets import ( - QWidget, QSlider, QFormLayout, QComboBox, QStyle, - QHBoxLayout, QLineEdit, QLabel) + QWidget, QSlider, QFormLayout, QComboBox, QStyle, QSizePolicy +) from AnyQt.QtCore import Signal -from AnyQt.QtGui import QFontMetrics, QDoubleValidator from Orange.widgets.utils import itemmodels, colorpalettes +from Orange.widgets.utils.spinbox import DoubleSpinBox, DBL_MIN, DBL_MAX +from Orange.widgets.utils.intervalslider import IntervalSlider class ColorGradientSelection(QWidget): @@ -45,55 +46,37 @@ def __init__(self, *args, thresholds=(0.0, 1.0), center=None, **kwargs): self.gradient_cb.setModel(model) self.gradient_cb.activated[int].connect(self.activated) self.gradient_cb.currentIndexChanged.connect(self.currentIndexChanged) + self.gradient_cb.currentIndexChanged.connect( + self.__update_center_visibility) + form.setWidget(0, QFormLayout.SpanningRole, self.gradient_cb) - if center is not None: - def __on_center_changed(): - self.__center = float(self.center_edit.text() or "0") + def on_center_spin_value_changed(value): + if self.__center != value: + self.__center = value self.centerChanged.emit(self.__center) - self.center_box = QWidget() - center_layout = QHBoxLayout() - self.center_box.setLayout(center_layout) - width = QFontMetrics(self.font()).boundingRect("9999999").width() - self.center_edit = QLineEdit( - text=f"{self.__center}", - maximumWidth=width, placeholderText="0", alignment=Qt.AlignRight) - self.center_edit.setValidator(QDoubleValidator()) - self.center_edit.editingFinished.connect(__on_center_changed) - center_layout.setContentsMargins(0, 0, 0, 0) - center_layout.addStretch(1) - center_layout.addWidget(QLabel("Centered at")) - center_layout.addWidget(self.center_edit) - self.gradient_cb.currentIndexChanged.connect( - self.__update_center_visibility) + if center is not None: + self.center_edit = DoubleSpinBox( + value=self.__center, + minimum=DBL_MIN, maximum=DBL_MAX, minimumStep=0.01, + minimumContentsLenght=8, alignment=Qt.AlignRight, + stepType=DoubleSpinBox.AdaptiveDecimalStepType, + keyboardTracking=False, + sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed), + ) + self.center_edit.valueChanged.connect(on_center_spin_value_changed) else: - self.center_box = None + self.center_edit = None - slider_low = QSlider( - objectName="threshold-low-slider", minimum=0, maximum=100, - value=int(low * 100), orientation=Qt.Horizontal, - tickPosition=QSlider.TicksBelow, pageStep=10, + slider = self.slider = IntervalSlider( + int(low * 100), int(high * 100), minimum=0, maximum=100, + tickPosition=QSlider.NoTicks, toolTip=self.tr("Low gradient threshold"), whatsThis=self.tr("Applying a low threshold will squeeze the " "gradient from the lower end") ) - slider_high = QSlider( - objectName="threshold-low-slider", minimum=0, maximum=100, - value=int(high * 100), orientation=Qt.Horizontal, - tickPosition=QSlider.TicksAbove, pageStep=10, - toolTip=self.tr("High gradient threshold"), - whatsThis=self.tr("Applying a high threshold will squeeze the " - "gradient from the higher end") - ) - form.setWidget(0, QFormLayout.SpanningRole, self.gradient_cb) - if self.center_box: - form.setWidget(1, QFormLayout.SpanningRole, self.center_box) - form.addRow(self.tr("Low:"), slider_low) - form.addRow(self.tr("High:"), slider_high) - self.slider_low = slider_low - self.slider_high = slider_high - self.slider_low.valueChanged.connect(self.__on_slider_low_moved) - self.slider_high.valueChanged.connect(self.__on_slider_high_moved) + form.addRow(self.tr("Range:"), slider) + self.slider.intervalChanged.connect(self.__on_slider_moved) self.setLayout(form) def setModel(self, model: QAbstractItemModel) -> None: @@ -138,35 +121,13 @@ def thresholdHigh(self) -> float: def setThresholdHigh(self, high: float) -> None: self.setThresholds(min(self.__threshold_low, high), high) - def center(self) -> float: - return self.__center - - def setCenter(self, center: float) -> None: - self.__center = center - self.center_edit.setText(f"{center}") - self.centerChanged.emit(center) - thresholdHigh_ = Property( float, thresholdLow, setThresholdLow, notify=thresholdsChanged) - def __on_slider_low_moved(self, value: int) -> None: - high = self.slider_high + def __on_slider_moved(self, low: int, high: int) -> None: old = self.__threshold_low, self.__threshold_high - self.__threshold_low = value / 100. - if value >= high.value(): - self.__threshold_high = value / 100. - high.setSliderPosition(value) - new = self.__threshold_low, self.__threshold_high - if new != old: - self.thresholdsChanged.emit(*new) - - def __on_slider_high_moved(self, value: int) -> None: - low = self.slider_low - old = self.__threshold_low, self.__threshold_high - self.__threshold_high = value / 100. - if low.value() >= value: - self.__threshold_low = value / 100 - low.setSliderPosition(value) + self.__threshold_low = low / 100. + self.__threshold_high = high / 100. new = self.__threshold_low, self.__threshold_high if new != old: self.thresholdsChanged.emit(*new) @@ -179,18 +140,33 @@ def setThresholds(self, low: float, high: float) -> None: if self.__threshold_low != low or self.__threshold_high != high: self.__threshold_high = high self.__threshold_low = low - self.slider_low.setSliderPosition(low * 100) - self.slider_high.setSliderPosition(high * 100) + self.slider.setInterval(int(low * 100), int(high * 100)) self.thresholdsChanged.emit(high, low) def __update_center_visibility(self): - if self.center_box is None: + palette = self.currentData() + if self.center_edit is None or \ + (visible := self.center_edit.parent() is not None) \ + == bool(isinstance(palette, colorpalettes.Palette) + and palette.flags & palette.Flags.Diverging): return + if visible: + self.layout().takeRow(1).labelItem.widget().setParent(None) + self.center_edit.setParent(None) + else: + self.layout().insertRow(1, "Center at:", self.center_edit) - palette = self.currentData() - self.center_box.setVisible( - isinstance(palette, colorpalettes.Palette) - and palette.flags & palette.Flags.Diverging != 0) + + def center(self) -> float: + return self.__center + + def setCenter(self, center: float) -> None: + if self.__center != center: + self.__center = center + self.center_edit.setValue(center) + self.centerChanged.emit(center) + + center_ = Property(float, center, setCenter, notify=centerChanged) def clip(a, amin, amax): diff --git a/Orange/widgets/utils/colorpalette.py b/Orange/widgets/utils/colorpalette.py deleted file mode 100644 index a08472d8c7d..00000000000 --- a/Orange/widgets/utils/colorpalette.py +++ /dev/null @@ -1,1018 +0,0 @@ -import sys -import copy -import math -from numbers import Number -from typing import Iterable -import warnings - -import numpy as np - -from AnyQt.QtGui import ( - QColor, QIcon, QPixmap, QPainter, QPen, QBrush, QLinearGradient, - QPalette, qRgb -) -from AnyQt.QtWidgets import ( - QSizePolicy, QVBoxLayout, QHBoxLayout, QWidget, QDialog, QColorDialog, - QInputDialog, QMessageBox, QScrollArea, QPushButton, QDialogButtonBox, - QListWidgetItem, QFrame, QGraphicsView, QGraphicsScene, QComboBox, - QItemDelegate -) -from AnyQt.QtCore import Qt, QSize, QRectF, pyqtSignal, PYQT_VERSION - -from Orange.widgets import gui -from Orange.widgets.utils import colorbrewer - -warnings.warn( - "Module colorpalette is obsolete; use colorpalettes", DeprecationWarning) - - -DefaultRGBColors = [ - (70, 190, 250), (237, 70, 47), (170, 242, 43), (245, 174, 50), (255, 255, 0), - (255, 0, 255), (0, 255, 255), (128, 0, 255), (0, 128, 255), (255, 223, 128), - (127, 111, 64), (92, 46, 0), (0, 84, 0), (192, 192, 0), (0, 127, 127), - (128, 0, 0), (127, 0, 127)] - -DefaultColorBrewerPalette = { - 3: [(127, 201, 127), (190, 174, 212), (253, 192, 134)], - 4: [(127, 201, 127), (190, 174, 212), (253, 192, 134), (255, 255, 153)], - 5: [(127, 201, 127), (190, 174, 212), (253, 192, 134), (255, 255, 153), - (56, 108, 176)], - 6: [(127, 201, 127), (190, 174, 212), (253, 192, 134), (255, 255, 153), - (56, 108, 176), (240, 2, 127)], - 7: [(127, 201, 127), (190, 174, 212), (253, 192, 134), (255, 255, 153), - (56, 108, 176), (240, 2, 127), (191, 91, 23)], - 8: [(127, 201, 127), (190, 174, 212), (253, 192, 134), (255, 255, 153), - (56, 108, 176), (240, 2, 127), (191, 91, 23), (102, 102, 102)]} - -ColorButtonSize = 25 - - -#A 10X10 single color pixmap -class ColorPixmap(QIcon): - def __init__(self, color=QColor(Qt.white), size=12): - p = QPixmap(size, size) - p.fill(color) - self.color = color - QIcon.__init__(self, p) - - -# a widget for selecting the colors to be used -class ColorPaletteDlg(QDialog, gui.OWComponent): - shemaChanged = pyqtSignal() - - def __init__(self, parent, windowTitle="Color Palette"): - super().__init__(parent, windowTitle=windowTitle) - - if PYQT_VERSION < 0x50000: - gui.OWComponent.__init__(self, None) - - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(4, 4, 4, 4) - - self.contPaletteNames = [] - self.exContPaletteNames = [] - self.discPaletteNames = [] - self.colorButtonNames = [] - self.colorSchemas = [] - self.selectedSchemaIndex = 0 - - self.mainArea = gui.vBox(self, spacing=4) - self.layout().addWidget(self.mainArea) - self.schemaCombo = gui.comboBox( - self.mainArea, self, "selectedSchemaIndex", box="Saved Profiles", - callback=self.paletteSelected) - - self.hbox = gui.hBox(self) - self.okButton = gui.button(self.hbox, self, "OK", self.acceptChanges) - self.cancelButton = gui.button(self.hbox, self, "Cancel", self.reject) - self.setMinimumWidth(230) - self.resize(350, 200) - - def acceptChanges(self): - state = self.getCurrentState() - oldState = self.colorSchemas[self.selectedSchemaIndex][1] - if state == oldState: - QDialog.accept(self) - else: - # if we change the default schema, we must save it under a new name - if self.colorSchemas[self.selectedSchemaIndex][0] == "Default": - if QMessageBox.information( - self, 'Question', - 'The color schema has changed. Save?', - QMessageBox.Yes | QMessageBox.Discard) == QMessageBox.Discard: - QDialog.reject(self) - else: - self.selectedSchemaIndex = self.schemaCombo.count() - 1 - self.schemaCombo.setCurrentIndex(self.selectedSchemaIndex) - self.paletteSelected() - QDialog.accept(self) - # simply save the new users schema - else: - self.colorSchemas[self.selectedSchemaIndex] = \ - [self.colorSchemas[self.selectedSchemaIndex][0], state] - QDialog.accept(self) - - def createBox(self, boxName, boxCaption=None): - box = gui.vBox(self.mainArea, boxCaption) - box.setAlignment(Qt.AlignLeft) - return box - - def createColorButton(self, box, buttonName, buttonCaption, - initialColor=Qt.black): - self.__dict__["butt" + buttonName] = ColorButton(self, box, buttonCaption) - self.__dict__["butt" + buttonName].setColor(QColor(initialColor)) - self.colorButtonNames.append(buttonName) - - def createContinuousPalette(self, paletteName, boxCaption, - passThroughBlack=0, - initialColor1=Qt.blue, initialColor2=Qt.yellow): - buttBox = gui.vBox(self.mainArea, boxCaption) - box = gui.hBox(buttBox) - - def _set(keypart, val): - self.__dict__["cont{}{}".format(paletteName, keypart)] = val - - _set("Left", ColorButton(self, box, color=QColor(initialColor1))) - _set("View", PaletteView(box)) - _set("Right", ColorButton(self, box, color=QColor(initialColor2))) - _set("passThroughBlack", passThroughBlack) - _set("passThroughBlackCheckbox", gui.checkBox( - buttBox, self, "cont" + paletteName + "passThroughBlack", - "Pass through black", callback=self.colorSchemaChange)) - self.contPaletteNames.append(paletteName) - - def createExtendedContinuousPalette( - self, paletteName, boxCaption, - passThroughColors=0, initialColor1=Qt.white, initialColor2=Qt.black, - extendedPassThroughColors=((Qt.red, 1), (Qt.black, 1), (Qt.green, 1))): - buttBox = gui.vBox(self.mainArea, boxCaption) - box = gui.hBox(buttBox) - - def _set(keypart, val): - self.__dict__["exCont{}{}".format(paletteName, keypart)] = val - - _set("Left", ColorButton(self, box, color=QColor(initialColor1))) - _set("View", PaletteView(box)) - _set("Right", ColorButton(self, box, color=QColor(initialColor2))) - _set("passThroughColors", passThroughColors) - _set("passThroughColorsCheckbox", - gui.checkBox(buttBox, self, - "exCont" + paletteName + "passThroughColors", - "Use pass-through colors", - callback=self.colorSchemaChange)) - - box = gui.hBox(buttBox, "Pass-through colors") - for i, (color, check) in enumerate(extendedPassThroughColors): - _set("passThroughColor" + str(i), check) - _set("passThroughColor" + str(i) + "Checkbox", gui.checkBox( - box, self, - "exCont" + paletteName + "passThroughColor" + str(i), - "", tooltip="Use color", callback=self.colorSchemaChange)) - _set("color" + str(i), ColorButton(self, box, color=QColor(color))) - if i < len(extendedPassThroughColors) - 1: - gui.rubber(box) - _set("colorCount", len(extendedPassThroughColors)) - self.exContPaletteNames.append(paletteName) - - - # ##################################################### - # DISCRETE COLOR PALETTE - # ##################################################### - def createDiscretePalette(self, paletteName, boxCaption, rgbColors=DefaultRGBColors): - def _set(keypart, val): - self.__dict__["disc{}{}".format(paletteName, keypart)] = val - - vbox = gui.vBox(self.mainArea, boxCaption) - paletteView = PaletteView(vbox) - paletteView.rgbColors = rgbColors - _set("View", paletteView) - - hbox = gui.hBox(vbox) - _set("EditButt", gui.button( - hbox, self, "Edit palette", self.editPalette, - tooltip="Edit the order and colors of the palette", toggleButton=1)) - _set("LoadButt", gui.button( - hbox, self, "Load palette", self.loadPalette, - tooltip="Load a predefined color palette", toggleButton=1)) - self.discPaletteNames.append(paletteName) - - - def editPalette(self): - def _set(keypart, val): - self.__dict__["disc{}{}".format(paletteName, keypart)] = val - - for paletteName in self.discPaletteNames: - if self.__dict__["disc" + paletteName + "EditButt"].isChecked(): - colors = self.__dict__["disc" + paletteName + "View"].rgbColors - if type(colors) == dict: - colors = colors[max(colors.keys())] - dlg = PaletteEditor(colors, parent=self) - if dlg.exec() and colors != dlg.getRgbColors(): - self.__dict__["disc" + paletteName + "View"].setDiscPalette(dlg.getRgbColors()) - self.__dict__["disc" + paletteName + "EditButt"].setChecked(0) - return - - def loadPalette(self): - for paletteName in self.discPaletteNames: - if self.__dict__["disc" + paletteName + "LoadButt"].isChecked(): - self.__dict__["disc" + paletteName + "LoadButt"].setChecked(0) - dlg = ColorPalleteListing() - if dlg.exec() == QDialog.Accepted: - colors = dlg.selectedColors - self.__dict__["disc" + paletteName + "View"].setDiscPalette(colors) - - - # ##################################################### - - def getCurrentSchemeIndex(self): - return self.selectedSchemaIndex - - def getColor(self, buttonName): - return self.__dict__["butt" + buttonName].getColor() - - def getContinuousPalette(self, paletteName): - c1 = self.__dict__["cont" + paletteName + "Left"].getColor() - c2 = self.__dict__["cont" + paletteName + "Right"].getColor() - b = self.__dict__["cont" + paletteName + "passThroughBlack"] - return ContinuousPaletteGenerator(c1, c2, b) - - def getExtendedContinuousPalette(self, paletteName): - c1 = self.__dict__["exCont" + paletteName + "Left"].getColor() - c2 = self.__dict__["exCont" + paletteName + "Right"].getColor() - colors = self.__dict__["exCont" + paletteName + "passThroughColors"] - if colors: - colors = [self.__dict__["exCont" + paletteName + "color" + str(i)].getColor() - for i in range(self.__dict__["exCont" + paletteName + "colorCount"]) - if self.__dict__["exCont" + paletteName + "passThroughColor" + str(i)]] - return ExtendedContinuousPaletteGenerator(c1, c2, colors or []) - - def getDiscretePalette(self, paletteName): - return ColorPaletteGenerator( - rgb_colors=self.__dict__["disc" + paletteName + "View"].rgbColors) - - def getColorSchemas(self): - return self.colorSchemas - - def getCurrentState(self): - l1 = [(name, self.qRgbFromQColor(self.__dict__["butt" + name].getColor())) - for name in self.colorButtonNames] - l2 = [(name, (self.qRgbFromQColor(self.__dict__["cont" + name + "Left"].getColor()), - self.qRgbFromQColor(self.__dict__["cont" + name + "Right"].getColor()), - self.__dict__["cont" + name + "passThroughBlack"])) - for name in self.contPaletteNames] - l3 = [(name, self.__dict__["disc" + name + "View"].rgbColors) - for name in self.discPaletteNames] - l4 = [(name, (self.qRgbFromQColor(self.__dict__["exCont" + name + "Left"].getColor()), - self.qRgbFromQColor(self.__dict__["exCont" + name + "Right"].getColor()), - self.__dict__["exCont" + name + "passThroughColors"], - [(self.qRgbFromQColor( - self.__dict__["exCont" + name + "color" + str(i)].getColor()), - self.__dict__["exCont" + name + "passThroughColor" + str(i)]) - for i in range(self.__dict__["exCont" + name + "colorCount"])])) - for name in self.exContPaletteNames] - return [l1, l2, l3, l4] - - - def setColorSchemas(self, schemas=None, selectedSchemaIndex=0): - self.schemaCombo.clear() - - if not schemas or type(schemas) != list: - schemas = [("Default", self.getCurrentState())] - - self.colorSchemas = schemas - self.schemaCombo.addItems([s[0] for s in schemas]) - self.schemaCombo.addItem("Save current palette as...") - self.selectedSchemaIndex = selectedSchemaIndex - self.schemaCombo.setCurrentIndex(self.selectedSchemaIndex) - self.paletteSelected() - - def setCurrentState(self, state): - if len(state) > 3: - [buttons, contPalettes, discPalettes, exContPalettes] = state - else: - [buttons, contPalettes, discPalettes] = state - exContPalettes = [] - for (name, but) in buttons: - self.__dict__["butt" + name].setColor(rgbToQColor(but)) - for (name, (l, r, chk)) in contPalettes: - self.__dict__["cont" + name + "Left"].setColor(rgbToQColor(l)) - self.__dict__["cont" + name + "Right"].setColor(rgbToQColor(r)) - self.__dict__["cont" + name + "passThroughBlack"] = chk - self.__dict__["cont" + name + "passThroughBlackCheckbox"].setChecked(chk) - self.__dict__["cont" + name + "View"]\ - .setContPalette(rgbToQColor(l), rgbToQColor(r), chk) - - for (name, rgbColors) in discPalettes: - self.__dict__["disc" + name + "View"].setDiscPalette(rgbColors) - - for name, (l, r, chk, colors) in exContPalettes: - self.__dict__["exCont" + name + "Left"].setColor(rgbToQColor(l)) - self.__dict__["exCont" + name + "Right"].setColor(rgbToQColor(r)) - - self.__dict__["exCont" + name + "passThroughColors"] = chk - self.__dict__["exCont" + name + "passThroughColorsCheckbox"].setChecked(chk) - - colorsList = [] - for i, (color, check) in enumerate(colors): - self.__dict__["exCont" + name + "passThroughColor" + str(i)] = check - self.__dict__["exCont" + name + "passThroughColor" + str(i) + "Checkbox"]\ - .setChecked(check) - self.__dict__["exCont" + name + "color" + str(i)].setColor(rgbToQColor(color)) - if check and chk: - colorsList.append(rgbToQColor(color)) - self.__dict__["exCont" + name + "colorCount"] = \ - self.__dict__.get("exCont" + name + "colorCount", len(colors)) - self.__dict__["exCont" + name + "View"].setExContPalette( - rgbToQColor(l), rgbToQColor(r), colorsList) - - def paletteSelected(self): - if not self.schemaCombo.count(): - return - self.selectedSchemaIndex = self.schemaCombo.currentIndex() - - # if we selected "Save current palette as..." option then add another option to the list - if self.selectedSchemaIndex == self.schemaCombo.count() - 1: - message = "Name the current color settings.\n" \ - "Pressing 'Cancel' will cancel your changes and close the dialog." - ok = 0 - while not ok: - text, ok = QInputDialog.getText(self, "Name Your Color Settings", message) - if (ok): - newName = str(text) - oldNames = [str(self.schemaCombo.itemText(i)).lower() - for i in range(self.schemaCombo.count() - 1)] - if newName.lower() == "default": - ok = False - message = "The 'Default' settings cannot be changed." \ - "Enter a different name:" - elif newName.lower() in oldNames: - index = oldNames.index(newName.lower()) - self.colorSchemas.pop(index) - - if ok: - self.colorSchemas.insert(0, (newName, self.getCurrentState())) - self.schemaCombo.insertItem(0, newName) - self.schemaCombo.setCurrentIndex(0) - self.selectedSchemaIndex = 0 - else: - ok = 1 - # if we pressed cancel we have to select a different item - # then the "Save current palette as..." - state = self.getCurrentState() - self.selectedSchemaIndex = 0 - self.schemaCombo.setCurrentIndex(0) - self.setCurrentState(state) - else: - schema = self.colorSchemas[self.selectedSchemaIndex][1] - self.setCurrentState(schema) - - def qRgbFromQColor(self, qcolor): - return qcolor.rgba() - - def createPalette(self, color1, color2, passThroughBlack, colorNumber=250): - if passThroughBlack: - palette = [qRgb(color1.red() - color1.red() * i * 2. / colorNumber, - color1.green() - color1.green() * i * 2. / colorNumber, - color1.blue() - color1.blue() * i * 2. / colorNumber) - for i in range(colorNumber / 2)] - palette += [qRgb(color2.red() * i * 2. / colorNumber, - color2.green() * i * 2. / colorNumber, - color2.blue() * i * 2. / colorNumber) - for i in range(colorNumber - (colorNumber / 2))] - else: - palette = [qRgb(color1.red() + (color2.red() - color1.red()) * i / colorNumber, - color1.green() + (color2.green() - color1.green()) * i / colorNumber, - color1.blue() + (color2.blue() - color1.blue()) * i / colorNumber) - for i in range(colorNumber)] - return palette - - # this function is called if one of the color buttons was pressed or - # there was any other change of the color palette - def colorSchemaChange(self): - self.setCurrentState(self.getCurrentState()) - self.shemaChanged.emit() - - -class ColorPalleteListing(QDialog): - def __init__(self, parent=None, windowTitle="Color Palette List", - **kwargs): - super().__init__(parent, windowTitle=windowTitle, **kwargs) - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - sa = QScrollArea( - horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, - verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn - ) - sa.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - sa.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) - self.layout().addWidget(sa) - - space = QWidget(self) - space.setLayout(QVBoxLayout()) - sa.setWidget(space) - sa.setWidgetResizable(True) - - self.buttons = [] - self.setMinimumWidth(400) - - box = gui.vBox(space, "Information") - gui.widgetLabel( - box, - '

      This dialog shows a list of predefined ' - 'color palettes
      from colorbrewer.org that can be used ' - 'in Orange.
      You can select a palette by clicking on it.

      ' - ) - - box = gui.vBox(space, "Default Palette") - - butt = _ColorButton( - DefaultRGBColors, flat=True, toolTip="Default color palette", - clicked=self._buttonClicked - ) - box.layout().addWidget(butt) - - self.buttons.append(butt) - - for type in ["Qualitative", "Spectral", "Diverging", "Sequential", "Pastels"]: - colorGroup = colorbrewer.colorSchemes.get(type.lower(), {}) - if colorGroup: - box = gui.vBox(space, type + " Palettes") - items = sorted(colorGroup.items()) - for key, colors in items: - butt = _ColorButton(colors, self, toolTip=key, flat=True, - clicked=self._buttonClicked) - box.layout().addWidget(butt) - self.buttons.append(butt) - - buttons = QDialogButtonBox( - QDialogButtonBox.Cancel, rejected=self.reject - ) - self.layout().addWidget(buttons) - self.selectedColors = None - - def sizeHint(self): - return QSize(300, 400) - - def _buttonClicked(self): - button = self.sender() - self.selectedColors = button.colors - self.accept() - - -class _ColorButton(QPushButton): - def __init__(self, colors, parent=None, **kwargs): - self.colors = colors - super().__init__(parent, **kwargs) - self.setIcon(self._paletteicon(colors, self.sizeHint())) - - def sizeHint(self): - return QSize(320, 40) - - def resizeEvent(self, event): - super().resizeEvent(event) - size = self.size() - self.setIconSize(size - QSize(20, 14)) - self.setIcon(self._paletteicon(self.colors, self.iconSize())) - - def _paletteicon(self, colors, size): - return QIcon( - createDiscPalettePixmap(size.width(), size.height(), colors)) - - -class PaletteEditor(QDialog): - - def __init__(self, rgbColors, parent=None, windowTitle="Palette Editor", - **kwargs): - super().__init__(parent, **kwargs) - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(4, 4, 4, 4) - - hbox = gui.hBox(self, "Information") - gui.widgetLabel( - hbox, - '

      You can reorder colors in the list using the' - '
      buttons on the right or by dragging and dropping the items.' - '
      To change a specific color double click the item in the ' - 'list.

      ') - - hbox = gui.hBox(self, box=True) - self.discListbox = gui.listBox(hbox, self, enableDragDrop=1) - - vbox = gui.vBox(hbox) - buttonUPAttr = gui.button(vbox, self, "", callback=self.moveAttrUP, - tooltip="Move selected colors up") - buttonDOWNAttr = gui.button(vbox, self, "", callback=self.moveAttrDOWN, - tooltip="Move selected colors down") - buttonUPAttr.setIcon(QIcon(gui.resource_filename("icons/Dlg_up3.png"))) - buttonUPAttr.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)) - buttonUPAttr.setMaximumWidth(30) - buttonDOWNAttr.setIcon(QIcon(gui.resource_filename("icons/Dlg_down3.png"))) - buttonDOWNAttr.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)) - buttonDOWNAttr.setMaximumWidth(30) - self.discListbox.itemDoubleClicked.connect(self.changeDiscreteColor) - - box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, - accepted=self.accept, rejected=self.reject) - self.layout().addWidget(box) - - self.discListbox.setIconSize(QSize(25, 25)) - for ind, (r, g, b) in enumerate(rgbColors): - item = QListWidgetItem(ColorPixmap(QColor(r, g, b), 25), "Color %d" % (ind + 1)) - item.rgbColor = (r, g, b) - self.discListbox.addItem(item) - - self.resize(300, 300) - - - def changeDiscreteColor(self, item): - r, g, b = item.rgbColor - color = QColorDialog.getColor(QColor(r, g, b), self) - if color.isValid(): - item.setIcon(ColorPixmap(color, 25)) - item.rgbColor = (color.red(), color.green(), color.blue()) - - - # move selected attribute in "Attribute Order" list one place up - def moveAttrUP(self): - if len(self.discListbox.selectedIndexes()) == 0: return - ind = self.discListbox.selectedIndexes()[0].row() - if ind == 0: return - iconI = self.discListbox.item(ind - 1).icon() - iconII = self.discListbox.item(ind).icon() - self.discListbox.item(ind - 1).setIcon(iconII) - self.discListbox.item(ind).setIcon(iconI) - self.discListbox.item(ind - 1).rgbColor, self.discListbox.item(ind).rgbColor = \ - self.discListbox.item(ind).rgbColor, self.discListbox.item(ind - 1).rgbColor - self.discListbox.setCurrentRow(ind - 1) - - - # move selected attribute in "Attribute Order" list one place down - def moveAttrDOWN(self): - if len(self.discListbox.selectedIndexes()) == 0: return - ind = self.discListbox.selectedIndexes()[0].row() - if ind == self.discListbox.count() - 1: return - iconI = self.discListbox.item(ind + 1).icon() - iconII = self.discListbox.item(ind).icon() - self.discListbox.item(ind + 1).setIcon(iconII) - self.discListbox.item(ind).setIcon(iconI) - self.discListbox.item(ind).rgbColor, self.discListbox.item(ind + 1).rgbColor = \ - self.discListbox.item(ind + 1).rgbColor, self.discListbox.item(ind).rgbColor - self.discListbox.setCurrentRow(ind + 1) - - def getRgbColors(self): - return [self.discListbox.item(i).rgbColor for i in range(self.discListbox.count())] - - -EPS = 7./3 - 4./3 - 1 # http://stackoverflow.com/a/25155518/1090455 -NAN_GREY = (0x88, 0x88, 0x88) - -class GradientPaletteGenerator: - def __init__(self, *colors): - assert len(colors) >= 2 - self.bins = np.linspace(0, 1, len(colors)) - self.deriv = self.bins[1] - self.bins[0] - self.colors = np.array([self.to_rgb_tuple(c) for c in colors], dtype=np.uint8) - - def to_rgb_tuple(self, color): - try: - color = QColor(*color) - except TypeError: - color = QColor(color) - return color.red(), color.green(), color.blue() - - def getRGB(self, values): - """ - Return RGB tuple that matches `value`, which is assumed - to lay within [0, 1]. - """ - values, single = (np.array([values]), True) \ - if isinstance(values, Number) else (values, False) - values = np.clip(values, 0, 1 - EPS) - bin = np.digitize(values, self.bins) - nans = bin >= len(self.bins) - values[nans] = bin[nans] = 0 # just so that the next two lines pass - p = (values - self.bins[bin - 1]) / self.deriv - results = np.round((1 - p) * self.colors[bin - 1].T + p * self.colors[bin].T).T.astype(int) - results[nans] = NAN_GREY - return results[0] if single else results - - def __getitem__(self, values): - if isinstance(values, Number): - return QColor(*self.getRGB(values)) - return [QColor(*c) for c in self.getRGB(values)] - - -class ContinuousPaletteGenerator(GradientPaletteGenerator): - def __init__(self, color1, color2, passThroughBlack): - args = (color1, '#000000', color2) if passThroughBlack else (color1, color2) - super().__init__(*args) - - -class ExtendedContinuousPaletteGenerator: - def __init__(self, color1, color2, passThroughColors): - self.colors = [color1] + passThroughColors + [color2] - self.gammaFunc = lambda x, gamma: \ - ((math.exp(gamma * math.log(2 * x - 1)) - if x > 0.5 else - -math.exp(gamma * math.log(-2 * x + 1)) - if x != 0.5 else 0.0) + 1) / 2.0 - - def getRGB(self, val, gamma=1.0): - index = int(val * (len(self.colors) - 1)) - if index == len(self.colors) - 1: - return (self.colors[-1].red(), self.colors[-1].green(), self.colors[-1].blue()) - else: - red1, green1, blue1 = self.colors[index].red(), \ - self.colors[index].green(), \ - self.colors[index].blue() - red2, green2, blue2 = self.colors[index + 1].red(), \ - self.colors[index + 1].green(), \ - self.colors[index + 1].blue() - x = val * (len(self.colors) - 1) - index - if gamma != 1.0: - x = self.gammaFunc(x, gamma) - return [(c2 - c1) * x + c1 - for c1, c2 in [(red1, red2), (green1, green2), (blue1, blue2)]] - ## if self.passThroughBlack: - ## if val < 0.5: - ## return (self.c1Red - self.c1Red*val*2, - ## self.c1Green - self.c1Green*val*2, - ## self.c1Blue - self.c1Blue*val*2) - ## else: - ## return (self.c2Red*(val-0.5)*2., - ## self.c2Green*(val-0.5)*2., - ## self.c2Blue*(val-0.5)*2.) - ## else: - ## return (self.c1Red + (self.c2Red-self.c1Red)*val, - ## self.c1Green + (self.c2Green-self.c1Green)*val, - ## self.c1Blue + (self.c2Blue-self.c1Blue)*val) - - # val must be between 0 and 1 - def __getitem__(self, val): - return QColor(*self.getRGB(val)) - - -class ColorPaletteGenerator: - - def __init__(self, number_of_colors=0, rgb_colors=DefaultRGBColors): - self.number_of_colors = 0 - self.rgb_colors = rgb_colors - self.rgb_array = [] - if isinstance(rgb_colors, dict): - number_of_colors = max(rgb_colors.keys()) - self.set_number_of_colors(number_of_colors) - - @classmethod - def palette(cls, n): - from Orange.data import DiscreteVariable - if isinstance(n, DiscreteVariable): - n = len(n.values) - return cls(n).getRGB(np.arange(n)) - - def set_number_of_colors(self, number_of_colors=0): - """Change the palette if there are palettes for different number of - colors. Else, just copy colors as numpy array""" - self.number_of_colors = number_of_colors - if isinstance(self.rgb_colors, dict): - number_of_colors = max(3, number_of_colors) - if number_of_colors not in self.rgb_colors: - try: - number_of_colors = min([n for n in self.rgb_colors - if n >= number_of_colors]) - except ValueError: - raise ValueError("Not enough colors") - rgb_colors = self.rgb_colors[number_of_colors] - elif number_of_colors <= len(self.rgb_colors): - rgb_colors = self.rgb_colors - else: - rgb_colors = [] - for i in range(self.number_of_colors): - col = QColor() - col.setHsv(360 / number_of_colors * i, 255, 255) - rgb_colors.append(col.getRgb()[:3]) - self.rgb_array = np.vstack((rgb_colors, [NAN_GREY])).astype(np.uint8) - - def __getitem__(self, value): - if isinstance(value, Iterable): - return [QColor(*c) for c in self.getRGB(value)] - return QColor(*self.getRGB(value)) - - def getRGB(self, value): - if isinstance(value, Iterable): - value, nans = np.asarray(value, dtype=int), np.isnan(value) - if nans.any(): - value = value.copy() - value[nans] = -1 - return self.rgb_array[value] - else: - return self.rgb_array[-1 if np.isnan(value) else int(value)] - - getColor = getRGB - - def resolve(self, number_of_colors): - """ - Return a palette with `number_of_colors`. - - If possible try to preserve the same 'color scheme' (`rgb_colors` - dictionary constructor parameter), falling back to the - `DefaultRGBColors` if `number_of_colors < 18` or a rainbow palette - otherwise. - - """ - palette = copy.copy(self) - try: - palette.set_number_of_colors(number_of_colors) - except ValueError: - # Fall back to the default palette - palette = ColorPaletteGenerator(number_of_colors) - return palette - - -# only for backward compatibility -class ColorPaletteHSV(ColorPaletteGenerator): - pass - - -# black and white color palette -class ColorPaletteBW: - def __init__(self, numberOfColors=-1, brightest=50, darkest=255): - self.numberOfColors = numberOfColors - self.brightest = brightest - self.darkest = darkest - self.hueValues = [] - - if numberOfColors == -1: - return # used for coloring continuous variables - else: - self.values = [int(brightest + (darkest - brightest) * x / float(numberOfColors - 1)) - for x in range(numberOfColors)] - - def __getitem__(self, index): - if self.numberOfColors == -1: # is this color for continuous attribute? - val = int(self.brightest + (self.darkest - self.brightest) * index) - return QColor(val, val, val) - else: - index = int(index) # get color for discrete attribute - return QColor(self.values[index], self.values[index], self.values[index]) - - # get QColor instance for given index - def getColor(self, index): - return self[index] - - -class ColorSchema: - def __init__(self, name, palette, additionalColors, passThroughBlack): - self.name = name - self.palette = palette - self.additionalColors = additionalColors - self.passThroughBlack = passThroughBlack - - def getName(self): - return self.name - - def getPalette(self): - return self.palette - - def getAdditionalColors(self): - return self.additionalColors - - def getPassThroughBlack(self): - return self.passThroughBlack - - -class PaletteView(QGraphicsView): - def __init__(self, parent=None): - self.canvas = QGraphicsScene(0, 0, 1000, ColorButtonSize) - QGraphicsView.__init__(self, self.canvas, parent) - self.ensureVisible(0, 0, 1, 1) - - self.color1 = None - self.color2 = None - self.rgbColors = [] - self.passThroughColors = None - - #self.setFrameStyle(QFrame.NoFrame) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self.setFixedHeight(ColorButtonSize) - self.setMinimumWidth(ColorButtonSize) - - if parent and parent.layout() is not None: - parent.layout().addWidget(self) - - def resizeEvent(self, ev): - self.updateImage() - - def setDiscPalette(self, rgbColors): - self.rgbColors = rgbColors - self.updateImage() - - def setContPalette(self, color1, color2, passThroughBlack): - self.color1 = color1 - self.color2 = color2 - self.passThroughBlack = passThroughBlack - self.updateImage() - - def setExContPalette(self, color1, color2, passThroughColors): - self.color1 = color1 - self.color2 = color2 - self.passThroughColors = passThroughColors - self.updateImage() - - def updateImage(self): - for item in self.scene().items(): - item.hide() - if self.color1 is None: - img = createDiscPalettePixmap(self.width(), self.height(), self.rgbColors) - elif self.passThroughColors is None: - img = createContPalettePixmap( - self.width(), self.height(), self.color1, self.color2, - self.passThroughBlack) - else: - img = createExContPalettePixmap( - self.width(), self.height(), self.color1, self.color2, - self.passThroughColors) - self.scene().addPixmap(img) - self.scene().update() - - -# create a pixmap with color going from color1 to color2 -def createContPalettePixmap(width, height, color1, color2, passThroughBlack): - p = QPainter() - img = QPixmap(width, height) - p.begin(img) - - #p.eraseRect(0, 0, w, h) - p.setPen(QPen(Qt.NoPen)) - g = QLinearGradient(0, 0, width, height) - g.setColorAt(0, color1) - g.setColorAt(1, color2) - if passThroughBlack: - g.setColorAt(0.5, Qt.black) - p.fillRect(img.rect(), QBrush(g)) - return img - - -# create a pixmap with a discrete palette -def createDiscPalettePixmap(width, height, palette): - p = QPainter() - img = QPixmap(width, height) - p.begin(img) - p.setPen(QPen(Qt.NoPen)) - if type(palette) == dict: # if palette is the dict with different - palette = palette[max(palette.keys())] - if len(palette) == 0: return img - rectWidth = width / float(len(palette)) - for i, col in enumerate(palette): - p.setBrush(QBrush(QColor(*col))) - p.drawRect(QRectF(i * rectWidth, 0, (i + 1) * rectWidth, height)) - return img - -# create a pixmap withcolor going from color1 to color2 passing through all -# intermediate colors in passThroughColors -def createExContPalettePixmap(width, height, color1, color2, passThroughColors): - p = QPainter() - img = QPixmap(width, height) - p.begin(img) - - #p.eraseRect(0, 0, w, h) - p.setPen(QPen(Qt.NoPen)) - g = QLinearGradient(0, 0, width, height) - g.setColorAt(0, color1) - g.setColorAt(1, color2) - for i, color in enumerate(passThroughColors): - g.setColorAt(float(i + 1) / (len(passThroughColors) + 1), color) - p.fillRect(img.rect(), QBrush(g)) - return img - - -class ColorButton(QWidget): - def __init__(self, master=None, parent=None, label=None, color=None): - QWidget.__init__(self, master) - - self.parent = parent - self.master = master - - if self.parent and self.parent.layout() is not None: - self.parent.layout().addWidget(self) - - self.setLayout(QHBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - self.icon = QFrame(self) - self.icon.setFixedSize(ColorButtonSize, ColorButtonSize) - self.icon.setAutoFillBackground(1) - self.icon.setFrameStyle(QFrame.StyledPanel + QFrame.Sunken) - self.layout().addWidget(self.icon) - - if label != None: - self.label = gui.widgetLabel(self, label) - self.layout().addWidget(self.label) - - if color != None: - self.setColor(color) - - def setColor(self, color): - self.color = color - palette = QPalette() - palette.setBrush(QPalette.Background, color) - self.icon.setPalette(palette) - - def getColor(self): - return self.color - - def mousePressEvent(self, ev): - color = QColorDialog.getColor(self.color) - if color.isValid(): - self.setColor(color) - if self.master and hasattr(self.master, "colorSchemaChange"): - self.master.colorSchemaChange() - - -def rgbToQColor(rgb): - return QColor(rgb & 0xFFFFFFFF) - - -class PaletteItemDelegate(QItemDelegate): - def __init__(self, selector, *args): - QItemDelegate.__init__(self, *args) - self.selector = selector - - def paint(self, painter, option, index): - img = self.selector.paletteImg[index.row()] - painter.drawPixmap(option.rect.x(), option.rect.y(), img) - - def sizeHint(self, option, index): - img = self.selector.paletteImg[index.row()] - return img.size() - - -class PaletteSelectorComboBox(QComboBox): - def __init__(self, *args): - QComboBox.__init__(self, *args) - self.paletteImg = [] - self.cachedPalettes = [] - ## self.setItemDelegate(PaletteItemDelegate(self, self)) - size = self.sizeHint() - size = QSize(size.width() * 2 / 3, size.height() * 2 / 3) - self.setIconSize(size) - - def setPalettes(self, name, paletteDlg): - self.clear() - self.cachedPalettes = [] - shemas = paletteDlg.getColorSchemas() - if name in paletteDlg.discPaletteNames: - pass - if name in paletteDlg.contPaletteNames: - pass - if name in paletteDlg.exContPaletteNames: - palettes = [] - paletteIndex = paletteDlg.exContPaletteNames.index(name) - for schemaName, state in shemas: - butt, disc, cont, exCont = state - name, (c1, c2, chk, colors) = exCont[paletteIndex] - palettes.append((schemaName, ( - (rgbToQColor(c1), - rgbToQColor(c2), - [rgbToQColor(color) - for color, check in colors if check and chk])))) - self.setContinuousPalettes(palettes) - - def setDiscretePalettes(self, palettes): - self.clear() - paletteImg = [] - self.cachedPalettes = [] - for name, colors in palettes: - self.addItem(name) - self.paletteImg.append(createDiscPalettePixmap(200, 20, colors)) - self.cachedPalettes.append(ColorPaletteGenerator(rgb_colors=colors)) - - def setContinuousPalettes(self, palettes): - self.clear() - paletteImg = [] - self.cachedPalettes = [] - for name, (c1, c2, colors) in palettes: - icon = QIcon( - createExContPalettePixmap( - self.iconSize().width(), self.iconSize().height(), - c1, c2, colors)) - self.addItem(icon, name) - - -def main(): # pragma: no cover - from AnyQt.QtWidgets import QApplication - a = QApplication(sys.argv) - - c = ColorPaletteDlg(None) - c.createContinuousPalette("continuousPalette", "Continuous Palette") - c.createDiscretePalette("discPalette", "Discrete Palette") - box = c.createBox("otherColors", "Colors") - c.createColorButton(box, "Canvas", "Canvas") - c.createColorButton(box, "Grid", "Grid") - c.setColorSchemas() - c.show() - a.exec() - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/Orange/widgets/utils/colorpalettes.py b/Orange/widgets/utils/colorpalettes.py index 158b498ec38..976d15dd709 100644 --- a/Orange/widgets/utils/colorpalettes.py +++ b/Orange/widgets/utils/colorpalettes.py @@ -406,7 +406,7 @@ def from_palette(cls, palette, bins): return palette.copy() if isinstance(palette, ContinuousPalette): assert len(bins) >= 2 - mids = (bins[:-1] + bins[1:]) / 2 + mids = bins[:-1] / 2 + bins[1:] / 2 bin_colors = palette.values_to_colors(mids, bins[0], bins[-1]) return cls( palette.friendly_name, palette.name, bin_colors, bins, diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index 616f5e6b6c6..865c641d9d9 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -1,11 +1,17 @@ -from AnyQt.QtCore import Qt -from AnyQt.QtGui import QBrush, QColor, QPalette, QPen, QFont, QFontMetrics -from AnyQt.QtWidgets import QStylePainter, QStyleOptionComboBox, QStyle +from AnyQt.QtCore import Qt, Signal +from AnyQt.QtGui import ( + QBrush, QColor, QPalette, QPen, QFont, QFontMetrics, QFocusEvent +) +from AnyQt.QtWidgets import ( + QStylePainter, QStyleOptionComboBox, QStyle, QApplication, QLineEdit +) -from orangewidget.utils.combobox import ComboBoxSearch, ComboBox +from orangewidget.utils.combobox import ( + ComboBoxSearch, ComboBox, qcombobox_emit_activated +) __all__ = [ - "ComboBoxSearch", "ComboBox", "ItemStyledComboBox" + "ComboBoxSearch", "ComboBox", "ItemStyledComboBox", "TextEditCombo" ] @@ -71,3 +77,109 @@ def initStyleOption(self, option: 'QStyleOptionComboBox') -> None: if self.currentIndex() == -1: option.currentText = self.__placeholderText option.palette.setCurrentColorGroup(QPalette.Disabled) + + +class TextEditCombo(ComboBox): + #: This signal is emitted whenever the contents of the combo box are + #: changed and the widget loses focus *OR* via item activation (activated + #: signal) + editingFinished = Signal() + + def __init__(self, *args, **kwargs): + kwargs.setdefault("editable", True) + # `activated=...` kwarg needs to be connected after `__on_activated` + activated = kwargs.pop("activated", None) + self.__edited = False + super().__init__(*args, **kwargs) + self.activated.connect(self.__on_activated) + if activated is not None: + self.activated.connect(activated) + ledit = self.lineEdit() + if ledit is not None: + ledit.textEdited.connect(self.__markEdited) + + def setLineEdit(self, edit: QLineEdit) -> None: + super().setLineEdit(edit) + edit.textEdited.connect(self.__markEdited) + + def __markEdited(self): + self.__edited = True + + def __on_activated(self): + self.__edited = False # mark clean on any activation + self.editingFinished.emit() + + def focusOutEvent(self, event: QFocusEvent) -> None: + super().focusOutEvent(event) + popup = QApplication.activePopupWidget() + if self.isEditable() and self.__edited and \ + (event.reason() != Qt.PopupFocusReason or + not (popup is not None + and popup.parent() in (self, self.lineEdit()))): + def monitor(): + # monitor if editingFinished was emitted from + # __on_editingFinished to avoid double emit. + nonlocal emitted + emitted = True + emitted = False + self.editingFinished.connect(monitor) + self.__edited = False + self.__on_editingFinished() + self.editingFinished.disconnect(monitor) + + if not emitted: + self.editingFinished.emit() + + def __on_editingFinished(self): + le = self.lineEdit() + policy = self.insertPolicy() + text = le.text() + if not text: + return + index = self.findText(text, Qt.MatchFixedString) + if index != -1: + self.setCurrentIndex(index) + qcombobox_emit_activated(self, index) + return + if policy == ComboBox.NoInsert: + return + elif policy == ComboBox.InsertAtTop: + index = 0 + elif policy == ComboBox.InsertAtBottom: + index = self.count() + elif policy == ComboBox.InsertAfterCurrent: + index = self.currentIndex() + 1 + elif policy == ComboBox.InsertBeforeCurrent: + index = max(self.currentIndex(), 0) + elif policy == ComboBox.InsertAlphabetically: + for index in range(self.count()): + if self.itemText(index).lower() >= text.lower(): + break + elif policy == ComboBox.InsertAtCurrent: + self.setItemText(self.currentIndex(), text) + qcombobox_emit_activated(self, self.currentIndex()) + return + + if index > -1: + self.insertItem(index, text) + self.setCurrentIndex(index) + qcombobox_emit_activated(self, self.currentIndex()) + + def text(self): + # type: () -> str + """ + Return the current text. + """ + return self.itemText(self.currentIndex()) + + def setText(self, text): + # type: (str) -> None + """ + Set `text` as the current text (adding it to the model if necessary). + """ + idx = self.findData(text, Qt.EditRole, Qt.MatchExactly) + if idx != -1: + self.setCurrentIndex(idx) + else: + self.addItem(text) + self.setCurrentIndex(self.count() - 1) diff --git a/Orange/widgets/utils/dendrogram.py b/Orange/widgets/utils/dendrogram.py index c112f99243b..06f2fe875e0 100644 --- a/Orange/widgets/utils/dendrogram.py +++ b/Orange/widgets/utils/dendrogram.py @@ -7,8 +7,8 @@ from AnyQt.QtCore import QPointF, QRectF, Qt, QSizeF, QEvent, Signal from AnyQt.QtGui import ( - QPainterPath, QPen, QBrush, QPainterPathStroker, QColor, QTransform, - QFontMetrics, QPolygonF + QPainterPath, QPen, QBrush, QPalette, QPainterPathStroker, QColor, + QTransform, QFontMetrics, QPolygonF ) from AnyQt.QtWidgets import ( QGraphicsWidget, QGraphicsPathItem, QGraphicsItemGroup, @@ -145,7 +145,7 @@ def update_pen(pen, brush=None, width=None, style=None, return pen -def path_stroke(path, width=1, join_style=Qt.MiterJoin): +def path_stroke(path, width=1, join_style=Qt.RoundJoin): stroke = QPainterPathStroker() stroke.setWidth(width) stroke.setJoinStyle(join_style) @@ -153,7 +153,7 @@ def path_stroke(path, width=1, join_style=Qt.MiterJoin): return stroke.createStroke(path) -def path_outline(path, width=1, join_style=Qt.MiterJoin): +def path_outline(path, width=1, join_style=Qt.RoundJoin): stroke = path_stroke(path, width, join_style) return stroke.united(path) @@ -218,7 +218,6 @@ def set_path(self, path): def set_label(self, label): self.label.setText(label) - self.label.setBrush(Qt.blue) self._update_label_pos() def set_color(self, color): @@ -250,6 +249,7 @@ def _update_label_pos(self): def __init__(self, parent=None, root=None, orientation=Left, hoverHighlightEnabled=True, selectionMode=ExtendedSelection, + *, pen_width=1, **kwargs): super().__init__(None, **kwargs) # Filter all events from children (`ClusterGraphicsItem`s) @@ -272,6 +272,7 @@ def __init__(self, parent=None, root=None, orientation=Left, self._cluster_parent = {} self.__hoverHighlightEnabled = hoverHighlightEnabled self.__selectionMode = selectionMode + self._pen_width = pen_width self.setContentsMargins(0, 0, 0, 0) self.setRoot(root) if parent is not None: @@ -348,8 +349,8 @@ def setRoot(self, root): self.clear() self._root = root if root is not None: - pen = make_pen(Qt.blue, width=1, cosmetic=True, - join_style=Qt.MiterJoin) + foreground = self.palette().color(QPalette.WindowText) + pen = make_pen(foreground, width=self._pen_width, cosmetic=True) for node in postorder(root): item = DendrogramWidget.ClusterGraphicsItem(self._itemgroup) item.setAcceptHoverEvents(True) @@ -437,19 +438,23 @@ def _set_hover_item(self, item): if self._highlighted_item is item: return - def branches(item): - return [self._items[ch] for ch in item.node.branches] + def set_pen(item, pen): + def branches(item): + return [self._items[ch] for ch in item.node.branches] + for it in postorder(item, branches): + it.setPen(pen) if self._highlighted_item: - pen = make_pen(Qt.blue, width=1, cosmetic=True) - for it in postorder(self._highlighted_item, branches): - it.setPen(pen) + # Restore the previous item + highlight = self.palette().color(QPalette.WindowText) + set_pen(self._highlighted_item, + make_pen(highlight, width=self._pen_width, cosmetic=True)) self._highlighted_item = item if item: - hpen = make_pen(Qt.blue, width=2, cosmetic=True) - for it in postorder(item, branches): - it.setPen(hpen) + hpen = make_pen(self.palette().color(QPalette.Highlight), + width=self._pen_width + 1, cosmetic=True) + set_pen(item, hpen) def leafItems(self): """Iterate over the dendrogram leaf items (:class:`QGraphicsItem`). @@ -564,6 +569,7 @@ def _add_selection(self, item): ppath = self._create_path(item, path) label = self._create_label(len(self._selection)) selection_item = self._SelectionItem(self, ppath, outline, label) + selection_item.label.setBrush(self.palette().color(QPalette.Link)) selection_item.setPos(self.contentsRect().topLeft()) self._selection[item] = selection_item @@ -755,7 +761,7 @@ def _rescale(self): self._selection_items = None self._update_selection_items() - def sizeHint(self, which: Qt.SizeHint, constraint=QSizeF()) -> QRectF: + def sizeHint(self, which: Qt.SizeHint, constraint=QSizeF()) -> QSizeF: # reimplemented fm = QFontMetrics(self.font()) spacing = fm.lineSpacing() @@ -825,12 +831,11 @@ def sceneEventFilter(self, obj, event): def changeEvent(self, event): # reimplemented super().changeEvent(event) - if event.type() == QEvent.FontChange: self.updateGeometry() - - # QEvent.ContentsRectChange is missing in PyQt4 <= 4.11.3 - if event.type() == 178: # QEvent.ContentsRectChange: + elif event.type() == QEvent.PaletteChange: + self._update_colors() + elif event.type() == QEvent.ContentsRectChange: self._rescale() def resizeEvent(self, event): @@ -844,3 +849,20 @@ def mousePressEvent(self, event): # A mouse press on an empty widget part if event.modifiers() == Qt.NoModifier and self._selection: self.set_selected_clusters([]) + + def _update_colors(self): + def set_color(item: DendrogramWidget.ClusterGraphicsItem, color: QColor): + def branches(item): + return [self._items[ch] for ch in item.node.branches] + for it in postorder(item, branches): + it.setPen(update_pen(it.pen(), brush=color)) + if self._root is not None: + foreground = self.palette().color(QPalette.WindowText) + item = self.item(self._root) + set_color(item, foreground) + highlight = self.palette().color(QPalette.Highlight) + if self._highlighted_item is not None: + set_color(self._highlighted_item, highlight) + accent = self.palette().color(QPalette.Link) + for item in self._selection.values(): + item.label.setBrush(accent) diff --git a/Orange/widgets/utils/distmatrixmodel.py b/Orange/widgets/utils/distmatrixmodel.py new file mode 100644 index 00000000000..bfd66085e83 --- /dev/null +++ b/Orange/widgets/utils/distmatrixmodel.py @@ -0,0 +1,137 @@ +from typing import NamedTuple, List, Optional, Dict, Any + +import numpy as np + +from AnyQt.QtWidgets import QTableView, QHeaderView +from AnyQt.QtGui import QColor, QBrush, QFont +from AnyQt.QtCore import Qt, QAbstractTableModel + +from Orange.misc import DistMatrix +from Orange.widgets import gui +from Orange.widgets.utils import colorpalettes +from Orange.widgets.utils.itemdelegates import FixedFormatNumericColumnDelegate + + +class LabelData(NamedTuple): + labels: Optional[List[str]] = None + colors: Optional[np.ndarray] = None + + +class DistMatrixModel(QAbstractTableModel): + _brushes = np.array([QBrush(QColor.fromHsv(120, int(i / 255 * 170), 255)) + for i in range(256)]) + + _diverging_brushes = np.array([ + QBrush(col) for col in + colorpalettes.ContinuousPalettes['diverging_tritanopic_cwr_75_98_c20' + ].qcolors]) + + def __init__(self): + super().__init__() + self.distances: Optional[DistMatrix] = None + self.colors: Optional[np.ndarray] = None + self.brushes: Optional[np.ndarray] = None + self.__header_data: Dict[Any, Optional[LabelData]] = { + Qt.Horizontal: LabelData(), + Qt.Vertical: LabelData()} + self.__zero_diag: bool = True + self.__span: Optional[float] = None + + self.__header_font = QFont() + self.__header_font.setBold(True) + + def set_data(self, distances): + self.beginResetModel() + self.distances = distances + self.__header_data = dict.fromkeys(self.__header_data, LabelData()) + if distances is None or len(distances) == 0: + self.__span = self.colors = self.brushes = None + return + minc = min(0, np.min(distances)) + maxc = np.max(distances) + if minc < 0: + self.__span = max(-minc, maxc) + self.brushes = self._diverging_brushes + self.colors = 127 + (distances / self.__span * 128).astype(int) + else: + self.__span = maxc + self.brushes = self._brushes + self.colors = (distances / self.__span * 255).astype(int) + + self.__zero_diag = \ + distances.is_symmetric() and np.allclose(np.diag(distances), 0) + self.endResetModel() + + def set_labels(self, orientation, labels: Optional[List[str]], + colors: Optional[np.ndarray] = None): + self.__header_data[orientation] = LabelData(labels, colors) + rc, cc = self.rowCount() - 1, self.columnCount() - 1 + self.headerDataChanged.emit( + orientation, 0, rc if orientation == Qt.Vertical else cc) + self.dataChanged.emit(self.index(0, 0), self.index(rc, cc)) + + def rowCount(self, parent=None): + if parent and parent.isValid() or self.distances is None: + return 0 + return self.distances.shape[0] + + def columnCount(self, parent=None): + if parent and parent.isValid() or self.distances is None: + return 0 + return self.distances.shape[1] + + def data(self, index, role=Qt.DisplayRole): + if role == Qt.TextAlignmentRole: + return Qt.AlignCenter | Qt.AlignVCenter + if self.distances is None: + return None + + row, col = index.row(), index.column() + if role == Qt.DisplayRole and not (self.__zero_diag and row == col): + return float(self.distances[row, col]) + if role == Qt.BackgroundRole: + return self.brushes[self.colors[row, col]] + if role == Qt.ForegroundRole: + return QColor(Qt.black) # the background is light-ish + if role == FixedFormatNumericColumnDelegate.ColumnDataSpanRole: + return 0., self.__span + return None + + def headerData(self, ind, orientation, role): + if role == Qt.FontRole: + return self.__header_font + + __header_data = self.__header_data[orientation] + if role == Qt.DisplayRole: + if __header_data.labels is not None \ + and ind < len(__header_data.labels): + return __header_data.labels[ind] + + colors = self.__header_data[orientation].colors + if colors is not None: + color = colors[ind].lighter(150) + if role == Qt.BackgroundRole: + return QBrush(color) + if role == Qt.ForegroundRole: + return QColor(Qt.black if color.value() > 128 else Qt.white) + return None + + +class DistMatrixView(gui.HScrollStepMixin, QTableView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWordWrap(False) + self.setTextElideMode(Qt.ElideNone) + self.setEditTriggers(QTableView.NoEditTriggers) + self.setItemDelegate( + FixedFormatNumericColumnDelegate( + roles=(Qt.DisplayRole, Qt.BackgroundRole, Qt.ForegroundRole, + Qt.TextAlignmentRole))) + for header in (self.horizontalHeader(), self.verticalHeader()): + header.setResizeContentsPrecision(1) + header.setSectionResizeMode(QHeaderView.ResizeToContents) + header.setHighlightSections(True) + header.setSectionsClickable(False) + self.verticalHeader().setDefaultAlignment( + Qt.AlignRight | Qt.AlignVCenter) diff --git a/Orange/widgets/utils/domaineditor.py b/Orange/widgets/utils/domaineditor.py index d9f9b016540..91f68017f14 100644 --- a/Orange/widgets/utils/domaineditor.py +++ b/Orange/widgets/utils/domaineditor.py @@ -81,9 +81,10 @@ def data(self, index, role): if col == Column.tpe: return gui.attributeIconDict[self.vartypes.index(val) + 1] if role == Qt.ForegroundRole: - if self.variables[row][Column.place] == Place.skip \ - and col != Column.place: + if self.variables[row][Column.place] == Place.skip and col != Column.place: return QColor(160, 160, 160) + # The background is light-ish, force dark text color - same as data table + return self.data(index, Qt.BackgroundRole) and QColor(0, 0, 0, 200) if role == Qt.BackgroundRole: place = self.variables[row][Column.place] mapping = [Place.meta, Place.feature, Place.class_var, None] @@ -161,7 +162,7 @@ def hidePopup(me): self.view.closeEditor(me, self.NoHint) combo = Combo(parent) - combo.highlighted[str].connect(combo.highlight) + combo.textHighlighted.connect(combo.highlight) return combo diff --git a/Orange/widgets/utils/encodings.py b/Orange/widgets/utils/encodings.py index 27201c58e63..a639ec86bd1 100644 --- a/Orange/widgets/utils/encodings.py +++ b/Orange/widgets/utils/encodings.py @@ -14,10 +14,11 @@ DEFAULT_ENCODINGS = [ "utf-8", "utf-16", "utf-32", - "iso8859-1", # latin 1 - "shift_jis", "iso2022_jp", - "gb18030", - "euc_kr", + "iso8859-1", "cp1252", # W Europe + "iso8859-2", "cp1250", # CE Europe + "shift_jis", "iso2022_jp", # Japanese + "gb18030", # Chinese + "euc_kr", # Korean ] ENCODING_DISPLAY_NAME = ( @@ -228,7 +229,8 @@ def selectedEncodings(self): res = [] for i in range(model.rowCount()): data = model.itemData(model.index(i, 0)) - if data.get(Qt.CheckStateRole) == Qt.Checked and \ + if Qt.CheckStateRole in data and \ + Qt.CheckState(data[Qt.CheckStateRole]) == Qt.Checked and \ EncodingNameRole in data: res.append(data[EncodingNameRole]) return res @@ -251,7 +253,7 @@ def clearAll(self): model = self.__model for i in range(model.rowCount()): item = model.item(i) - item.setCheckState(Qt.Checked) + item.setCheckState(Qt.Unchecked) @Slot() def reset(self): @@ -326,7 +328,7 @@ def store_selected(index): # type: (QModelIndex) -> None # write back the selected state for index co = index.data(CodecInfoRole) - state = index.data(Qt.CheckStateRole) + state = Qt.CheckState(index.data(Qt.CheckStateRole)) if isinstance(co, codecs.CodecInfo): settings.setValue(co.name, state == Qt.Checked) diff --git a/Orange/widgets/utils/filedialogs.py b/Orange/widgets/utils/filedialogs.py index ac494fc8735..81a7ffa5fab 100644 --- a/Orange/widgets/utils/filedialogs.py +++ b/Orange/widgets/utils/filedialogs.py @@ -1,8 +1,14 @@ +from abc import abstractmethod +from typing import List, Type + +from AnyQt.QtCore import QUrl +from AnyQt.QtGui import QDropEvent from orangewidget.utils.filedialogs import ( open_filename_dialog_save, open_filename_dialog, RecentPath, RecentPathsWidgetMixin, RecentPathsWComboMixin, ) + # imported for backcompatibility from orangewidget.utils.filedialogs import ( # pylint: disable=unused-import fix_extension, format_filter, get_file_name, Compression @@ -10,10 +16,12 @@ from Orange.data.io import FileFormat from Orange.util import deprecated +from Orange.widgets.widget import OWWidget __all__ = [ "open_filename_dialog_save", "open_filename_dialog", "RecentPath", "RecentPathsWidgetMixin", "RecentPathsWComboMixin", + "stored_recent_paths_prepend", "OWUrlDropBase" ] @@ -27,3 +35,77 @@ def dialog_formats(): ";;".join("{} (*{})".format(f.DESCRIPTION, ' *'.join(f.EXTENSIONS)) for f in sorted(set(FileFormat.readers.values()), key=list(FileFormat.readers.values()).index))) + + +def stored_recent_paths_prepend( + class_: Type[RecentPathsWidgetMixin], r: RecentPath +) -> List[RecentPath]: + """ + Load existing stored defaults *recent_paths* and move or prepend + `r` to front. + """ + existing = get_stored_default_recent_paths(class_) + if r in existing: + existing.remove(r) + return [r] + existing + + +def get_stored_default_recent_paths(class_: Type[RecentPathsWidgetMixin]): + recent_paths = [] + try: + items = class_.settingsHandler.defaults.get("recent_paths", []) + for item in items: + if isinstance(item, RecentPath): + recent_paths.append(item) + except (AttributeError, KeyError, TypeError): + pass + return recent_paths + + +class OWUrlDropBase(OWWidget, openclass=True): + """ + A abstract base class for a OWBaseWidget that accepts url drops. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setAcceptDrops(True) + + @abstractmethod + def canDropUrl(self, url: QUrl) -> bool: + """ + Can the `url` be dropped on this widget. + + This method must be reimplemented in a subclass. + """ + raise NotImplementedError + + @abstractmethod + def handleDroppedUrl(self, url: QUrl) -> None: + """ + Handle the dropped `url`. + + This method must be reimplemented in a subclass. + """ + raise NotImplementedError + + def dragEnterEvent(self, event): + urls = event.mimeData().urls() + if urls and self.canDropUrl(urls[0]): + event.acceptProposedAction() + return + super().dragEnterEvent(event) + + def dragMoveEvent(self, event): + urls = event.mimeData().urls() + if urls and self.canDropUrl(urls[0]): + event.acceptProposedAction() + return + super().dragMoveEvent(event) + + def dropEvent(self, event: QDropEvent): + urls = event.mimeData().urls() + if urls and self.canDropUrl(urls[0]): + self.handleDroppedUrl(urls[0]) + event.acceptProposedAction() + return + super().dropEvent(event) diff --git a/Orange/widgets/utils/graphicspixmapwidget.py b/Orange/widgets/utils/graphicspixmapwidget.py index aff86b77852..09934b97845 100644 --- a/Orange/widgets/utils/graphicspixmapwidget.py +++ b/Orange/widgets/utils/graphicspixmapwidget.py @@ -112,7 +112,9 @@ def paint( exposedcrect = crect.intersected(exposed) pixmaptransform = self.pixmapTransform() # map exposed rect to exposed pixmap coords - assert pixmaptransform.type() <= QTransform.TxRotate + assert pixmaptransform.type() in ( + QTransform.TxNone, QTransform.TxTranslate, QTransform.TxScale + ) pixmaptransform, ok = pixmaptransform.inverted() if not ok: painter.drawPixmap( diff --git a/Orange/widgets/utils/graphicsscene.py b/Orange/widgets/utils/graphicsscene.py new file mode 100644 index 00000000000..6a91334d14b --- /dev/null +++ b/Orange/widgets/utils/graphicsscene.py @@ -0,0 +1,57 @@ +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QTransform +from AnyQt.QtWidgets import ( + QGraphicsScene, QGraphicsSceneHelpEvent,QGraphicsView, QToolTip +) + +__all__ = [ + "GraphicsScene", + "graphicsscene_help_event", +] + + +class GraphicsScene(QGraphicsScene): + """ + A QGraphicsScene with better tool tip event dispatch. + """ + def helpEvent(self, event: QGraphicsSceneHelpEvent) -> None: + """ + Reimplemented. + + Send the help event to every graphics item that is under the event's + scene position (default `QGraphicsScene` only dispatches help events + to `QGraphicsProxyWidget`s. + """ + graphicsscene_help_event(self, event) + + +def graphicsscene_help_event( + scene: QGraphicsScene, event: QGraphicsSceneHelpEvent +) -> None: + """ + Send the help event to every graphics item that is under the `event` + scene position. + """ + widget = event.widget() + if widget is not None and isinstance(widget.parentWidget(), + QGraphicsView): + view = widget.parentWidget() + deviceTransform = view.viewportTransform() + else: + deviceTransform = QTransform() + items = scene.items( + event.scenePos(), Qt.IntersectsItemShape, Qt.DescendingOrder, + deviceTransform, + ) + text = "" + event.setAccepted(False) + for item in items: + scene.sendEvent(item, event) + if event.isAccepted(): + return + elif item.toolTip(): + text = item.toolTip() + break + + QToolTip.showText(event.screenPos(), text, event.widget()) + event.setAccepted(bool(text)) diff --git a/Orange/widgets/utils/graphicstextlist.py b/Orange/widgets/utils/graphicstextlist.py index 1a3c785264d..efe24d6cd4d 100644 --- a/Orange/widgets/utils/graphicstextlist.py +++ b/Orange/widgets/utils/graphicstextlist.py @@ -1,43 +1,63 @@ +import bisect import math -from typing import Optional, Union, Any, Iterable, List, Callable +from typing import Optional, Union, Any, Iterable, List, Callable, cast -from AnyQt.QtCore import Qt, QSizeF, QEvent, QMarginsF -from AnyQt.QtGui import QFont, QFontMetrics, QFontInfo +from AnyQt.QtCore import ( + Qt, QSizeF, QEvent, QMarginsF, QPointF, QAbstractItemModel, QRect +) +from AnyQt.QtGui import ( + QFont, QFontMetrics, QFontInfo, QPalette, QPixmap, QPainter, QPen +) from AnyQt.QtWidgets import ( QGraphicsWidget, QSizePolicy, QGraphicsItemGroup, QGraphicsSimpleTextItem, - QGraphicsItem, QGraphicsScene, QGraphicsSceneResizeEvent + QGraphicsItem, QGraphicsScene, QGraphicsSceneResizeEvent, QToolTip, + QGraphicsSceneHelpEvent, QGraphicsPixmapItem ) + from . import apply_all from .graphicslayoutitem import scaled __all__ = ["TextListWidget"] -class TextListWidget(QGraphicsWidget): +class _FuncArray: + __slots__ = ("func", "length") + + def __init__(self, func, length): + self.func = func + self.length = length + + def __getitem__(self, item): + return self.func(item) + + def __len__(self): + return self.length + + +class TextListBase(QGraphicsWidget): """ - A linear text list widget. + A base class for linear text list view and widget. Displays a list of uniformly spaced text lines. Parameters ---------- parent: Optional[QGraphicsItem] - items: Iterable[str] alignment: Qt.Alignment orientation: Qt.Orientation """ def __init__( self, parent: Optional[QGraphicsItem] = None, - items: Iterable[str] = (), alignment: Union[Qt.AlignmentFlag, Qt.Alignment] = Qt.AlignLeading, orientation: Qt.Orientation = Qt.Vertical, autoScale=False, + elideMode=Qt.ElideNone, **kwargs: Any ) -> None: - self.__items: List[str] = [] self.__textitems: List[QGraphicsSimpleTextItem] = [] self.__group: Optional[QGraphicsItemGroup] = None + self.__strip: Optional[QGraphicsPixmapItem] = None self.__spacing = 0 self.__alignment = Qt.AlignmentFlag(alignment) self.__orientation = orientation @@ -45,6 +65,7 @@ def __init__( # The effective font when autoScale is in effect self.__effectiveFont = QFont() self.__widthCache = {} + self.__elideMode = elideMode sizePolicy = kwargs.pop( "sizePolicy", None) # type: Optional[QSizePolicy] super().__init__(None, **kwargs) @@ -60,19 +81,14 @@ def __init__( if parent is not None: self.setParentItem(parent) - if items is not None: - self.setItems(items) + def data(self, row, role=Qt.DisplayRole): + raise NotImplementedError - def setItems(self, items: Iterable[str]) -> None: - """ - Set items for display + def count(self): + raise NotImplementedError - Parameters - ---------- - items: Iterable[str] - """ + def reset(self): self.__clear() - self.__items = list(items) self.__widthCache.clear() self.__setup() self.__layout() @@ -116,15 +132,39 @@ def clear(self) -> None: Remove all items. """ self.__clear() - self.__items = [] self.__widthCache.clear() self.updateGeometry() - def count(self) -> int: + def indexAt(self, pos: QPointF) -> Optional[int]: """ - Return the number of items + Return the index of item at `pos`. """ - return len(self.__items) + def brect(item): + return item.mapRectToParent(item.boundingRect()) + + if self.__orientation == Qt.Vertical: + def y(pos): + return pos.y() + else: + def y(pos): + return pos.x() + + def top(idx): + return brect(items[idx]).top() + + def bottom(idx): + return brect(items[idx]).bottom() + + items = self.__textitems + if not items: + return None + idx = bisect.bisect_right(_FuncArray(top, len(items)), y(pos)) - 1 + if idx == -1: + idx = 0 + if top(idx) <= y(pos) <= bottom(idx): + return idx + else: + return None def sizeHint(self, which: Qt.SizeHint, constraint=QSizeF()) -> QSizeF: """Reimplemented.""" @@ -143,21 +183,35 @@ def sizeHint(self, which: Qt.SizeHint, constraint=QSizeF()) -> QSizeF: def __width_for_font(self, font: QFont) -> float: """Return item width for the font""" - key = font.key() - if key in self.__widthCache: - return self.__widthCache[key] - fm = QFontMetrics(font) - width = max((fm.horizontalAdvance(text) for text in self.__items), - default=0) - self.__widthCache[key] = width + if self.data(0, Qt.FontRole) is not None: + if None in self.__widthCache: + return self.__widthCache[None] + width = max( + (QFontMetrics(self.data(row, Qt.FontRole)) + .boundingRect(self.data(row)) + .width() + for row in range(self.count()) + ), default=0) + self.__widthCache[None] = width + else: + key = font.key() + if key in self.__widthCache: + return self.__widthCache[key] + fm = QFontMetrics(font) + width = max((fm.boundingRect(self.data(row)).width() + for row in range(self.count())), + default=0) + self.__widthCache[key] = width return width def __naturalsh(self) -> QSizeF: """Return the natural size hint (preferred sh with no constraints).""" fm = QFontMetrics(self.font()) spacing = self.__spacing - N = len(self.__items) - width = self.__width_for_font(self.font()) + N = self.count() + width = self.__width_for_font(self.font()) + 1 + if self.has_color_strip(): + width += int(round((fm.height() + spacing) * 1.3)) height = N * fm.height() + max(N - 1, 0) * spacing return QSizeF(width, height) @@ -170,40 +224,43 @@ def event(self, event: QEvent) -> bool: self.__layout() elif event.type() == QEvent.ContentsRectChange: self.__layout() + elif event.type() == QEvent.GraphicsSceneHelp: + self.helpEvent(cast(QGraphicsSceneHelpEvent, event)) + if event.isAccepted(): + return True return super().event(event) + def helpEvent(self, event: QGraphicsSceneHelpEvent): + idx = self.indexAt(self.mapFromScene(event.scenePos())) + if idx is not None: + rect = self.__textitems[idx].sceneBoundingRect() + viewport = event.widget() + view = viewport.parentWidget() + rect = view.mapFromScene(rect).boundingRect() + QToolTip.showText(event.screenPos(), self.data(idx), + view, rect) + event.setAccepted(True) + def changeEvent(self, event): if event.type() == QEvent.FontChange: self.updateGeometry() if self.__autoScale: self.__layout() - else: + elif self.data(0, Qt.FontRole) is None: font = self.font() apply_all(self.__textitems, lambda it: it.setFont(font)) elif event.type() == QEvent.PaletteChange: palette = self.palette() - brush = palette.brush(palette.Text) + brush = palette.brush(QPalette.Text) for item in self.__textitems: item.setBrush(brush) super().changeEvent(event) - def __setup(self) -> None: - self.__clear() - font = self.__effectiveFont if self.__autoScale else self.font() - assert self.__group is None - group = QGraphicsItemGroup() - for text in self.__items: - t = QGraphicsSimpleTextItem(group) - t.setFont(font) - t.setText(text) - t.setToolTip(text) - t.setData(0, text) - self.__textitems.append(t) - group.setParentItem(self) - self.__group = group - def __layout(self) -> None: + if not self.__textitems: + return + margins = QMarginsF(*self.getContentsMargins()) if self.__orientation == Qt.Horizontal: # transposed margins @@ -223,7 +280,7 @@ def __layout(self) -> None: if align_horizontal == 0: align_horizontal = Qt.AlignLeft - N = len(self.__items) + N = self.count() if not N: return @@ -248,11 +305,32 @@ def __layout(self) -> None: fm = QFontMetrics(font) fontheight = fm.height() - if self.__autoScale and self.__effectiveFont != font: + advance = cell_height + spacing + self.__remove_items((self.__strip, ), self.scene()) + self.__strip = self.__color_strip(round(advance)) + offset = int(round(advance * 1.3)) if self.__strip else 0 + + if self.__autoScale and self.__effectiveFont != font \ + and self.data(0, Qt.FontRole) is None: self.__effectiveFont = font apply_all(self.__textitems, lambda it: it.setFont(font)) - advance = cell_height + spacing + if self.__elideMode != Qt.ElideNone: + if self.__orientation == Qt.Vertical: + textwidth = math.ceil(crect.width()) - offset + else: + textwidth = math.ceil(crect.height()) + for row, item in enumerate(self.__textitems): + text = self.data(row) + if text: + fmr = QFontMetrics(self.data(row, Qt.FontRole) or font) + textelide = fmr.elidedText( + text, self.__elideMode, textwidth, Qt.TextSingleLine + ) + item.setText(textelide) + else: + item.setText(text) + if align_vertical == Qt.AlignTop: align_dy = 0. elif align_vertical == Qt.AlignVCenter: @@ -278,23 +356,174 @@ def __layout(self) -> None: if self.__orientation == Qt.Vertical: self.__group.setRotation(0) - self.__group.setPos(0, 0) + self.__group.setPos(offset, 0) + if self.__strip: + self.__strip.setPos(0, 0) else: self.__group.setRotation(-90) - self.__group.setPos(self.rect().bottomLeft()) + y = self.rect().bottom() + self.__group.setPos(0, y - offset) + if self.__strip: + self.__strip.setPos(0, y) + + def has_color_strip(self): + return self.data(0, Qt.BackgroundRole) is not None + + def __color_strip(self, size: int): + if not self.has_color_strip() or not size: + return None + has_selection = self.data(0, Qt.UserRole) is not None + margin = int(round(size * 0.2)) + side = size - 2 * margin + pixmap = QPixmap(size, size * self.count()) + pixmap.fill(Qt.transparent) + painter = QPainter() + painter.begin(pixmap) + painter.setRenderHints(painter.Antialiasing | painter.TextAntialiasing | + painter.SmoothPixmapTransform) + for row in range(self.count()): + color = self.data(row, Qt.BackgroundRole) + painter.setPen(QPen(color, 1)) + if has_selection and not self.data(row, Qt.UserRole): + painter.setBrush(Qt.NoBrush) + else: + painter.setBrush(color.lighter(140)) + rect = QRect(margin, margin + row * size, side, side) + painter.drawRect(rect) + painter.end() + return QGraphicsPixmapItem(pixmap, self) def __clear(self) -> None: - def remove(items: Iterable[QGraphicsItem], - scene: Optional[QGraphicsScene]): - for item in items: - if scene is not None: - scene.removeItem(item) - else: - item.setParentItem(None) self.__textitems = [] - if self.__group is not None: - remove([self.__group], self.scene()) - self.__group = None + self.__remove_items((self.__group, self.__strip), self.scene()) + self.__group = self.__strip = None + + @staticmethod + def __remove_items(items: Iterable[QGraphicsItem], + scene: Optional[QGraphicsScene]): + for item in items: + if item is None: + continue + if scene is not None: + scene.removeItem(item) + else: + item.setParentItem(None) + + def __setup(self) -> None: + self.__clear() + font = self.__effectiveFont if self.__autoScale else self.font() + assert self.__group is None + group = QGraphicsItemGroup() + brush = self.palette().brush(QPalette.Text) + for row in range(self.count()): + text = self.data(row) + t = QGraphicsSimpleTextItem(group) + t.setBrush(self.data(row, Qt.ForegroundRole) or brush) + t.setFont(self.data(row, Qt.FontRole) or font) + t.setText(text) + t.setData(0, text) + t.setToolTip(self.data(row, Qt.ToolTipRole)) + self.__textitems.append(t) + group.setParentItem(self) + self.__group = group + + +class TextListWidget(TextListBase): + """ + A linear text list widget. + + Displays a list of uniformly spaced text lines. + + Parameters + ---------- + parent: Optional[QGraphicsItem] + items: Iterable[str] + alignment: Qt.Alignment + orientation: Qt.Orientation + """ + def __init__( + self, + parent: Optional[QGraphicsItem] = None, + items: Iterable[str] = (), + alignment: Union[Qt.AlignmentFlag, Qt.Alignment] = Qt.AlignLeading, + orientation: Qt.Orientation = Qt.Vertical, + autoScale=False, + elideMode=Qt.ElideNone, + **kwargs: Any + ) -> None: + self.__items: List[str] = [] + super().__init__(parent, alignment, orientation, autoScale, elideMode, + **kwargs) + if items is not None: + self.setItems(items) + + def setItems(self, items: Iterable[str]) -> None: + self.__items = list(items) + self.reset() + + def clear(self) -> None: + """Remove all items.""" + self.__items = [] + super().clear() + + def count(self) -> int: + """Return the number of items""" + return len(self.__items) + + def data(self, row, role=Qt.DisplayRole): + if row < len(self.__items): + if role == Qt.DisplayRole: + return self.__items[row] + return None + + +class TextListView(TextListBase): + """ + A linear text list view. + + Displays a list of uniformly spaced text lines. + + Parameters + ---------- + parent: Optional[QGraphicsItem] + items: Iterable[str] + alignment: Qt.Alignment + orientation: Qt.Orientation + """ + def __init__( + self, + parent: Optional[QGraphicsItem] = None, + alignment: Union[Qt.AlignmentFlag, Qt.Alignment] = Qt.AlignLeading, + orientation: Qt.Orientation = Qt.Vertical, + autoScale=False, + elideMode=Qt.ElideNone, + **kwargs: Any + ) -> None: + self.__model = None + super().__init__(parent, alignment, orientation, autoScale, elideMode, + **kwargs) + + def setModel(self, model: QAbstractItemModel) -> None: + self.__model = model + self.reset() + model.dataChanged.connect(self.reset) + model.rowsInserted.connect(self.reset) + model.rowsRemoved.connect(self.reset) + model.rowsMoved.connect(self.reset) + model.modelReset.connect(self.reset) + + def model(self): + return self.__model + + def count(self) -> int: + if self.__model is None: + return 0 + return self.__model.rowCount() + + def data(self, row, role=Qt.DisplayRole): + if self.__model is None: + return None + return self.__model.index(row, 0).data(role) def effective_point_size_for_height( diff --git a/Orange/widgets/utils/graphicsview.py b/Orange/widgets/utils/graphicsview.py index 01c41b898a5..93e7805c6a5 100644 --- a/Orange/widgets/utils/graphicsview.py +++ b/Orange/widgets/utils/graphicsview.py @@ -9,6 +9,8 @@ from AnyQt.QtCore import ( pyqtSignal as Signal, pyqtProperty as Property, pyqtSlot as Slot ) +from orangecanvas.utils import qsizepolicy_is_expanding, \ + qsizepolicy_is_shrinking from Orange.widgets.utils.graphicslayoutitem import scaled @@ -282,9 +284,10 @@ def adjusted_size( elif policy == QSizePolicy.Ignored: return min(max(available, minimum), maximum) size = hint - if policy & QSizePolicy.ExpandFlag and hint < available: + + if qsizepolicy_is_expanding(policy) and hint < available: size = min(max(size, available), maximum) - if policy & QSizePolicy.ShrinkFlag and hint > available: + if qsizepolicy_is_shrinking(policy) and hint > available: size = max(min(size, available), minimum) return size diff --git a/Orange/widgets/utils/headerview.py b/Orange/widgets/utils/headerview.py index 75fc474ab70..b83023713d6 100644 --- a/Orange/widgets/utils/headerview.py +++ b/Orange/widgets/utils/headerview.py @@ -1,7 +1,9 @@ -from AnyQt.QtCore import Qt, QRect +from __future__ import annotations + +from AnyQt.QtCore import Qt, QRect, QSize from AnyQt.QtGui import QBrush, QIcon, QCursor, QPalette, QPainter, QMouseEvent from AnyQt.QtWidgets import ( - QHeaderView, QStyleOptionHeader, QStyle, QApplication + QHeaderView, QStyleOptionHeader, QStyle, QApplication, QStyleOptionViewItem ) @@ -68,6 +70,9 @@ def initStyleOptionForIndex( is used (isSectionSelected will scan the entire model column/row when the whole column/row is selected). """ + model = self.model() + if model is None: + return hover = self.logicalIndexAt(self.mapFromGlobal(QCursor.pos())) pressed = self.__pressed @@ -98,7 +103,6 @@ def initStyleOptionForIndex( ) style = self.style() - model = self.model() orientation = self.orientation() textAlignment = model.headerData(logicalIndex, self.orientation(), Qt.TextAlignmentRole) @@ -107,8 +111,8 @@ def initStyleOptionForIndex( else defaultAlignment) option.section = logicalIndex - option.state = QStyle.State(int(option.state) | int(state)) - option.textAlignment = Qt.Alignment(int(textAlignment)) + option.state = QStyle.State(option.state | state) + option.textAlignment = Qt.Alignment(textAlignment) option.iconAlignment = Qt.AlignVCenter text = model.headerData(logicalIndex, self.orientation(), @@ -225,3 +229,140 @@ def paintSection(self, painter, rect, logicalIndex): self.style().drawControl(QStyle.CE_Header, opt, painter, self) painter.setBrushOrigin(oldBO) + + +class CheckableHeaderView(HeaderView): + """ + A HeaderView with checkable header items. + + The header is checkable if the model defines a `Qt.CheckStateRole` value. + """ + __sectionPressed: int = -1 + + def paintSection( + self, painter: QPainter, rect: QRect, logicalIndex: int + ) -> None: + opt = QStyleOptionHeader() + self.initStyleOption(opt) + self.initStyleOptionForIndex(opt, logicalIndex) + model = self.model() + if model is None: + return # pragma: no cover + opt.rect = rect + checkstate = self.sectionCheckState(logicalIndex) + ischeckable = checkstate is not None + style = self.style() + # draw background + style.drawControl(QStyle.CE_HeaderSection, opt, painter, self) + text_rect = QRect(rect) + optindicator = QStyleOptionViewItem() + optindicator.initFrom(self) + optindicator.font = self.font() + optindicator.fontMetrics = opt.fontMetrics + optindicator.features = QStyleOptionViewItem.HasCheckIndicator | QStyleOptionViewItem.HasDisplay + optindicator.rect = opt.rect + indicator_rect = style.subElementRect( + QStyle.SE_ItemViewItemCheckIndicator, optindicator, self) + text_rect.setLeft(indicator_rect.right() + 4) + if ischeckable: + optindicator.checkState = checkstate + optindicator.state |= QStyle.State_On if checkstate == Qt.Checked else QStyle.State_Off + optindicator.rect = indicator_rect + style.drawPrimitive(QStyle.PE_IndicatorItemViewItemCheck, optindicator, + painter, self) + opt.rect = text_rect + # draw section label + style.drawControl(QStyle.CE_HeaderLabel, opt, painter, self) + + def mousePressEvent(self, event: QMouseEvent) -> None: + pos = event.pos() + section = self.logicalIndexAt(pos) + if section == -1 or not self.isSectionCheckable(section): + super().mousePressEvent(event) + return + if event.button() == Qt.LeftButton: + opt = self.__viewItemOption(section) + hitrect = self.style().subElementRect(QStyle.SE_ItemViewItemCheckIndicator, opt, self) + if hitrect.contains(pos): + self.__sectionPressed = section + event.accept() + return + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + pos = event.pos() + section = self.logicalIndexAt(pos) + if section == -1 or not self.isSectionCheckable(section) \ + or self.__sectionPressed != section: + super().mouseReleaseEvent(event) + return + if event.button() == Qt.LeftButton: + opt = self.__viewItemOption(section) + hitrect = self.style().subElementRect(QStyle.SE_ItemViewItemCheckIndicator, opt, self) + if hitrect.contains(pos): + state = self.sectionCheckState(section) + newstate = Qt.Checked if state == Qt.Unchecked else Qt.Unchecked + model = self.model() + model.setHeaderData( + section, self.orientation(), newstate, Qt.CheckStateRole) + return + super().mouseReleaseEvent(event) + + def isSectionCheckable(self, index: int) -> bool: + model = self.model() + if model is None: # pragma: no cover + return False + checkstate = model.headerData(index, self.orientation(), Qt.CheckStateRole) + return checkstate is not None + + def sectionCheckState(self, index: int) -> Qt.CheckState | None: + model = self.model() + if model is None: # pragma: no cover + return None + checkstate = model.headerData(index, self.orientation(), Qt.CheckStateRole) + if checkstate is None: + return None + try: + return Qt.CheckState(checkstate) + except TypeError: # pragma: no cover + return None + + def __viewItemOption(self, index: int) -> QStyleOptionViewItem: + opt = QStyleOptionHeader() + self.initStyleOption(opt) + self.initStyleOptionForIndex(opt, index) + pos = self.sectionViewportPosition(index) + size = self.sectionSize(index) + if self.orientation() == Qt.Horizontal: + rect = QRect(pos, 0, size, self.height()) + else: + rect = QRect(0, pos, self.width(), size) + optindicator = QStyleOptionViewItem() + optindicator.initFrom(self) + optindicator.rect = rect + optindicator.font = self.font() + optindicator.fontMetrics = opt.fontMetrics + optindicator.features = QStyleOptionViewItem.HasCheckIndicator + if not opt.icon.isNull(): + optindicator.icon = opt.icon + optindicator.features |= QStyleOptionViewItem.HasDecoration + return optindicator + + def sectionSizeFromContents(self, logicalIndex: int) -> QSize: + style = self.style() + opt = QStyleOptionHeader() + self.initStyleOption(opt) + self.initStyleOptionForIndex(opt, logicalIndex) + sh = style.sizeFromContents(QStyle.CT_HeaderSection, opt, + QSize(), self) + + optindicator = QStyleOptionViewItem() + optindicator.initFrom(self) + optindicator.font = self.font() + optindicator.fontMetrics = opt.fontMetrics + optindicator.features = QStyleOptionViewItem.HasCheckIndicator + optindicator.rect = opt.rect + indicator_rect = style.subElementRect( + QStyle.SE_ItemViewItemCheckIndicator, optindicator, self) + return QSize(sh.width() + indicator_rect.width() + 4, + max(sh.height(), indicator_rect.height())) diff --git a/Orange/widgets/utils/intervalslider.py b/Orange/widgets/utils/intervalslider.py new file mode 100644 index 00000000000..9936c2e7d25 --- /dev/null +++ b/Orange/widgets/utils/intervalslider.py @@ -0,0 +1,253 @@ +from typing import Tuple + +from AnyQt.QtCore import pyqtSignal as Signal + +from AnyQt.QtWidgets import \ + QWidget, QStyleOptionSlider, QSizePolicy, QStyle, QSlider +from AnyQt.QtGui import QPainter, QMouseEvent, QPalette, QBrush +from AnyQt.QtCore import QRect, Qt, QSize + + +# Based on idea and in part the code from +# https://stackoverflow.com/questions/47342158/porting-range-slider-widget-to-pyqt5 + +class IntervalSlider(QWidget): + """ + Slider with two handles for setting an interval of values. + + Only horizontal orientation is supported. + + Signals: + + rangeChanged(minimum: int, maximum: int): + minumum or maximum or both have changed + + intervalChanged(low: int, high: int): + One or both boundaries have been changed.The tracking determines + whether this signal is emitted during user interaction or only when + the mouse is released + + sliderPressed(id: int) + The user started dragging. + Id is IntervarlSlider.LowHandle or IntervalSlider.HighHandle + + sliderMoved(id: int, value: int) + the user drags the slider + Id is IntervarlSlider.LowHandle or IntervalSlider.HighHandle + + sliderReleased(id: int) + The user finished dragging. + Id is IntervarlSlider.LowHandle or IntervalSlider.HighHandle + + """ + NoHandle, LowHandle, HighHandle = 0, 1, 2 + + rangeChanged = Signal((int, int)) # notifier for setMinimum/setMaximum + intervalChanged = Signal((int, int)) # setRange, slider move (when tracking) + sliderPressed = Signal(int) # argument is handle id (see above) + sliderMoved = Signal(int, int) # + sliderReleased = Signal(int) + + def __init__(self, low=1, high=8, minimum=0, maximum=10, + parent: QWidget = None, **args): + super().__init__(parent) + self.setSizePolicy( + QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed, + QSizePolicy.Slider)) + + self._dragged = self.NoHandle + self._start_drag_value = None + + self.opt = QStyleOptionSlider() + self._interval = self._pos = (low, high) + self.opt.minimum = minimum + self.opt.maximum = maximum + self._tracking = True + for opt, value in args.items(): + getattr(self, f"set{opt[0].upper()}{opt[1:]}")(value) + + # Properties + + def orientation(self): + """Orientation. Always Qt.Horizontal""" + return Qt.Horizontal + + def setOrientation(self, orientation): + """Set orientation (to Qt.Horizontal)""" + if orientation != Qt.Horizontal: + raise ValueError("IntervalSlider supports only horizontal direction") + + def sliderPosition(self) -> Tuple[int, int]: + """ + Current position of sliders. + This is the same as `interval` if tracking is enabled. + """ + return self._pos + + def setSliderPosition(self, low: int, high: int) -> None: + """ + Set position of sliders. + + This also changes `interval` if tracking is enabled. + """ + self._pos = (low, high) + self.update() + if self._tracking: + self._interval = self._pos + self.intervalChanged.emit(low, high) + self.sliderMoved.emit(self.LowHandle, low) + self.sliderMoved.emit(self.HighHandle, high) + + def interval(self) -> Tuple[int, int]: + """Current interval""" + return self._interval + + def setInterval(self, low: int, high: int) -> None: + """Set the current interval""" + self._interval = (low, high) + self.setSliderPosition(low, high) + + def low(self) -> int: + """The lower bound of the interavl""" + return self._interval[0] + + def setLow(self, low: int) -> None: + """Set the lower bound""" + self.setInterval(low, self.high()) + + def high(self) -> int: + """The higher bound of the interval""" + return self._interval[1] + + def setHigh(self, high: int) -> None: + """Set the higher bound of the interval""" + self.setInterval(self.low(), high) + + def setMinimum(self, minimum: int) -> None: + """Set the minimal value of the lower bound""" + self.opt.minimum = minimum + self.rangeChanged.emit(minimum, self.opt.maximum) + + def setMaximum(self, maximum: int) -> None: + """Set the maximal value of the higher bound""" + self.opt.maximum = maximum + self.rangeChanged.emit(self.opt.minimum, maximum) + + def setTickPosition(self, position: QSlider.TickPosition) -> None: + """See QSlider.setTickPosition""" + self.opt.tickPosition = position + + def setTickInterval(self, ti: int) -> None: + """See QSlider.setInterval""" + self.opt.tickInterval = ti + + def setTracking(self, enabled: bool) -> None: + """ + If enabled, interval is changed during user interaction and the + notifier signal is emitted immediately. + """ + self._tracking = enabled + + def hasTracking(self) -> bool: + return self._tracking + + # Mouse events + + def mousePressEvent(self, event: QMouseEvent) -> None: + self._dragged = self.NoHandle + args = (QStyle.CC_Slider, self.opt, event.pos(), self) + self.opt.sliderPosition = self._pos[0] + if self.style().hitTestComplexControl(*args) == QStyle.SC_SliderHandle: + self._dragged |= self.LowHandle + self.opt.sliderPosition = self._pos[1] + if self.style().hitTestComplexControl(*args) == QStyle.SC_SliderHandle: + self._dragged |= self.HighHandle + if self._dragged != self.NoHandle: + self.sliderPressed.emit(self._dragged) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + distance = self.opt.maximum - self.opt.minimum + pos = self.style().sliderValueFromPosition( + 0, distance, event.pos().x(), self.rect().width()) + low, high = self._pos + + # If handles overlap, determine which one is dragged + if self._dragged == self.LowHandle | self.HighHandle: + if pos < high: + self._dragged = self.LowHandle + elif pos > low: + self._dragged = self.HighHandle + + if self._dragged == self.LowHandle: + low = min(pos, high) + elif self._dragged == self.HighHandle: + high = max(pos, low) + + if self._pos == (low, high): + return + + self._pos = (low, high) + self.update() + if self._tracking: + self._interval = self._pos + self.intervalChanged.emit(*self._interval) + + def mouseReleaseEvent(self, _) -> None: + if self._dragged == self.NoHandle: + return + + self.sliderReleased.emit(self._dragged) + if self._interval != self._pos: + self._interval = self._pos + self.intervalChanged.emit(*self._interval) + + # Paint + + def paintEvent(self, _) -> None: + painter = QPainter(self) + + # Draw groove + self.opt.initFrom(self) + self.opt.rect = self.rect() + self.opt.sliderPosition = 0 + self.opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks + self.style().drawComplexControl(QStyle.CC_Slider, self.opt, painter) + + # Draw interval + # Interval has an arbitrary width of 4; I don't know how to get the + # actual groove thickness. + color = self.palette().color(QPalette.Highlight) + painter.setBrush(QBrush(color)) + painter.setPen(Qt.NoPen) + + args = QStyle.CC_Slider, self.opt, QStyle.SC_SliderHandle + self.opt.sliderPosition = self._pos[0] + x_left_handle = self.style().subControlRect(*args).right() + self.opt.sliderPosition = self._pos[1] + x_right_handle = self.style().subControlRect(*args).left() + + groove_rect = self.style().subControlRect( + QStyle.CC_Slider, self.opt, QStyle.SC_SliderGroove, self) + selection = QRect( + x_left_handle, + groove_rect.y() + groove_rect.height() // 2 - 2, + x_right_handle - x_left_handle, + 4) + painter.drawRect(selection) + + # Draw handles + self.opt.subControls = QStyle.SC_SliderHandle + for self.opt.sliderPosition in self._pos: + self.style().drawComplexControl(QStyle.CC_Slider, self.opt, painter) + + def sizeHint(self) -> QSize: + SliderLength = 84 + TickSpace = 5 + + w = SliderLength + h = self.style().pixelMetric(QStyle.PM_SliderThickness, self.opt, self) + if self.opt.tickPosition != QSlider.NoTicks: + h += TickSpace + + return self.style().sizeFromContents( + QStyle.CT_Slider, self.opt, QSize(w, h)) diff --git a/Orange/widgets/utils/itemdelegates.py b/Orange/widgets/utils/itemdelegates.py index 64b8214b0cb..dff53ac2d66 100644 --- a/Orange/widgets/utils/itemdelegates.py +++ b/Orange/widgets/utils/itemdelegates.py @@ -1,5 +1,5 @@ import math -from typing import Optional, Tuple +from typing import Optional, Tuple, ClassVar from AnyQt.QtCore import QModelIndex, QSize, Qt from AnyQt.QtWidgets import QStyle, QStyleOptionViewItem, QApplication @@ -101,4 +101,7 @@ class TableDataDelegate(DataDelegate): :class:`Orange.widgets.utils.itemmodels.TableModel` """ #: Roles supplied by TableModel we want DataDelegate to use. - DefaultRoles = (Qt.DisplayRole, Qt.TextAlignmentRole, Qt.BackgroundRole) + DefaultRoles: ClassVar[Tuple[int, ...]] = ( + Qt.DisplayRole, Qt.TextAlignmentRole, Qt.BackgroundRole, + Qt.ForegroundRole + ) diff --git a/Orange/widgets/utils/itemmodels.py b/Orange/widgets/utils/itemmodels.py index 98b02f14a7f..352c1a466a9 100644 --- a/Orange/widgets/utils/itemmodels.py +++ b/Orange/widgets/utils/itemmodels.py @@ -5,8 +5,9 @@ from collections import namedtuple, defaultdict from collections.abc import Sequence from contextlib import contextmanager -from functools import reduce, partial, lru_cache, wraps +from functools import reduce, partial, wraps from itertools import chain +from typing import Iterable, Mapping, Any from warnings import warn from xml.sax.saxutils import escape @@ -14,7 +15,7 @@ Qt, QObject, QAbstractListModel, QModelIndex, QItemSelectionModel, QItemSelection) from AnyQt.QtCore import pyqtSignal as Signal -from AnyQt.QtGui import QColor, QBrush +from AnyQt.QtGui import QColor, QBrush, QStandardItem, QStandardItemModel from AnyQt.QtWidgets import ( QWidget, QBoxLayout, QToolButton, QAbstractButton, QAction ) @@ -22,11 +23,12 @@ import numpy from orangewidget.utils.itemmodels import ( - PyListModel, AbstractSortTableModel as _AbstractSortTableModel + PyListModel, AbstractSortTableModel as _AbstractSortTableModel, + LabelledSeparator, SeparatorItem ) from Orange.widgets.utils.colorpalettes import ContinuousPalettes, ContinuousPalette -from Orange.data import Variable, Storage, DiscreteVariable, ContinuousVariable +from Orange.data import Value, Variable, Storage, DiscreteVariable, ContinuousVariable from Orange.data.domain import filter_visible from Orange.widgets import gui from Orange.widgets.utils import datacaching @@ -36,9 +38,11 @@ __all__ = [ "PyListModel", "VariableListModel", "PyListModelTooltip", "DomainModel", "AbstractSortTableModel", "PyTableModel", "TableModel", - "ModelActionsWidget", "ListSingleSelectionModel" + "ModelActionsWidget", "ListSingleSelectionModel", + "select_row", "select_rows", "signal_blocking", "create_list_model", ] + @contextmanager def signal_blocking(obj): blocked = obj.signalsBlocked() @@ -248,7 +252,7 @@ def insertColumns(self, column, count, parent=QModelIndex()): self.beginInsertColumns(parent, column, column + count - 1) for row in self._table: row[column:column] = [''] * count - self._rows = self._table_dim()[0] + self._cols = self._table_dim()[1] self.endInsertColumns() return True @@ -358,12 +362,17 @@ def remove(self, val): class PyListModelTooltip(PyListModel): - def __init__(self): - super().__init__() - self.tooltips = [] + def __init__(self, iterable=None, tooltips=(), **kwargs): + super().__init__(iterable, **kwargs) + if not isinstance(tooltips, Sequence): + # may be a generator; if not, fail + tooltips = list(tooltips) + self.tooltips = tooltips def data(self, index, role=Qt.DisplayRole): if role == Qt.ToolTipRole: + if index.row() >= len(self.tooltips): + return None return self.tooltips[index.row()] else: return super().data(index, role) @@ -446,7 +455,9 @@ class DomainModel(VariableListModel): PRIMITIVE = (DiscreteVariable, ContinuousVariable) def __init__(self, order=SEPARATED, separators=True, placeholder=None, - valid_types=None, alphabetical=False, skip_hidden_vars=True, **kwargs): + valid_types=None, alphabetical=False, skip_hidden_vars=True, + *, strict_type=False, + **kwargs): """ Parameters @@ -460,9 +471,13 @@ def __init__(self, order=SEPARATED, separators=True, placeholder=None, valid_types: tuple (Sub)types of `Variable` that are included in the model alphabetical: bool - If true, variables are sorted alphabetically. + If True, variables are sorted alphabetically. skip_hidden_vars: bool - If true, variables marked as "hidden" are skipped. + If True, variables marked as "hidden" are skipped. + strict_type: bool + If True, variable must be one of specified valid_types and not a + derived type (i.e. TimeVariable is not accepted as + ContinuousVariable) """ super().__init__(placeholder=placeholder, **kwargs) if isinstance(order, int): @@ -474,9 +489,10 @@ def __init__(self, order=SEPARATED, separators=True, placeholder=None, (self.Separator, ) * (self.Separator in order) + \ order if not separators: - order = [e for e in order if e is not self.Separator] + order = [e for e in order if not isinstance(e, SeparatorItem)] self.order = order self.valid_types = valid_types + self.strict_type = strict_type self.alphabetical = alphabetical self.skip_hidden_vars = skip_hidden_vars self._within_set_domain = False @@ -488,10 +504,10 @@ def set_domain(self, domain): # The logic related to separators is a bit complicated: it ensures that # even when a section is empty we don't have two separators in a row # or a separator at the end - add_separator = False + add_separator = None for section in self.order: - if section is self.Separator: - add_separator = True + if isinstance(section, SeparatorItem): + add_separator = section continue if isinstance(section, int): if domain is None: @@ -504,7 +520,9 @@ def set_domain(self, domain): to_add = list(filter_visible(to_add)) if self.valid_types is not None: to_add = [var for var in to_add - if isinstance(var, self.valid_types)] + if (type(var) in self.valid_types + if self.strict_type + else isinstance(var, self.valid_types))] if self.alphabetical: to_add = sorted(to_add, key=lambda x: x.name) elif isinstance(section, list): @@ -512,9 +530,10 @@ def set_domain(self, domain): else: to_add = [section] if to_add: - if add_separator and content: - content.append(self.Separator) - add_separator = False + if add_separator and ( + content or isinstance(add_separator, LabelledSeparator)): + content.append(add_separator) + add_separator = None content += to_add try: self._within_set_domain = True @@ -820,33 +839,27 @@ def __init__(self, sourcedata, parent=None): for role, c in self.ColorForRole.items() } - def format_sparse(vars, datagetter, instance): - data = datagetter(instance) - return ", ".join("{}={}".format(vars[i].name, vars[i].repr_val(v)) - for i, v in zip(data.indices, data.data)) - - def format_sparse_bool(vars, datagetter, instance): - data = datagetter(instance) - return ", ".join(vars[i].name for i in data.indices) - - def format_dense(var, instance): - return str(instance[var]) - - def make_basket_formater(vars, density, role): - formater = (format_sparse if density == Storage.SPARSE - else format_sparse_bool) - if role == TableModel.Attribute: - getter = operator.attrgetter("sparse_x") - elif role == TableModel.ClassVar: - getter = operator.attrgetter("sparse_y") - elif role == TableModel.Meta: - getter = operator.attrgetter("sparse_metas") - return partial(formater, vars, getter) + def format_sparse(vars, row): + row = row.tocsr() + return ", ".join("{}={}".format(vars[i].name, vars[i].str_val(v)) + for i, v in zip(row.indices, row.data)) + + def format_sparse_bool(vars, row): + row = row.tocsr() + return ", ".join(vars[i].name for i in row.indices) + + def format_dense(var, val): + return var.str_val(val) + + def make_basket_formatter(vars, density): + formatter = (format_sparse if density == Storage.SPARSE + else format_sparse_bool) + return partial(formatter, vars) def make_basket(vars, density, role): return TableModel.Basket( - vars, TableModel.Attribute, brush_for_role[role], density, - make_basket_formater(vars, density, role) + vars, role, brush_for_role[role], density, + make_basket_formatter(vars, density) ) def make_column(var, role): @@ -891,20 +904,26 @@ def make_column(var, role): [set(var.attributes) for var in self.vars], set())) - @lru_cache(maxsize=1000) - def row_instance(index): - return self.source[int(index)] - self._row_instance = row_instance - # column basic statistics (VariableStatsRole), computed when # first needed. self.__stats = None - self.__rowCount = sourcedata.approx_len() + self.__rowCount = len(sourcedata) self.__columnCount = len(self.columns) if self.__rowCount > (2 ** 31 - 1): raise ValueError("len(sourcedata) > 2 ** 31 - 1") + def _get_source_item(self, row, coldesc): + if isinstance(coldesc, self.Basket): + # `self.source[row:row + 1]` returns Table + # `self.source[row]` returns RowInstance + # We only worry about X and metas, as Y cannot be sparse + if coldesc.role is self.Meta: + return self.source[row:row + 1].metas + if coldesc.role is self.Attribute: + return self.source[row:row + 1].X + return self.source[row, coldesc.var] + def sortColumnData(self, column): return self._columnSortKeyData(column, TableModel.ValueRole) @@ -924,12 +943,7 @@ def _columnSortKeyData(self, column, role): coldesc = self.columns[column] if isinstance(coldesc, TableModel.Column) \ and role == TableModel.ValueRole: - col_data = numpy.asarray(self.source.get_column_view(coldesc.var)[0]) - - if coldesc.var.is_continuous: - # continuous from metas have dtype object; cast it to float - col_data = col_data.astype(float) - return col_data + return self.source.get_column(coldesc.var) else: return numpy.asarray([self.index(i, column).data(role) for i in range(self.rowCount())]) @@ -941,6 +955,7 @@ def data(self, index, role, _Qt_DisplayRole=Qt.DisplayRole, _Qt_EditRole=Qt.EditRole, _Qt_BackgroundRole=Qt.BackgroundRole, + _Qt_ForegroundRole=Qt.ForegroundRole, _ValueRole=ValueRole, _ClassValueRole=ClassValueRole, _VariableRole=VariableRole, @@ -951,6 +966,7 @@ def data(self, index, role, _recognizedRoles=frozenset([Qt.DisplayRole, Qt.EditRole, Qt.BackgroundRole, + Qt.ForegroundRole, ValueRole, ClassValueRole, VariableRole, @@ -962,14 +978,16 @@ def data(self, index, role, if role not in _recognizedRoles: return None - row, col = index.row(), index.column() - if not 0 <= row <= self.__rowCount: + row = index.row() + if not 0 <= row <= self.__rowCount: return None row = self.mapToSourceRows(row) + col = 0 if role is _ClassValueRole else index.column() try: - instance = self._row_instance(row) + coldesc = self.columns[col] + instance = self._get_source_item(row, coldesc) except IndexError: self.layoutAboutToBeChanged.emit() self.beginRemoveRows(self.parent(), row, max(self.rowCount(), row)) @@ -977,21 +995,20 @@ def data(self, index, role, self.endRemoveRows() self.layoutChanged.emit() return None - coldesc = self.columns[col] if role == _Qt_DisplayRole: return coldesc.format(instance) - elif role == _Qt_EditRole and isinstance(coldesc, TableModel.Column): - return instance[coldesc.var] + elif role in (_Qt_EditRole, _ValueRole) and isinstance(coldesc, TableModel.Column): + return Value(coldesc.var, instance) elif role == _Qt_BackgroundRole: return coldesc.background - elif role == _ValueRole and isinstance(coldesc, TableModel.Column): - return instance[coldesc.var] - elif role == _ClassValueRole: - try: - return instance.get_class() - except TypeError: - return None + elif role == _Qt_ForegroundRole: + # The background is light-ish, force dark text color + return coldesc.background and QColor(0, 0, 0, 200) + elif role == _ClassValueRole \ + and isinstance(coldesc, TableModel.Column) \ + and len(self.domain.class_vars) == 1: + return Value(coldesc.var, instance) elif role == _VariableRole and isinstance(coldesc, TableModel.Column): return coldesc.var elif role == _DomainRole: @@ -1081,3 +1098,19 @@ def _stats_for_column(self, column): ) return self.__stats[coldesc.var] + + +def create_list_model( + items: Iterable[Mapping[Qt.ItemDataRole, Any]], + parent: QObject | None = None, +) -> QStandardItemModel: + """ + Create list model from an `items` iterable. + """ + model = QStandardItemModel(parent) + for item in items: + sitem = QStandardItem() + for role, value in item.items(): + sitem.setData(value, role) + model.appendRow([sitem]) + return model diff --git a/Orange/widgets/utils/listfilter.py b/Orange/widgets/utils/listfilter.py index c84c9dd1904..2febd2354ee 100644 --- a/Orange/widgets/utils/listfilter.py +++ b/Orange/widgets/utils/listfilter.py @@ -42,7 +42,7 @@ class VariablesListItemView(QListView): """ #: Emitted with a Qt.DropAction when a drag/drop (originating from this #: view) completed successfully - dragDropActionDidComplete = Signal(int) + dragDropActionDidComplete = Signal(Qt.DropAction) def __init__(self, parent=None, acceptedType=Orange.data.Variable): super().__init__(parent) @@ -135,6 +135,9 @@ def set_filter_string(self, filter): self._filter_string = str(filter).lower() self.invalidateFilter() + def filter_string(self): + return self._filter_string + def filter_accepts_variable(self, var): row_str = var.name + " ".join(("%s=%s" % item) for item in var.attributes.items()) diff --git a/Orange/widgets/utils/localization/__init__.py b/Orange/widgets/utils/localization/__init__.py new file mode 100644 index 00000000000..382ab4993a6 --- /dev/null +++ b/Orange/widgets/utils/localization/__init__.py @@ -0,0 +1,3 @@ +from orangecanvas.localization import pl + +__all__ = ['pl'] diff --git a/Orange/widgets/utils/localization/tests/__init__.py b/Orange/widgets/utils/localization/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/utils/localization/tests/test_localization.py b/Orange/widgets/utils/localization/tests/test_localization.py new file mode 100644 index 00000000000..808a3a4a0db --- /dev/null +++ b/Orange/widgets/utils/localization/tests/test_localization.py @@ -0,0 +1,21 @@ +import unittest +from Orange.widgets.utils.localization import pl + + +class TestEn(unittest.TestCase): + def test_pl(self): + self.assertEqual(pl(0, "cat"), "cats") + self.assertEqual(pl(1, "cat"), "cat") + self.assertEqual(pl(2, "cat"), "cats") + self.assertEqual(pl(100, "cat"), "cats") + self.assertEqual(pl(101, "cat"), "cats") + + self.assertEqual(pl(0, "cat|cats"), "cats") + self.assertEqual(pl(1, "cat|cats"), "cat") + self.assertEqual(pl(2, "cat|cats"), "cats") + self.assertEqual(pl(100, "cat|cats"), "cats") + self.assertEqual(pl(101, "cat|cats"), "cats") + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/utils/multi_target.py b/Orange/widgets/utils/multi_target.py new file mode 100644 index 00000000000..831b44309ae --- /dev/null +++ b/Orange/widgets/utils/multi_target.py @@ -0,0 +1,28 @@ +from functools import wraps + +from Orange.widgets.utils.messages import UnboundMsg + +multiple_targets_msg = "Multiple targets are not supported." +_multiple_targets_data = UnboundMsg(multiple_targets_msg) + + +def check_multiple_targets_input(f): + """ + Wrapper for widget's set_data method that checks if the input + has multiple targets and shows an error if it does. + + :param f: widget's `set_data` method to wrap + :return: wrapped method that handles multiple targets data inputs + """ + + @wraps(f) + def new_f(widget, data, *args, **kwargs): + widget.Error.add_message("multiple_targets_data", + _multiple_targets_data) + widget.Error.multiple_targets_data.clear() + if data is not None and len(data.domain.class_vars) > 1: + widget.Error.multiple_targets_data() + data = None + return f(widget, data, *args, **kwargs) + + return new_f diff --git a/Orange/widgets/utils/owlearnerwidget.py b/Orange/widgets/utils/owlearnerwidget.py index c45859f6b51..671d01cb39c 100644 --- a/Orange/widgets/utils/owlearnerwidget.py +++ b/Orange/widgets/utils/owlearnerwidget.py @@ -79,6 +79,14 @@ class Error(OWWidget.Error): class Warning(OWWidget.Warning): outdated_learner = Msg("Press Apply to submit changes.") + class Information(OWWidget.Information): + ignored_preprocessors = Msg( + "Ignoring default preprocessing.\n" + "Default preprocessing, such as scaling, one-hot encoding and " + "treatment of missing data, has been replaced with user-specified " + "preprocessors. Problems may occur if these are inadequate " + "for the given data.") + class Inputs: data = Input("Data", Table) preprocessor = Input("Preprocessor", Preprocess) @@ -90,14 +98,18 @@ class Outputs: OUTPUT_MODEL_NAME = Outputs.model.name # Attr for backcompat w/ self.send() code + _SEND, _SOFT, _UPDATE = range(3) + def __init__(self, preprocessors=None): super().__init__() + self.__default_learner_name = "" self.data = None self.valid_data = False self.learner = None self.model = None self.preprocessors = preprocessors self.outdated_settings = False + self.__apply_level = [] self.setup_layout() QTimer.singleShot(0, getattr(self, "unconditional_apply", self.apply)) @@ -119,10 +131,32 @@ def get_learner_parameters(self): """ return [] + def default_learner_name(self) -> str: + """ + Return the default learner name. + + By default this is the same as the widget's name. + """ + return self.__default_learner_name or self.captionTitle + + def set_default_learner_name(self, name: str) -> None: + """ + Set the default learner name if not otherwise specified by the user. + """ + changed = name != self.__default_learner_name + if name: + self.name_line_edit.setPlaceholderText(name) + else: + self.name_line_edit.setPlaceholderText(self.captionTitle) + self.__default_learner_name = name + if not self.learner_name and changed: + self.learner_name_changed() + @Inputs.preprocessor def set_preprocessor(self, preprocessor): self.preprocessors = preprocessor - self.apply() + # invalidate learner and model, so handleNewSignals will renew them + self.learner = self.model = None @Inputs.data @check_sql_input @@ -142,23 +176,50 @@ def set_data(self, data): "Select one with the Select Columns widget.") self.data = None - self.update_model() + # invalidate the model so that handleNewSignals will update it + self.model = None + def apply(self): + level, self.__apply_level = max(self.__apply_level, default=self._UPDATE), [] """Applies learner and sends new model.""" - self.update_learner() - self.update_model() + if level == self._SEND: + self._send_learner() + self._send_model() + elif level == self._UPDATE: + self.update_learner() + self.update_model() + else: + self.learner or self.update_learner() + self.model or self.update_model() + + def apply_as(self, level, unconditional=False): + self.__apply_level.append(level) + if unconditional: + self.unconditional_apply() + else: + self.apply() def update_learner(self): self.learner = self.create_learner() if self.learner and issubclass(self.LEARNER, Fitter): self.learner.use_default_preprocessors = True if self.learner is not None: - self.learner.name = self.learner_name or self.name + self.learner.name = self.effective_learner_name() + self._send_learner() + + def _send_learner(self): self.Outputs.learner.send(self.learner) self.outdated_settings = False self.Warning.outdated_learner.clear() + def handleNewSignals(self): + self.apply_as(self._SOFT, True) + self.Information.ignored_preprocessors( + shown=not getattr(self.learner, "use_default_preprocessors", False) + and getattr(self.LEARNER, "preprocessors", False) + and self.preprocessors is not None) + def show_fitting_failed(self, exc): """Show error when fitting fails. Derived widgets can override this to show more specific messages.""" @@ -173,8 +234,11 @@ def update_model(self): except BaseException as exc: self.show_fitting_failed(exc) else: - self.model.name = self.learner_name or self.name + self.model.name = self.effective_learner_name() self.model.instances = self.data + self._send_model() + + def _send_model(self): self.Outputs.model.send(self.model) def check_data(self): @@ -182,8 +246,10 @@ def check_data(self): self.Error.sparse_not_supported.clear() if self.data is not None and self.learner is not None: self.Error.data_error.clear() - if not self.learner.check_learner_adequacy(self.data.domain): - self.Error.data_error(self.learner.learner_adequacy_err_msg) + + reason = self.learner.incompatibility_reason(self.data.domain) + if reason is not None: + self.Error.data_error(reason) elif not len(self.data): self.Error.data_error("Dataset is empty.") elif len(ut.unique(self.data.Y)) < 2: @@ -194,6 +260,7 @@ def check_data(self): self.Error.sparse_not_supported() else: self.valid_data = True + return self.valid_data def settings_changed(self, *args, **kwargs): @@ -201,18 +268,19 @@ def settings_changed(self, *args, **kwargs): self.Warning.outdated_learner(shown=not self.auto_apply) self.apply() - def _change_name(self, instance, output): - if instance: - instance.name = self.learner_name or self.name - if self.auto_apply: - output.send(instance) - def learner_name_changed(self): - self._change_name(self.learner, self.Outputs.learner) - self._change_name(self.model, self.Outputs.model) + if self.model is not None: + self.model.name = self.effective_learner_name() + if self.learner is not None: + self.learner.name = self.effective_learner_name() + self.apply_as(self._SEND) + + def effective_learner_name(self): + """Return the effective learner name.""" + return self.learner_name or self.name_line_edit.placeholderText() def send_report(self): - self.report_items((("Name", self.learner_name or self.name),)) + self.report_items((("Name", self.effective_learner_name()),)) model_parameters = self.get_learner_parameters() if model_parameters: @@ -246,7 +314,6 @@ def add_main_layout(self): Override this method for laying out any learner-specific parameter controls. See setup_layout() method for execution order. """ - pass def add_classification_layout(self, box): """Creates layout for classification specific options. @@ -255,7 +322,6 @@ def add_classification_layout(self, box): and regression learners require different options. See `setup_layout()` method for execution order. """ - pass def add_regression_layout(self, box): """Creates layout for regression specific options. @@ -264,15 +330,21 @@ def add_regression_layout(self, box): and regression learners require different options. See `setup_layout()` method for execution order. """ - pass def add_learner_name_widget(self): self.name_line_edit = gui.lineEdit( self.controlArea, self, 'learner_name', box='Name', - placeholderText=self.name, + placeholderText=self.captionTitle, tooltip='The name will identify this model in other widgets', orientation=Qt.Horizontal, callback=self.learner_name_changed) + def setCaption(self, caption): + super().setCaption(caption) + if not self.__default_learner_name: + self.name_line_edit.setPlaceholderText(caption) + if not self.learner_name: + self.learner_name_changed() + def add_bottom_buttons(self): self.apply_button = gui.auto_apply(self.buttonsArea, self, commit=self.apply) diff --git a/Orange/widgets/utils/plot/owpalette.py b/Orange/widgets/utils/plot/owpalette.py index 9c58c29947c..19112fa1570 100644 --- a/Orange/widgets/utils/plot/owpalette.py +++ b/Orange/widgets/utils/plot/owpalette.py @@ -5,8 +5,6 @@ __all__ = ["create_palette", "OWPalette"] -pg.setConfigOption('background', 'w') -pg.setConfigOption('foreground', 'k') pg.setConfigOptions(antialias=True) diff --git a/Orange/widgets/utils/plot/owplotgui.py b/Orange/widgets/utils/plot/owplotgui.py index a2f040a67fb..3dde9877544 100644 --- a/Orange/widgets/utils/plot/owplotgui.py +++ b/Orange/widgets/utils/plot/owplotgui.py @@ -31,7 +31,7 @@ QWidget, QToolButton, QVBoxLayout, QHBoxLayout, QGridLayout, QMenu, QAction, QSizePolicy, QLabel, QStyledItemDelegate, QStyle, QListView ) -from AnyQt.QtGui import QIcon, QColor, QFont +from AnyQt.QtGui import QIcon, QFont, QPalette from AnyQt.QtCore import Qt, pyqtSignal, QSize, QRect, QPoint, QMimeData from Orange.data import ContinuousVariable, DiscreteVariable @@ -145,12 +145,13 @@ def paint(self, painter, option, index): txtw = painter.fontMetrics().horizontalAdvance(txt) painter.save() painter.setPen(Qt.NoPen) - painter.setBrush(QColor("#ccc")) + painter.setBrush(option.palette.brush(QPalette.Button)) brect = QRect(rect.x() + rect.width() - 8 - txtw, rect.y(), txtw, rect.height()) painter.drawRoundedRect(brect, 4, 4) - painter.restore() + painter.setPen(option.palette.color(QPalette.ButtonText)) painter.drawText(brect, Qt.AlignCenter, txt) + painter.restore() painter.save() double_pen = painter.pen() @@ -493,6 +494,7 @@ def __init__(self, master): ZoomReset = 16 ToolTipShowsAll = 17 + AggregatePoints = 53 ClassDensity = 18 RegressionLine = 19 LabelOnlySelected = 20 @@ -632,6 +634,20 @@ def class_density_check_box(self, widget): cb_name=self._plot.update_density, stateWhenDisabled=False) + def aggregate_points_check_box(self, widget): + cb = self._master.cb_aggregate_points = \ + self._check_box( + widget=widget, + value="aggregate_dense_regions", + label="Aggregate points in dense regions", + cb_name=self._plot.set_aggregation, + stateWhenDisabled=False, + ) + cb.setToolTip( + "Dense regions with many points are aggregated into " + "circles or pie charts,\n" + "unless data is jittered or labels, selection or subset is shown.") + def regression_line_check_box(self, widget): self._master.cb_reg_line = \ self._check_box(widget=widget, value="show_reg_line", @@ -757,7 +773,11 @@ def plot_properties_box(self, widget, box=None): """ Create a box with controls for common plot settings """ - return self.create_box([ + return self.create_box( + ([self.AggregatePoints] + if type(self._plot).__dict__.get("aggregate_dense_regions", False) is not False else + []) + + [ self.ClassDensity, self.ShowLegend], widget, box, False) @@ -768,6 +788,7 @@ def plot_properties_box(self, widget, box=None): ShowLegend: show_legend_check_box, ShowGridLines: grid_lines_check_box, ToolTipShowsAll: tooltip_shows_all_check_box, + AggregatePoints: aggregate_points_check_box, ClassDensity: class_density_check_box, RegressionLine: regression_line_check_box, LabelOnlySelected: label_only_selected_check_box, diff --git a/Orange/widgets/utils/save/owsavebase.py b/Orange/widgets/utils/save/owsavebase.py index 0f7d5866981..9a40aaa9d6e 100644 --- a/Orange/widgets/utils/save/owsavebase.py +++ b/Orange/widgets/utils/save/owsavebase.py @@ -10,6 +10,8 @@ _userhome = os.path.expanduser(f"~{os.sep}") +_IS_DARWIN = sys.platform == "darwin" +_IS_WIN32 = sys.platform == "win32" class OWSaveBase(widget.OWWidget, openclass=True): """ @@ -41,8 +43,17 @@ class OWSaveBase(widget.OWWidget, openclass=True): class Information(widget.OWWidget.Information): empty_input = widget.Msg("Empty input; nothing was saved.") + class Warning(widget.OWWidget.Warning): + auto_save_disabled = widget.Msg( + "Auto save disabled.\n" + "Due to security reasons auto save is only restored for paths " + "that are in the same directory as the workflow file or in a " + "subtree of that directory." + ) + class Error(widget.OWWidget.Error): no_file_name = widget.Msg("File name is not set.") + unsupported_format = widget.Msg("File format is unsupported.\n{}") general_error = widget.Msg("{}") want_main_area = False @@ -58,7 +69,7 @@ class Error(widget.OWWidget.Error): # workflow). stored_path = Setting("") stored_name = Setting("", schema_only=True) # File name, without path - auto_save = Setting(False) + auto_save = Setting(False, schema_only=True) filters = [] @@ -76,18 +87,19 @@ def __init__(self, start_row=0): """ super().__init__() self.data = None + self.__show_auto_save_disabled = False self._absolute_path = self._abs_path_from_setting() # This cannot be done outside because `filters` is defined by subclass if not self.filter: - self.filter = next(iter(self.get_filters())) + self.filter = self.default_filter() self.grid = grid = QGridLayout() gui.widgetBox(self.controlArea, orientation=grid, box=True) grid.addWidget( gui.checkBox( None, self, "auto_save", "Autosave when receiving new data", - callback=self.update_messages), + callback=self._on_auto_save_toggled), start_row, 0, 1, 2) self.bt_save = gui.button( self.buttonsArea, self, @@ -98,6 +110,10 @@ def __init__(self, start_row=0): self.adjustSize() self.update_messages() + def default_filter(self): + """Returns the first filter in the list""" + return next(iter(self.get_filters())) + @property def last_dir(self): # Not the best name, but kept for compatibility @@ -107,12 +123,16 @@ def last_dir(self): def last_dir(self, absolute_path): """Store _absolute_path and update relative path (stored_path)""" self._absolute_path = absolute_path - + self.stored_path = absolute_path workflow_dir = self.workflowEnv().get("basedir", None) - if workflow_dir and absolute_path.startswith(workflow_dir.rstrip("/")): - self.stored_path = os.path.relpath(absolute_path, workflow_dir) - else: - self.stored_path = absolute_path + if workflow_dir: + try: + relative_path = os.path.relpath(absolute_path, start=workflow_dir) + except ValueError: # on Windows for paths on different drives + pass + else: + if not relative_path.startswith(".."): + self.stored_path = relative_path def _abs_path_from_setting(self): """ @@ -124,7 +144,7 @@ def _abs_path_from_setting(self): workflow_dir = self.workflowEnv().get("basedir") if os.path.isabs(self.stored_path): if os.path.exists(self.stored_path): - self.auto_save = False + self._disable_auto_save_and_warn() return self.stored_path elif workflow_dir is not None: return os.path.normpath( @@ -134,6 +154,15 @@ def _abs_path_from_setting(self): self.auto_save = False return self.stored_path + def _disable_auto_save_and_warn(self): + if self.auto_save: + self.__show_auto_save_disabled = True + self.auto_save = False + + def _on_auto_save_toggled(self): + self.__show_auto_save_disabled = False + self.update_messages() + @property def filename(self): if self.stored_name: @@ -158,13 +187,20 @@ def get_filters(cls): @property def writer(self): """ - Return the active writer + Return the active writer or None if there is no writer for this filter The base class uses this property only in `do_save` to find the writer corresponding to the filter. Derived classes (e.g. OWSave) may also use it elsewhere. + + Filter may not exist if it comes from settings saved in Orange with + some add-ons that are not (or no longer) present, or if support for + some extension was dropped, like the old Excel format. """ - return self.get_filters()[self.filter] + filters = self.get_filters() + if self.filter not in filters: + return None + return filters[self.filter] def on_new_input(self): """ @@ -194,6 +230,7 @@ def save_file_as(self): return self.filename = filename self.filter = selected_filter + self.Error.unsupported_format.clear() self.bt_save.setText(f"Save as {self.stored_name}") self.update_messages() self._try_save() @@ -233,6 +270,9 @@ def do_save(self): a single format. """ # This method is separated out because it will usually be overriden + if self.writer is None: + self.Error.unsupported_format(self.filter) + return self.writer.write(self.filename, self.data) def update_messages(self): @@ -248,6 +288,7 @@ def update_messages(self): """ self.Error.no_file_name(shown=not self.filename and self.auto_save) self.Information.empty_input(shown=self.filename and self.data is None) + self.Warning.auto_save_disabled(shown=self.__show_auto_save_disabled) def update_status(self): """ @@ -261,7 +302,7 @@ def initial_start_dir(self): Return either the current file's path, the last directory or home. """ if self.filename and os.path.exists(os.path.split(self.filename)[0]): - return self.filename + return os.path.splitext(self.filename)[0] else: return self.last_dir or _userhome @@ -314,39 +355,30 @@ def migrate_settings(cls, settings, version): # As of Qt 5.9, QFileDialog.setDefaultSuffix does not support double # suffixes, not even in non-native dialogs. We handle each OS separately. - if sys.platform in ("darwin", "win32"): - # macOS and Windows native dialogs do not correctly handle double - # extensions. We thus don't pass any suffixes to the dialog and add - # the correct suffix after closing the dialog and only then check - # if the file exists and ask whether to override. - # It is a bit confusing that the user does not see the final name in the - # dialog, but I see no better solution. + if _IS_DARWIN or _IS_WIN32: def get_save_filename(self): # pragma: no cover - if sys.platform == "darwin": - def remove_star(filt): - return filt.replace(" (*.", " (.") - else: - def remove_star(filt): - return filt - - no_ext_filters = {remove_star(f): f for f in self.valid_filters()} filename = self.initial_start_dir() while True: dlg = QFileDialog( - None, "Save File", filename, ";;".join(no_ext_filters)) + None, "Save File", filename, ";;".join(self.valid_filters())) dlg.setAcceptMode(dlg.AcceptSave) - dlg.selectNameFilter(remove_star(self.default_valid_filter())) - dlg.setOption(QFileDialog.DontConfirmOverwrite) + dlg.selectNameFilter(self.default_valid_filter()) + # MacOs (currently) ignores DontConfirmOverwrite + # Let us not set it, so we know it's not set in the future + if _IS_WIN32: + dlg.setOption(QFileDialog.DontConfirmOverwrite) if dlg.exec() == QFileDialog.Rejected: return "", "" filename = dlg.selectedFiles()[0] - selected_filter = no_ext_filters[dlg.selectedNameFilter()] + selected_filter = dlg.selectedNameFilter() filename = self._replace_extension( filename, self._extension_from_filter(selected_filter)) - if not os.path.exists(filename) or QMessageBox.question( + if (not os.path.exists(filename) + or _IS_DARWIN # MacOs already asked for confirmation + or QMessageBox.question( self, "Overwrite file?", f"File {os.path.split(filename)[1]} already exists.\n" - "Overwrite?") == QMessageBox.Yes: + "Overwrite?") == QMessageBox.Yes): return filename, selected_filter else: # Linux and any unknown platforms diff --git a/Orange/widgets/utils/save/tests/test_owsavebase.py b/Orange/widgets/utils/save/tests/test_owsavebase.py index cadb637a620..8755dc46bce 100644 --- a/Orange/widgets/utils/save/tests/test_owsavebase.py +++ b/Orange/widgets/utils/save/tests/test_owsavebase.py @@ -49,6 +49,7 @@ class TestOWSaveBaseWithWriters(WidgetTest): # with with writers as keys in `filters`. class OWSaveMockWriter(OWSaveBase): name = "Mock save" + keywords = "mock save" writer = Mock() writer.EXTENSIONS = [".csv"] writer.SUPPORT_COMPRESSED = True @@ -105,6 +106,8 @@ def assertPathEqual(self, a, b): @patch("os.path.exists", lambda name: name in ["/home/u/orange/a/b", "/foo/bar"]) + @patch("os.path.isabs", + lambda name: name in ["/a/d", "/foo/bar"]) # Python 3.13+ made that False on Windows def test_open_moved_workflow(self): """Stored relative paths are properly changed on load""" home = _userhome @@ -157,6 +160,7 @@ def test_open_moved_workflow(self): self.assertPathEqual(w.last_dir, "/home/u/orange/a/b") self.assertPathEqual(w.filename, "/home/u/orange/a/b/c.foo") self.assertTrue(w.auto_save) + self.assertFalse(w.Warning.auto_save_disabled.is_shown()) w = self.create_widget( self.OWSaveMockWriter, @@ -166,6 +170,7 @@ def test_open_moved_workflow(self): self.assertPathEqual(w.last_dir, "/home/u/orange/a/b") self.assertPathEqual(w.filename, "/home/u/orange/a/b/c.foo") self.assertFalse(w.auto_save) + self.assertFalse(w.Warning.auto_save_disabled.is_shown()) w = self.create_widget( self.OWSaveMockWriter, @@ -175,6 +180,7 @@ def test_open_moved_workflow(self): self.assertPathEqual(w.last_dir, "/home/u/orange/a/d") self.assertPathEqual(w.filename, "/home/u/orange/a/d/c.foo") self.assertTrue(w.auto_save) + self.assertFalse(w.Warning.auto_save_disabled.is_shown()) w = self.create_widget( self.OWSaveMockWriter, @@ -184,6 +190,7 @@ def test_open_moved_workflow(self): self.assertPathEqual(w.last_dir, "/home/u/orange/") self.assertPathEqual(w.filename, "/home/u/orange/c.foo") self.assertFalse(w.auto_save) + self.assertFalse(w.Warning.auto_save_disabled.is_shown()) w = self.create_widget( self.OWSaveMockWriter, @@ -193,6 +200,7 @@ def test_open_moved_workflow(self): self.assertPathEqual(w.last_dir, "/home/u/orange/") self.assertPathEqual(w.filename, "/home/u/orange/c.foo") self.assertTrue(w.auto_save) + self.assertFalse(w.Warning.auto_save_disabled.is_shown()) w = self.create_widget( self.OWSaveMockWriter, @@ -202,6 +210,7 @@ def test_open_moved_workflow(self): self.assertPathEqual(w.last_dir, "/home/u/orange/") self.assertPathEqual(w.filename, "/home/u/orange/c.foo") self.assertTrue(w.auto_save) + self.assertFalse(w.Warning.auto_save_disabled.is_shown()) def test_move_workflow(self): """Widget correctly stores relative paths""" @@ -252,6 +261,8 @@ def test_move_workflow(self): self.assertPathEqual(w.stored_path, ".") self.assertEqual(w.stored_name, "c.foo") + @patch("os.path.isabs", + lambda name: name in ["/a/b"]) # Python 3.13+ made that False on Windows def test_migrate_pre_relative_settings(self): with patch("os.path.exists", lambda name: name == "/a/b"): w = self.create_widget( @@ -276,6 +287,41 @@ def test_save_button_label(self): stored_settings=dict(stored_path="", stored_name="c.foo")) self.assertTrue(w.bt_save.text().endswith(" c.foo")) + def test_invalid_filter(self): + writer = Mock() + + class OWSaveNoWriter(OWSaveBase): + name = "Mock save" + keywords = "mock save" + writers = {} + filters = {"csv (*.csv)": writer} + + w = self.create_widget( + OWSaveNoWriter, + stored_settings=dict( + filter="Unsupported format (*.foo)", stored_path='test.foo') + ) + w.data = Mock() + self.assertIsNone(w.writer) + + w.do_save() + self.assertTrue(w.Error.unsupported_format.is_shown()) + + name = "/home/u/orange/a/b/c.csv" + w.get_save_filename = Mock(return_value=(name, "csv (*.csv)")) + w.save_file_as() + self.assertFalse(w.Error.unsupported_format.is_shown()) + call_name, _ = writer.write.call_args[0] + self.assertPathEqual(call_name, name) + + def test_default_filter(self): + class OWSave(OWSaveBase): + name = "Mock save" + filters = {"csv (*.csv)": Mock(), "txt (*.txt)": Mock()} + + widget = self.create_widget(OWSave) + self.assertEqual(widget.default_filter(), "csv (*.csv)") + class TestOWSaveBase(WidgetTest): # Tests for OWSaveBase methods with filters as list @@ -283,6 +329,7 @@ def setUp(self): class OWSaveMockWriter(OWSaveBase): name = "Mock save" filters = ["csv (*.csv)"] + keywords = "mock save" do_save = Mock() @@ -317,6 +364,61 @@ def test_base_methods(self): self.assertIs(widget.valid_filters(), widget.get_filters()) self.assertIs(widget.default_valid_filter(), widget.filter) + def test_default_filter(self): + class OWSave(OWSaveBase): + name = "Mock save" + filters = ["csv (*.csv)", "txt (*.txt)"] + + widget = self.create_widget(OWSave) + self.assertEqual(widget.default_filter(), OWSave.filters[0]) + + @unittest.skipUnless(sys.platform.startswith("win"), "windows path tests") + def test_paths_win(self): + class OWSave(OWSaveBase): + name = "Mock save" + filters = ["csv (*.csv)", "txt (*.txt)"] + widget = self.create_widget(OWSave) + # relative stored paths + for workflow_dir, filename in [("C:/Temp", "C:/Temp/abc.csv"), + ("C:/Temp", "C:/Temp/Project/abc.csv"), + ("C:/Temp/", "C:/Temp/abc.csv"), + ("C:/Temp/", "C:/Temp/Project/abc.csv"), + ("C:/Temp", "c:\\Temp\\Project\\abc.csv"), + ("c:\\Temp", "c:/Temp/Project\\abc.csv")]: + widget.workflowEnv = lambda bd=workflow_dir: {"basedir": bd} + widget.filename = filename + self.assertFalse(os.path.isabs(widget.stored_path)) + # absolute stored paths + for workflow_dir, filename in [("C:/Temp", "C:/Folder/abc.csv"), + ("C:/Temp/Project", "C:/Temp/abc.csv"), + ("C:\\Temp\\Project", "C:\\Temp\\abc.csv"), + ("C:/Temp", "D:/Folder/abc.csv"), + ("C:\\Temp\\Project", "D:\\Temp\\abc.csv")]: + widget.workflowEnv = lambda bd=workflow_dir: {"basedir": bd} + widget.filename = filename + self.assertTrue(os.path.isabs(widget.stored_path)) + + @unittest.skipIf(sys.platform.startswith("win"), "unix path tests") + def test_paths_unix(self): + class OWSave(OWSaveBase): + name = "Mock save" + filters = ["csv (*.csv)", "txt (*.txt)"] + widget = self.create_widget(OWSave) + # relative stored paths + for workflow_dir, filename in [("/temp", "/temp/abc.csv"), + ("/temp", "/temp/project/abc.csv"), + ("/temp/", "/temp/abc.csv"), + ("/temp/", "/temp/project/abc.csv")]: + widget.workflowEnv = lambda bd=workflow_dir: {"basedir": bd} + widget.filename = filename + self.assertFalse(os.path.isabs(widget.stored_path)) + # absolute stored paths + for workflow_dir, filename in [("/temp", "/folder/abc.csv"), + ("/temp/project", "/temp/abc.csv")]: + widget.workflowEnv = lambda bd=workflow_dir: {"basedir": bd} + widget.filename = filename + self.assertTrue(os.path.isabs(widget.stored_path)) + class TestOWSaveUtils(unittest.TestCase): def test_replace_extension(self): diff --git a/Orange/widgets/utils/signals.py b/Orange/widgets/utils/signals.py index 014bc81b616..2550c79cc60 100644 --- a/Orange/widgets/utils/signals.py +++ b/Orange/widgets/utils/signals.py @@ -1,8 +1,12 @@ +from typing import Optional + from orangewidget.utils.signals import ( Input, Output, Single, Multiple, Default, NonDefault, Explicit, Dynamic, - InputSignal, OutputSignal, WidgetSignalsMixin + InputSignal, OutputSignal, WidgetSignalsMixin, LazyValue ) +from Orange.data import Table, Domain + __all__ = [ "Input", "Output", "InputSignal", "OutputSignal", "Single", "Multiple", "Default", "NonDefault", "Explicit", "Dynamic", @@ -12,3 +16,11 @@ class AttributeList(list): """Signal type for lists of attributes (variables)""" + + +def lazy_table_transform(domain: Domain, + data: Optional[Table]) -> LazyValue[Table]: + if data is None: + return None + return LazyValue[Table](lambda: data.transform(domain), + domain=domain, length=len(data)) diff --git a/Orange/widgets/utils/slidergraph.py b/Orange/widgets/utils/slidergraph.py index e18327381ef..f91751e1cb3 100644 --- a/Orange/widgets/utils/slidergraph.py +++ b/Orange/widgets/utils/slidergraph.py @@ -1,9 +1,39 @@ import numpy as np -from pyqtgraph import PlotWidget, mkPen, InfiniteLine, PlotCurveItem, \ +from pyqtgraph import mkPen, InfiniteLine, PlotCurveItem, \ TextItem, Point from AnyQt.QtGui import QColor from AnyQt.QtCore import Qt +from Orange.widgets.visualize.utils.plotutils import PlotWidget + + +class InteractiveInfiniteLine(InfiniteLine): # pylint: disable=abstract-method + """ + A subclass of InfiniteLine that provides custom hover behavior. + """ + + def __init__(self, angle=90, pos=None, movable=False, bounds=None, + normal_pen=None, highlight_pen=None, **kwargs): + super().__init__(angle=angle, pos=pos, movable=movable, bounds=bounds, **kwargs) + self._normal_pen = normal_pen + self._highlight_pen = highlight_pen + self.setPen(normal_pen) + + def hoverEvent(self, ev): + """ + Override hoverEvent to provide custom hover behavior. + + Parameters + ---------- + ev : HoverEvent + The hover event from pyqtgraph + """ + + if ev.isEnter() and self._highlight_pen is not None: + self.setPen(self._highlight_pen) + elif ev.isExit() and self._normal_pen is not None: + self.setPen(self._normal_pen) + class SliderGraph(PlotWidget): """ @@ -12,7 +42,7 @@ class SliderGraph(PlotWidget): the line is moved a callback function is called with selected value (on x axis). - Attributes + Parameters ---------- x_axis_label : str A text label for x axis @@ -20,12 +50,10 @@ class SliderGraph(PlotWidget): A text label for y axis callback : callable A function which is called when selection is changed. - background : str, optional (default: "w") - Plot background color """ - def __init__(self, x_axis_label, y_axis_label, callback): - super().__init__(background="w") + def __init__(self, x_axis_label, y_axis_label, callback, **kwargs): + super().__init__(**kwargs) axis = self.getAxis("bottom") axis.setLabel(x_axis_label) @@ -83,7 +111,8 @@ def update(self, x, y, colors, cutpoint_x=None, selection_limit=None, self.selection_limit = selection_limit self.data_increasing = [np.sum(d[1:] - d[:-1]) > 0 for d in y] - + foreground = self.palette().text().color() + foreground.setAlpha(128) # plot sequence for s, c, n, inc in zip(y, colors, names, self.data_increasing): c = QColor(c) @@ -91,7 +120,7 @@ def update(self, x, y, colors, cutpoint_x=None, selection_limit=None, if n is not None: label = TextItem( - text=n, anchor=(0, 1), color=QColor(0, 0, 0, 128)) + text=n, anchor=(0, 1), color=foreground) label.setPos(x[-1], s[-1]) self._set_anchor(label, len(x) - 1, inc) self.addItem(label) @@ -135,14 +164,23 @@ def _plot_cutpoint(self, x): self._line = None return if self._line is None: - # plot interactive vertical line - self._line = InfiniteLine( + normal_pen = mkPen( + self.palette().text().color(), width=4, + style=Qt.SolidLine, capStyle=Qt.RoundCap + ) + highlight_pen = mkPen( + self.palette().link().color(), width=4, + style=Qt.SolidLine, capStyle=Qt.RoundCap + ) + + self._line = InteractiveInfiniteLine( angle=90, pos=x, movable=True, bounds=self.selection_limit if self.selection_limit is not None - else (self.x.min(), self.x.max()) + else (self.x.min(), self.x.max()), + normal_pen=normal_pen, + highlight_pen=highlight_pen ) self._line.setCursor(Qt.SizeHorCursor) - self._line.setPen(mkPen(QColor(Qt.black), width=2)) self._line.sigPositionChanged.connect(self._on_cut_changed) self.addItem(self._line) else: @@ -155,11 +193,13 @@ def _plot_horizontal_lines(self): Function plots the vertical dashed lines that points to the selected sequence values at the y axis. """ + highlight = self.palette().highlight() + text = self.palette().text() for _ in range(len(self.sequences)): self.plot_horline.append(PlotCurveItem( - pen=mkPen(QColor(Qt.blue), style=Qt.DashLine))) + pen=mkPen(highlight.color(), style=Qt.DashLine))) self.plot_horlabel.append(TextItem( - color=QColor(Qt.black), anchor=(0, 1))) + color=text.color(), anchor=(0, 1))) for item in self.plot_horlabel + self.plot_horline: self.addItem(item) diff --git a/Orange/widgets/utils/spinbox.py b/Orange/widgets/utils/spinbox.py index 74092071587..d238cb2655a 100644 --- a/Orange/widgets/utils/spinbox.py +++ b/Orange/widgets/utils/spinbox.py @@ -3,8 +3,8 @@ import numpy as np -from AnyQt.QtCore import QLocale -from AnyQt.QtWidgets import QDoubleSpinBox +from AnyQt.QtCore import QLocale, QSize +from AnyQt.QtWidgets import QDoubleSpinBox, QStyle, QStyleOptionSpinBox DBL_MIN = float(np.finfo(float).min) DBL_MAX = float(np.finfo(float).max) @@ -16,9 +16,11 @@ class DoubleSpinBox(QDoubleSpinBox): """ A QDoubleSpinSubclass with non-fixed decimal precision/rounding. """ - def __init__(self, parent=None, decimals=-1, minimumStep=1e-5, **kwargs): + def __init__(self, parent=None, decimals=-1, minimumStep=1e-5, + minimumContentsLenght=-1, **kwargs): self.__decimals = decimals self.__minimumStep = minimumStep + self.__minimumContentsLength = minimumContentsLenght stepType = kwargs.pop("stepType", DoubleSpinBox.DefaultStepType) super().__init__(parent, **kwargs) if decimals < 0: @@ -107,3 +109,42 @@ def setStepType(self, stepType): def stepType(self): return self.__stepType + + def setMinimumContentsLength(self, characters: int): + self.__minimumContentsLength = characters + self.updateGeometry() + + def minimumContentsLength(self): + return self.__minimumContentsLength + + def sizeHint(self) -> QSize: + if self.minimumContentsLength() < 0: + return super().sizeHint() + self.ensurePolished() + fm = self.fontMetrics() + template = "X" * self.minimumContentsLength() + template += "." + if self.prefix(): + template = self.prefix() + " " + template + if self.suffix(): + template = template + self.suffix() + if self.minimum() < 0.0: + template = "-" + template + if self.specialValueText(): + templates = [template, self.specialValueText()] + else: + templates = [template] + height = self.lineEdit().sizeHint().height() + width = max(map(fm.horizontalAdvance, templates)) + width += 2 # cursor blinking space + hint = QSize(width, height) + opt = QStyleOptionSpinBox() + self.initStyleOption(opt) + sh = self.style().sizeFromContents(QStyle.CT_SpinBox, opt, hint, self) + return sh + + def minimumSizeHint(self) -> QSize: + if self.minimumContentsLength() < 0: + return super().minimumSizeHint() + else: + return self.sizeHint() diff --git a/Orange/widgets/utils/sql.py b/Orange/widgets/utils/sql.py index 986bb3fee16..8d37abb91a0 100644 --- a/Orange/widgets/utils/sql.py +++ b/Orange/widgets/utils/sql.py @@ -23,7 +23,7 @@ def new_f(widget, data, *args, **kwargs): widget.Error.add_message("download_sql_data", _download_sql_data) widget.Error.download_sql_data.clear() if isinstance(data, SqlTable): - if data.approx_len() < AUTO_DL_LIMIT: + if len(data) < AUTO_DL_LIMIT: data = Table(data) else: widget.Error.download_sql_data() @@ -31,3 +31,28 @@ def new_f(widget, data, *args, **kwargs): return f(widget, data, *args, **kwargs) return new_f + + +def check_sql_input_sequence(f): + """ + Wrapper for widget's set_data/insert_data methods that first checks + if the input is a SqlTable and: + - if small enough, download all data and convert to Table + - for large sql tables, show an error + + :param f: widget's `set_data` method to wrap + :return: wrapped method that handles SQL data inputs + """ + @wraps(f) + def new_f(widget, index, data, *args, **kwargs): + widget.Error.add_message("download_sql_data", _download_sql_data) + widget.Error.download_sql_data.clear() + if isinstance(data, SqlTable): + if len(data) < AUTO_DL_LIMIT: + data = Table(data) + else: + widget.Error.download_sql_data() + data = None + return f(widget, index, data, *args, **kwargs) + + return new_f diff --git a/Orange/widgets/utils/state_summary.py b/Orange/widgets/utils/state_summary.py index cdcca2efa8d..2fe0f62e2f4 100644 --- a/Orange/widgets/utils/state_summary.py +++ b/Orange/widgets/utils/state_summary.py @@ -1,13 +1,19 @@ from datetime import date from html import escape +from typing import Union from AnyQt.QtCore import Qt -from orangewidget.utils.signals import summarize, PartialSummary +from Orange.widgets.utils.localization import pl +from orangewidget.utils.signals import summarize, PartialSummary, LazyValue +from Orange.widgets.utils.itemmodels import TableModel +from Orange.widgets.utils.tableview import TableView +from Orange.widgets.utils.distmatrixmodel import \ + DistMatrixModel, DistMatrixView from Orange.data import ( StringVariable, DiscreteVariable, ContinuousVariable, TimeVariable, - Table + Table, Domain ) from Orange.evaluation import Results @@ -18,6 +24,9 @@ from Orange.base import Model, Learner +COMPUTE_NANS_LIMIT = 1e7 + + def format_variables_string(variables): """ A function that formats the descriptive part of the input/output summary for @@ -53,63 +62,67 @@ def format_variables_string(variables): return var_string -def _plural(number): - return 's' * (number % 100 != 1) - - # `format` is a good name for the argument, pylint: disable=redefined-builtin -def format_summary_details(data, format=Qt.PlainText): +def format_summary_details(data: Union[Table, Domain], + format=Qt.PlainText, missing=None): """ A function that forms the entire descriptive part of the input/output summary. :param data: A dataset - :type data: Orange.data.Table + :type data: Orange.data.Table or Orange.data.Domain :return: A formatted string """ if data is None: return "" - if format == Qt.PlainText: - def b(s): - return s - else: - def b(s): - return f"{s}" - - features = format_variables_string(data.domain.attributes) - targets = format_variables_string(data.domain.class_vars) - metas = format_variables_string(data.domain.metas) - - features_missing = missing_values(data.has_missing_attribute() - and data.get_nan_frequency_attribute()) - n_features = len(data.domain.variables) + len(data.domain.metas) - name = getattr(data, "name", None) - if name == "untitled": + features_missing = "" if missing is None else missing_values(missing) + if isinstance(data, Domain): + domain = data name = None - basic = f'{len(data):n} instance{_plural(len(data))}, ' \ - f'{n_features} variable{_plural(n_features)}' + basic = "" + else: + assert isinstance(data, Table) + domain = data.domain + if not features_missing and \ + len(data) * len(domain.attributes) < COMPUTE_NANS_LIMIT: + features_missing \ + = missing_values(data.get_nan_frequency_attribute()) + name = getattr(data, "name", None) + if name == "untitled": + name = None + basic = f'{len(data):n} {pl(len(data), "instance")}, ' + + n_features = len(domain.variables) + len(domain.metas) + basic += f'{n_features} {pl(n_features, "variable")}' + + features = format_variables_string(domain.attributes) + features = f'Features: {features}{features_missing}' + + targets = format_variables_string(domain.class_vars) + targets = f'Target: {targets}' + + metas = format_variables_string(domain.metas) + metas = f'Metas: {metas}' if format == Qt.PlainText: - details = \ - (f"{name}: " if name else "") + basic \ - + f'\nFeatures: {features} {features_missing}' \ - + f'\nTarget: {targets}' - if data.domain.metas: - details += f'\nMetas: {metas}' + details = f"{name}: " if name else "Table with " + details += f"{basic}\n{features}\n{targets}" + if domain.metas: + details += f"\n{metas}" else: descs = [] if name: descs.append(_nobr(f"{escape(name)}: {basic}")) else: - descs.append(_nobr(f'{basic}')) + descs.append(_nobr(f"Table with {basic}")) - if data.domain.variables: - descs.append(_nobr(f'Features: {features} {features_missing}')) - if data.domain.class_vars: - descs.append(_nobr(f"Target: {targets}")) - if data.domain.metas: - descs.append(_nobr(f"Metas: {metas}")) + if domain.variables: + descs.append(_nobr(features)) + if domain.class_vars: + descs.append(_nobr(targets)) + if domain.metas: + descs.append(_nobr(metas)) details = '
      '.join(descs) @@ -118,9 +131,11 @@ def b(s): def missing_values(value): if value: - return f'({value*100:.1f}% missing values)' + return f' ({value*100:.1f}% missing values)' + elif value is None: + return '' else: - return '(no missing values)' + return ' (no missing values)' def format_multiple_summaries(data_list, type_io='input'): @@ -161,30 +176,78 @@ def _nobr(s): return f"{s}" -@summarize.register(Table) -def summarize_(data: Table): +@summarize.register +def summarize_table(data: Table): # pylint: disable=function-redefined return PartialSummary( - data.approx_len(), - format_summary_details(data, format=Qt.RichText)) + len(data), + format_summary_details(data, format=Qt.RichText), + lambda: _table_previewer(data)) -@summarize.register(DistMatrix) -def summarize_(matrix: DistMatrix): # pylint: disable=function-redefined - n, m = matrix.shape - return PartialSummary(f"{n}×{m}", _nobr(f"{n}×{m} distance matrix")) +@summarize.register +def summarize_table(data: LazyValue[Table]): + if data.is_cached: + return summarize(data.get_value()) + + length = getattr(data, "length", "?") + details = format_summary_details(data.domain, format=Qt.RichText, + missing=getattr(data, "missing", None)) \ + if hasattr(data, "domain") else "data available, but not prepared yet" + return PartialSummary( + length, + details, + lambda: _table_previewer(data.get_value())) + + +def _table_previewer(data): + view = TableView(selectionMode=TableView.NoSelection) + view.setModel(TableModel(data)) + return view + + +@summarize.register +def summarize_matrix(matrix: DistMatrix): # pylint: disable=function-redefined + def previewer(): + view = DistMatrixView(selectionMode=TableView.NoSelection) + model = DistMatrixModel() + model.set_data(matrix) + col_labels = matrix.get_labels(matrix.col_items) + row_labels = matrix.get_labels(matrix.row_items) + if matrix.is_symmetric() and ( + (col_labels is None) is not (row_labels is None)): + if col_labels is None: + col_labels = row_labels + else: + row_labels = col_labels + if col_labels is None: + col_labels = [str(x) for x in range(w)] + if row_labels is None: + row_labels = [str(x) for x in range(h)] + model.set_labels(Qt.Horizontal, col_labels) + model.set_labels(Qt.Vertical, row_labels) + view.setModel(model) + + return view + + h, w = matrix.shape + return PartialSummary( + f"{w}×{h}", + _nobr(f"{w}×{h} distance matrix"), + previewer + ) -@summarize.register(Results) -def summarize_(results: Results): # pylint: disable=function-redefined +@summarize.register +def summarize_results(results: Results): # pylint: disable=function-redefined nmethods, ninstances = results.predicted.shape summary = f"{nmethods}×{ninstances}" - details = f"{nmethods} method{_plural(nmethods)} " \ - f"on {ninstances} test instance{_plural(ninstances)}" + details = f"{nmethods} {pl(nmethods, 'method')} " \ + f"on {ninstances} test {pl(ninstances, 'instance')}" return PartialSummary(summary, _nobr(details)) -@summarize.register(AttributeList) -def summarize_(attributes): # pylint: disable=function-redefined +@summarize.register +def summarize_attributes(attributes: AttributeList): # pylint: disable=function-redefined n = len(attributes) if n == 0: details = "empty list" @@ -196,8 +259,8 @@ def summarize_(attributes): # pylint: disable=function-redefined return PartialSummary(n, details) -@summarize.register(Preprocess) -def summarize_(preprocessor: Preprocess): +@summarize.register +def summarize_preprocessor(preprocessor: Preprocess): # pylint: disable=function-redefined if isinstance(preprocessor, PreprocessorList): if preprocessor.preprocessors: details = "
      ".join(map(_name_of, preprocessor.preprocessors)) @@ -209,11 +272,11 @@ def summarize_(preprocessor: Preprocess): def summarize_by_name(type_, symbol): - @summarize.register(type_) + @summarize.register def summarize_(model: type_): return PartialSummary(symbol, _name_of(model)) -summarize_by_name(Model, "⛄" if date.month == 12 else "🄼") +summarize_by_name(Model, "⛄" if date.today().month == 12 else "🄼") summarize_by_name(Learner, "🄻") summarize_by_name(Scorer, "🅂") diff --git a/Orange/widgets/utils/stickygraphicsview.py b/Orange/widgets/utils/stickygraphicsview.py index dee2a042958..d228c0f5edb 100644 --- a/Orange/widgets/utils/stickygraphicsview.py +++ b/Orange/widgets/utils/stickygraphicsview.py @@ -24,7 +24,7 @@ def __init__(self, *args, **kwargs) -> None: ds = QGraphicsDropShadowEffect( parent=self, objectName="sticky-view-shadow", - color=palette.color(QPalette.Foreground), + color=palette.color(QPalette.WindowText), blurRadius=15, offset=QPointF(0, 0), enabled=True @@ -38,7 +38,7 @@ def changeEvent(self, event: QEvent) -> None: QGraphicsDropShadowEffect, "sticky-view-shadow") if effect is not None: palette = self.palette() - effect.setColor(palette.color(QPalette.Foreground)) + effect.setColor(palette.color(QPalette.WindowText)) def eventFilter(self, recv: QObject, event: QEvent) -> bool: if event.type() in (QEvent.Show, QEvent.Hide) and recv is self.widget(): @@ -151,8 +151,10 @@ def setupViewport(self, widget: QWidget) -> None: sizePolicy=sp, visible=False, ) - over.setLayout(QVBoxLayout(margin=0)) - over.layout().addWidget(header) + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(header) + over.setLayout(layout) over.setWidget(widget) over = _OverlayWidget( @@ -161,8 +163,10 @@ def setupViewport(self, widget: QWidget) -> None: sizePolicy=sp, visible=False ) - over.setLayout(QVBoxLayout(margin=0)) - over.layout().addWidget(footer) + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(footer) + over.setLayout(layout) over.setWidget(widget) def bind(source: QScrollBar, target: QScrollBar) -> None: diff --git a/Orange/widgets/utils/tableview.py b/Orange/widgets/utils/tableview.py index 56f04adacae..90a77bfd597 100644 --- a/Orange/widgets/utils/tableview.py +++ b/Orange/widgets/utils/tableview.py @@ -2,7 +2,7 @@ import csv from AnyQt.QtCore import Signal, QItemSelectionModel, Qt, QSize, QEvent, \ - QByteArray, QMimeData + QByteArray, QMimeData, QT_VERSION_INFO from AnyQt.QtGui import QMouseEvent from AnyQt.QtWidgets import QTableView, QStyleOptionViewItem, QStyle @@ -49,6 +49,16 @@ def __init__(self, *args, **kwargs,): self.setHorizontalHeader(hheader) self.setVerticalHeader(vheader) table_view_compact(self) + if QT_VERSION_INFO < (5, 13): + hheader.sortIndicatorChanged.connect(self.__sort_reset) + + if QT_VERSION_INFO < (5, 13): + def __sort_reset(self, column, order): + # Prior to Qt 5.13 QTableView did not propagate sort by -1 column + # (i.e. sort reset) to models. + if self.model() is not None and column == -1 and \ + self.isSortingEnabled(): + self.model().sort(column, order) def setSelectionModel(self, selectionModel: QItemSelectionModel) -> None: """Reimplemented from QTableView""" diff --git a/Orange/widgets/utils/tests/concurrent_example.py b/Orange/widgets/utils/tests/concurrent_example.py index 3fb8a513455..cd4ce972c26 100644 --- a/Orange/widgets/utils/tests/concurrent_example.py +++ b/Orange/widgets/utils/tests/concurrent_example.py @@ -43,6 +43,7 @@ def run(data: Table, embedding: Optional[np.ndarray], state: TaskState): class OWConcurrentWidget(OWDataProjectionWidget, ConcurrentWidgetMixin): name = "Projection" + keywords = "concurrent, projection, example" param = Setting(0) def __init__(self): @@ -69,7 +70,7 @@ def _toggle_run(self): if self.task is not None: self.cancel() self.run_button.setText("Resume") - self.commit() + self.commit.deferred() # Resume task else: self._run() @@ -97,12 +98,13 @@ def on_done(self, result: Result): assert len(result.embedding) == len(self.data) self.embedding = result.embedding self.run_button.setText("Start") - self.commit() + self.commit.deferred() def on_exception(self, ex: Exception): raise ex # OWDataProjectionWidget + @OWDataProjectionWidget.Inputs.data def set_data(self, data: Table): super().set_data(data) if self._invalidated: diff --git a/Orange/widgets/utils/tests/test_annotated_data.py b/Orange/widgets/utils/tests/test_annotated_data.py index a2b5647d3e6..47bdda3910a 100644 --- a/Orange/widgets/utils/tests/test_annotated_data.py +++ b/Orange/widgets/utils/tests/test_annotated_data.py @@ -1,12 +1,16 @@ +from unittest.mock import patch + import random import unittest import numpy as np -from Orange.data import Table, Domain, StringVariable, DiscreteVariable +from Orange.data import Table, Domain, StringVariable, DiscreteVariable, \ + ContinuousVariable from Orange.data.filter import SameValue from Orange.widgets.utils.annotated_data import ( - create_annotated_table, create_groups_table, ANNOTATED_DATA_FEATURE_NAME + create_annotated_table, create_groups_table, ANNOTATED_DATA_FEATURE_NAME, + lazy_annotated_table, lazy_groups_table, domain_with_annotation_column ) @@ -15,6 +19,42 @@ def setUp(self): random.seed(42) self.zoo = Table("zoo") + def test_domain_with_annotation_column(self): + a, b, c = (ContinuousVariable(x) for x in "abc") + + x = [[1, 2, 3], [4, 5, 6]] + + for data in (dabc := Domain([a, b, c]), Table.from_list(dabc, x)): + dom, var = domain_with_annotation_column(data) + self.assertEqual(dom.attributes, (a, b, c)) + self.assertIs(dom.class_var, var) + self.assertEqual(var.name, ANNOTATED_DATA_FEATURE_NAME) + self.assertEqual(var.values, ("No", "Yes")) + + dom, var = domain_with_annotation_column( + data, values=tuple("xyz"), var_name="d") + self.assertEqual(dom.attributes, (a, b, c)) + self.assertIs(dom.class_var, var) + self.assertEqual(var.name, "d") + self.assertEqual(var.values, tuple("xyz")) + + for data in (dabc := Domain([a, b], c), Table.from_list(dabc, x)): + dom, var = domain_with_annotation_column( + data, values=tuple("xyz"), var_name="d") + self.assertEqual(dom.attributes, (a, b)) + self.assertIs(dom.class_var, c) + self.assertEqual(dom.metas, (var, )) + self.assertEqual(var.name, "d") + self.assertEqual(var.values, tuple("xyz")) + + dom, var = domain_with_annotation_column( + data, values=tuple("xyz"), var_name="c") + self.assertEqual(dom.attributes, (a, b)) + self.assertIs(dom.class_var, c) + self.assertEqual(dom.metas, (var, )) + self.assertEqual(var.name, "c (1)") + self.assertEqual(var.values, tuple("xyz")) + def test_create_annotated_table(self): annotated = create_annotated_table(self.zoo, list(range(10))) @@ -129,3 +169,52 @@ def test_create_groups_table_set_values(self): values = ("this", "that", "rest") table = create_groups_table(self.zoo, selection, values=values) self.assertEqual(tuple(table.domain["Selected"].values), values) + + @patch("Orange.widgets.utils.annotated_data.create_annotated_table") + def test_lazy_annotated_table(self, creator): + selected_indices = np.array([1, 2, 3]) + lazy_table = lazy_annotated_table(self.zoo, selected_indices) + self.assertEqual(lazy_table.length, len(self.zoo)) + self.assertEqual(lazy_table.domain.attributes, self.zoo.domain.attributes) + self.assertEqual(lazy_table.domain.class_var, self.zoo.domain.class_var) + self.assertEqual(len(lazy_table.domain.metas), 2) + var = lazy_table.domain.metas[1] + self.assertIsInstance(var, DiscreteVariable) + self.assertEqual(var.name, ANNOTATED_DATA_FEATURE_NAME) + creator.assert_not_called() + self.assertIs(lazy_table.get_value(), creator.return_value) + + @patch("Orange.widgets.utils.annotated_data.create_groups_table") + def test_lazy_groups_table(self, creator): + group_indices = np.zeros(len(self.zoo), dtype=int) + group_indices[10:15] = 1 + + lazy_table = lazy_groups_table(self.zoo, group_indices) + self.assertEqual(lazy_table.length, len(self.zoo)) + self.assertEqual(lazy_table.domain.attributes, self.zoo.domain.attributes) + self.assertEqual(lazy_table.domain.class_var, self.zoo.domain.class_var) + self.assertEqual(len(lazy_table.domain.metas), 2) + var = lazy_table.domain.metas[1] + self.assertIsInstance(var, DiscreteVariable) + self.assertEqual(var.name, ANNOTATED_DATA_FEATURE_NAME) + creator.assert_not_called() + self.assertIs(lazy_table.get_value(), creator.return_value) + creator.reset_mock() + + lazy_table = lazy_groups_table( + self.zoo, group_indices, include_unselected=False, var_name="foo", + values=("bar", "baz")) + self.assertEqual(lazy_table.length, 5) + self.assertEqual(lazy_table.domain.attributes, self.zoo.domain.attributes) + self.assertEqual(lazy_table.domain.class_var, self.zoo.domain.class_var) + self.assertEqual(len(lazy_table.domain.metas), 2) + var = lazy_table.domain.metas[1] + self.assertIsInstance(var, DiscreteVariable) + self.assertEqual(var.name, "foo") + self.assertEqual(var.values, ("bar", "baz")) + creator.assert_not_called() + self.assertIs(lazy_table.get_value(), creator.return_value) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/utils/tests/test_colorgradientselection.py b/Orange/widgets/utils/tests/test_colorgradientselection.py index 2bc086cfc02..e372f5c9cad 100644 --- a/Orange/widgets/utils/tests/test_colorgradientselection.py +++ b/Orange/widgets/utils/tests/test_colorgradientselection.py @@ -1,8 +1,6 @@ -from unittest.mock import Mock - import numpy as np -from AnyQt.QtTest import QSignalSpy +from AnyQt.QtTest import QSignalSpy, QTest from AnyQt.QtCore import Qt, QStringListModel, QModelIndex from Orange.widgets.utils import itemmodels @@ -56,22 +54,14 @@ def test_slider_move(self): w.adjustSize() w.setThresholds(0.5, 0.5) changed = QSignalSpy(w.thresholdsChanged) - sl, sh = w.slider_low, w.slider_high - sl.triggerAction(sl.SliderToMinimum) + w.slider.setLow(25) self.assertEqual(len(changed), 1) - low, high = changed[-1] - self.assertLessEqual(low, high) - self.assertEqual(low, 0.0) - sl.triggerAction(sl.SliderToMaximum) + self.assertEqual(changed[-1], [0.25, 0.5]) + self.assertEqual(w.thresholds(), (0.25, 0.5)) + w.slider.setHigh(75) self.assertEqual(len(changed), 2) - low, high = changed[-1] - self.assertLessEqual(low, high) - self.assertEqual(low, 1.0) - sh.triggerAction(sl.SliderToMinimum) - self.assertEqual(len(changed), 3) - low, high = changed[-1] - self.assertLessEqual(low, high) - self.assertEqual(high, 0.0) + self.assertEqual(changed[-1], [0.25, 0.75]) + self.assertEqual(w.thresholds(), (0.25, 0.75)) def test_center(self): w = ColorGradientSelection(center=42) @@ -81,7 +71,6 @@ def test_center(self): def test_center_visibility(self): w = ColorGradientSelection(center=0) - w.center_box.setVisible = Mock() model = itemmodels.ContinuousPalettesModel() w.setModel(model) for row in range(model.rowCount(QModelIndex())): @@ -93,19 +82,21 @@ def test_center_visibility(self): nondiverging = row w.setCurrentIndex(diverging) - w.center_box.setVisible.assert_called_with(True) + self.assertIsNotNone(w.center_edit.parent()) w.setCurrentIndex(nondiverging) - w.center_box.setVisible.assert_called_with(False) + self.assertIsNone(w.center_edit.parent()) w.setCurrentIndex(diverging) - w.center_box.setVisible.assert_called_with(True) + self.assertIsNotNone(w.center_edit.parent()) w = ColorGradientSelection() - self.assertIsNone(w.center_box) + self.assertIsNone(w.center_edit) def test_center_changed(self): w = ColorGradientSelection(center=42) changed = QSignalSpy(w.centerChanged) - w.center_edit.setText("41") - w.center_edit.editingFinished.emit() - self.assertEqual(w.center(), 41) - self.assertEqual(list(changed), [[41]]) + ledit = w.center_edit.lineEdit() + ledit.selectAll() + QTest.keyClicks(ledit, "41") + QTest.keyClick(ledit, Qt.Key_Return) + self.assertEqual(w.center(), 41.0) + self.assertEqual(list(changed), [[41.0]]) diff --git a/Orange/widgets/utils/tests/test_colorpalette.py b/Orange/widgets/utils/tests/test_colorpalette.py deleted file mode 100644 index dbadbf020c3..00000000000 --- a/Orange/widgets/utils/tests/test_colorpalette.py +++ /dev/null @@ -1,47 +0,0 @@ -import warnings -import unittest - -import numpy as np - -from AnyQt.QtCore import Qt -from AnyQt.QtGui import QColor - -from Orange.widgets.tests.base import WidgetTest - -with warnings.catch_warnings(): - # This test tests an obsolete module, hence this warning is expected - warnings.filterwarnings("ignore", ".*", DeprecationWarning) - from Orange.widgets.utils.colorpalette import \ - ColorPaletteDlg, GradientPaletteGenerator, NAN_GREY - - -class TestColorPalette(WidgetTest): - def test_colorpalette(self): - dlg = ColorPaletteDlg(None) - - dlg.createContinuousPalette( - "", "Gradient palette", False, QColor(Qt.white), QColor(Qt.black)) - - dlg.contLeft.getColor().getRgb() - dlg.contRight.getColor().getRgb() - dlg.contpassThroughBlack - - -class GradientPaletteGeneratorTest(unittest.TestCase): - def test_two_color(self): - generator = GradientPaletteGenerator('#000', '#fff') - for float_values, rgb in ((.5, (128, 128, 128)), - (np.nan, NAN_GREY), - ((0, .5, np.nan), ((0, 0, 0), - (128, 128, 128), - NAN_GREY))): - np.testing.assert_equal(generator.getRGB(float_values), rgb) - - def test_three_color(self): - generator = GradientPaletteGenerator('#f00', '#000', '#fff') - for float_values, rgb in ((.5, (0, 0, 0)), - (np.nan, NAN_GREY), - ((0, .5, 1), ((255, 0, 0), - (0, 0, 0), - (255, 255, 255)))): - np.testing.assert_equal(generator.getRGB(float_values), rgb) diff --git a/Orange/widgets/utils/tests/test_combobox.py b/Orange/widgets/utils/tests/test_combobox.py index 46d92210931..29a430f72d6 100644 --- a/Orange/widgets/utils/tests/test_combobox.py +++ b/Orange/widgets/utils/tests/test_combobox.py @@ -1,8 +1,12 @@ -from AnyQt.QtCore import Qt -from AnyQt.QtGui import QFont, QColor +from AnyQt.QtCore import Qt, QEvent +from AnyQt.QtGui import QFont, QColor, QFocusEvent +from AnyQt.QtWidgets import QApplication +from AnyQt.QtTest import QTest, QSignalSpy from orangewidget.tests.base import GuiTest -from Orange.widgets.utils.combobox import ItemStyledComboBox +from orangewidget.tests.utils import simulate +from orangewidget.utils.itemmodels import PyListModel +from Orange.widgets.utils.combobox import ItemStyledComboBox, TextEditCombo class TestItemStyledComboBox(GuiTest): @@ -19,3 +23,64 @@ def test_combobox(self): Qt.FontRole: QFont("Windings") }) cb.grab() + + +class TestTextEditCombo(GuiTest): + def test_texteditcombo(self): + cb = TextEditCombo() + model = PyListModel() + cb.setModel(model) + + def enter_text(text: str): + cb.lineEdit().selectAll() + spy_act = QSignalSpy(cb.activated[int]) + spy_edit = QSignalSpy(cb.editingFinished) + QTest.keyClick(cb.lineEdit(), Qt.Key_Delete) + QTest.keyClicks(cb.lineEdit(), text) + QApplication.sendEvent( + cb, QFocusEvent(QEvent.FocusOut, Qt.TabFocusReason) + ) + self.assertEqual(len(spy_edit), 1) + if cb.insertPolicy() != TextEditCombo.NoInsert: + self.assertEqual(list(spy_act), [[cb.currentIndex()]]) + + cb.setInsertPolicy(TextEditCombo.NoInsert) + enter_text("!!") + self.assertEqual(list(model), []) + cb.setInsertPolicy(TextEditCombo.InsertAtTop) + enter_text("BB") + enter_text("AA") + self.assertEqual(list(model), ["AA", "BB"]) + cb.setInsertPolicy(TextEditCombo.InsertAtBottom) + enter_text("CC") + self.assertEqual(list(model), ["AA", "BB", "CC"]) + cb.setInsertPolicy(TextEditCombo.InsertBeforeCurrent) + cb.setCurrentIndex(1) + enter_text("AB") + self.assertEqual(list(model), ["AA", "AB", "BB", "CC"]) + cb.setInsertPolicy(TextEditCombo.InsertAfterCurrent) + cb.setCurrentIndex(2) + enter_text("BC") + self.assertEqual(list(model), ["AA", "AB", "BB", "BC", "CC"]) + cb.setInsertPolicy(TextEditCombo.InsertAtCurrent) + cb.setCurrentIndex(2) + enter_text("BBA") + self.assertEqual(list(model), ["AA", "AB", "BBA", "BC", "CC"]) + cb.setInsertPolicy(TextEditCombo.InsertAlphabetically) + enter_text("BCA") + self.assertEqual(list(model), ["AA", "AB", "BBA", "BC", "BCA", "CC"]) + + def test_activate_editing_finished_emit_ordering(self): + def activated(): + sigs.append("activated") + + def finished(): + sigs.append("finished") + + sigs = [] + cb = TextEditCombo( + activated=activated, editingFinished=finished + ) + cb.insertItem(0, "AA") + simulate.combobox_activate_index(cb, 0) + self.assertEqual(sigs, ["finished", "activated"]) diff --git a/Orange/widgets/utils/tests/test_concurrent_example.py b/Orange/widgets/utils/tests/test_concurrent_example.py index 6e67dccb98e..26f91cfe53c 100644 --- a/Orange/widgets/utils/tests/test_concurrent_example.py +++ b/Orange/widgets/utils/tests/test_concurrent_example.py @@ -19,7 +19,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWConcurrentWidget.Inputs.data cls.signal_data = cls.data cls.same_input_output_domain = False @@ -44,16 +44,16 @@ def test_button_toggle(self): def test_plot_once(self): table = Table("heart_disease") self.widget.setup_plot = Mock() - self.widget.commit = self.widget.unconditional_commit = Mock() + self.widget.commit.now = self.widget.commit.deferred = Mock() self.send_signal(self.widget.Inputs.data, table) self.widget.setup_plot.assert_called_once() - self.widget.commit.assert_called_once() + self.widget.commit.deferred.assert_called_once() self.wait_until_stop_blocking() self.widget.setup_plot.reset_mock() - self.widget.commit.reset_mock() + self.widget.commit.deferred.reset_mock() self.send_signal(self.widget.Inputs.data_subset, table[::10]) self.widget.setup_plot.assert_not_called() - self.widget.commit.assert_called_once() + self.widget.commit.deferred.ssert_called_once() if __name__ == "__main__": diff --git a/Orange/widgets/utils/tests/test_dendrogram.py b/Orange/widgets/utils/tests/test_dendrogram.py index 96a4ff25892..a3f040c78a2 100644 --- a/Orange/widgets/utils/tests/test_dendrogram.py +++ b/Orange/widgets/utils/tests/test_dendrogram.py @@ -1,18 +1,37 @@ +# pylint: disable=all import numpy as np from AnyQt.QtCore import Qt, QPoint -from AnyQt.QtWidgets import QGraphicsScene +from AnyQt.QtGui import QPalette, QColor +from AnyQt.QtTest import QTest +from AnyQt.QtWidgets import QGraphicsScene, QGraphicsView +from orangewidget.tests.utils import mouseMove from orangewidget.tests.base import GuiTest from Orange.clustering import hierarchical from Orange.widgets.utils.dendrogram import DendrogramWidget +T = hierarchical.Tree +C = hierarchical.ClusterData +S = hierarchical.SingletonData + + +def t(h: float, left: T, right: T): + return T(C((left.value.first, right.value.last), h), (left, right)) + + +def leaf(r, index): + return T(S((r, r + 1), 0.0, index)) + + class TestDendrogramWidget(GuiTest): def setUp(self) -> None: super().setUp() self.scene = QGraphicsScene() + self.view = QGraphicsView(self.scene) + self.view.resize(300, 300) self.widget = DendrogramWidget() self.scene.addItem(self.widget) @@ -23,19 +42,6 @@ def tearDown(self) -> None: def test_widget(self): w = self.widget - - T = hierarchical.Tree - C = hierarchical.ClusterData - S = hierarchical.SingletonData - - def t(h: float, left: T, right: T): - return T(C((left.value.first, right.value.last), h), (left, right)) - - def leaf(r, index): - return T(S((r, r + 1), 0.0, index)) - - T = hierarchical.Tree - w.set_root(t(0.0, leaf(0, 0), leaf(1, 1))) w.resize(w.effectiveSizeHint(Qt.PreferredSize)) h = w.height_at(QPoint()) @@ -51,8 +57,33 @@ def leaf(r, index): h = w.height_at(QPoint()) self.assertEqual(h, height) - h = w.height_at(QPoint(w.size().width(), 0)) + h = w.height_at(QPoint(int(w.size().width()), 0)) self.assertEqual(h, 0) self.assertEqual(w.pos_at_height(0).x(), w.rect().right()) self.assertEqual(w.pos_at_height(height).x(), w.rect().left()) + + view = self.view + view.grab() # ensure w is laid out + root = w.root() + rootitem = w.item(root) + r = view.mapFromScene(rootitem.sceneBoundingRect()).boundingRect() + # move/hover over the item + mouseMove(view.viewport(), r.center()) + self.assertEqual(w._highlighted_item, rootitem) + # click select + QTest.mouseClick(view.viewport(), Qt.LeftButton, Qt.NoModifier, r.center()) + self.assertTrue(w.isItemSelected(rootitem)) + p = r.topLeft() + QPoint(-3, -3) # just out of the item + mouseMove(view.viewport(), p) + self.assertEqual(w._highlighted_item, None) + + def test_update_palette(self): + w = self.widget + w.set_root(t(1.0, leaf(0, 0), leaf(1, 1))) + w.setSelectedClusters([w.root()]) + p = QPalette() + p.setColor(QPalette.All, QPalette.WindowText, QColor(Qt.red)) + w.setPalette(p) + item = w.item(w.root()) + self.assertEqual(item.pen().color(), p.color(QPalette.WindowText)) diff --git a/Orange/widgets/utils/tests/test_distmatrixmodel.py b/Orange/widgets/utils/tests/test_distmatrixmodel.py new file mode 100644 index 00000000000..af81b677fb9 --- /dev/null +++ b/Orange/widgets/utils/tests/test_distmatrixmodel.py @@ -0,0 +1,85 @@ +import unittest + +import numpy as np +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QColor + +from orangewidget.tests.base import GuiTest + +from Orange.misc import DistMatrix +from Orange.widgets.utils.itemdelegates import FixedFormatNumericColumnDelegate +from Orange.widgets.utils.distmatrixmodel import DistMatrixModel + + +class TestModel(GuiTest): + def assert_brush_value(self, brush, value): + self.assert_brush_color(brush, QColor.fromHsv(120, int(value), 255)) + + def assert_brush_color(self, brush, color): + self.assertEqual(brush.color().getRgb()[:3], color.getRgb()[:3]) + + def test_data(self): + model = DistMatrixModel() + + dist = DistMatrix(np.array([[1.0, 2, 3], [0, 10, 5]])) + model.set_data(dist) + + self.assertEqual(model.rowCount(), 2) + self.assertEqual(model.columnCount(), 3) + index = model.index(0, 1) + self.assertEqual( + index.data(FixedFormatNumericColumnDelegate.ColumnDataSpanRole), + (0, 10)) + self.assertEqual(index.data(Qt.DisplayRole), 2) + self.assert_brush_value(index.data(Qt.BackgroundRole), 2 / 10 * 170) + + def test_header_data(self): + model = DistMatrixModel() + + dist = DistMatrix(np.array([[1.0, 2, 3], [0, 10, 5]])) + model.set_data(dist) + + self.assertIsNone(model.headerData(1, Qt.Horizontal, Qt.DisplayRole)) + self.assertIsNone(model.headerData(1, Qt.Horizontal, Qt.BackgroundRole)) + self.assertIsNone(model.headerData(1, Qt.Horizontal, Qt.ForegroundRole)) + self.assertIsNone(model.headerData(1, Qt.Vertical, Qt.DisplayRole)) + self.assertIsNone(model.headerData(1, Qt.Vertical, Qt.BackgroundRole)) + self.assertIsNone(model.headerData(1, Qt.Vertical, Qt.ForegroundRole)) + + model.set_labels(Qt.Horizontal, list("abc")) + self.assertEqual(model.headerData(1, Qt.Horizontal, Qt.DisplayRole), "b") + self.assertIsNone(model.headerData(1, Qt.Vertical, Qt.DisplayRole)) + # These shouldn't fail; what they return ... we don't care here + model.headerData(1, Qt.Horizontal, Qt.BackgroundRole) + model.headerData(1, Qt.Horizontal, Qt.ForegroundRole) + model.headerData(1, Qt.Vertical, Qt.BackgroundRole) + model.headerData(1, Qt.Vertical, Qt.ForegroundRole) + + model.set_labels(Qt.Vertical, list("de")) + self.assertEqual(model.headerData(1, Qt.Horizontal, Qt.DisplayRole), "b") + self.assertEqual(model.headerData(1, Qt.Vertical, Qt.DisplayRole), "e") + + model.set_labels(Qt.Horizontal, None) + self.assertIsNone(model.headerData(1, Qt.Horizontal, Qt.DisplayRole)) + self.assertEqual(model.headerData(1, Qt.Vertical, Qt.DisplayRole), "e") + + colors = np.array([QColor(1, 2, 3), QColor(4, 5, 6), QColor(7, 8, 9)]) + model.set_labels(Qt.Horizontal, list("abc"), colors) + + self.assert_brush_color( + model.headerData(1, Qt.Horizontal, Qt.BackgroundRole), + colors[1].lighter(150)) + self.assertIsNone(model.headerData(1, Qt.Vertical, Qt.BackgroundRole)) + + vcolors = np.array([QColor(12, 13, 14), QColor(9, 10, 11)]) + model.set_labels(Qt.Vertical, list("de"), vcolors) + self.assert_brush_color( + model.headerData(1, Qt.Horizontal, Qt.BackgroundRole), + colors[1].lighter(150)) + self.assert_brush_color( + model.headerData(1, Qt.Vertical, Qt.BackgroundRole), + vcolors[1].lighter(150)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/utils/tests/test_encodings.py b/Orange/widgets/utils/tests/test_encodings.py new file mode 100644 index 00000000000..8bd741617ca --- /dev/null +++ b/Orange/widgets/utils/tests/test_encodings.py @@ -0,0 +1,28 @@ +import os +from unittest import mock + +from AnyQt.QtCore import QSettings + +from Orange.widgets.tests.base import GuiTest +from Orange.widgets.utils.encodings import SelectEncodingsWidget + + +def mock_settings(): + return QSettings(os.devnull, QSettings.IniFormat) + + +class TestSelectEncodingsWidget(GuiTest): + + @mock.patch("Orange.widgets.utils.encodings.QSettings", mock_settings) + def test_widget(self): + w = SelectEncodingsWidget() + model = w.model() + w.reset() + enc = w.selectedEncodings() + self.assertLess(len(enc), model.rowCount()) + w.selectAll() + enc = w.selectedEncodings() + self.assertEqual(len(enc), model.rowCount()) + w.clearAll() + enc = w.selectedEncodings() + self.assertEqual(len(enc), 0) diff --git a/Orange/widgets/utils/tests/test_filedialogs.py b/Orange/widgets/utils/tests/test_filedialogs.py new file mode 100644 index 00000000000..1839e647091 --- /dev/null +++ b/Orange/widgets/utils/tests/test_filedialogs.py @@ -0,0 +1,28 @@ +from AnyQt.QtCore import QUrl, QMimeData + +from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.tests.utils import dragDrop +from Orange.widgets.utils.filedialogs import OWUrlDropBase + + +class TestOWUrlDropBase(WidgetTest): + def test_drop(self): + class TestW(OWUrlDropBase): + path = None + + def canDropUrl(self, url: QUrl) -> bool: + return url.toLocalFile().endswith(".foo") + + def handleDroppedUrl(self, url: QUrl) -> None: + self.path = url.toLocalFile() + + w = self.create_widget(TestW) + url = QUrl("file:///bar.foo") + mime = QMimeData() + mime.setUrls([url]) + self.assertTrue(dragDrop(w, mime)) + self.assertEqual(w.path, url.toLocalFile()) + url = QUrl("file:///bar.baz") + mime.setUrls([url]) + self.assertFalse(dragDrop(w, mime)) + self.assertNotEqual(w.path, url.toLocalFile()) diff --git a/Orange/widgets/utils/tests/test_graphicspixmapwidget.py b/Orange/widgets/utils/tests/test_graphicspixmapwidget.py new file mode 100644 index 00000000000..a2b5c2071a9 --- /dev/null +++ b/Orange/widgets/utils/tests/test_graphicspixmapwidget.py @@ -0,0 +1,38 @@ +from AnyQt.QtCore import Qt, QSize, QSizeF +from AnyQt.QtGui import QPixmap +from AnyQt.QtWidgets import QGraphicsScene, QGraphicsView + +from orangewidget.tests.base import GuiTest +from Orange.widgets.utils.graphicspixmapwidget import GraphicsPixmapWidget + + +class TestGraphicsPixmapWidget(GuiTest): + def setUp(self) -> None: + super().setUp() + self.scene = QGraphicsScene() + self.view = QGraphicsView(self.scene) + + def tearDown(self) -> None: + self.scene.clear() + self.scene.deleteLater() + self.view.deleteLater() + del self.scene + del self.view + super().tearDown() + + def test_graphicspixmapwidget(self): + w = GraphicsPixmapWidget() + self.scene.addItem(w) + w.setPixmap(QPixmap(100, 100)) + p = w.pixmap() + self.assertEqual(p.size(), QSize(100, 100)) + self.view.grab() + w.setScaleContents(True) + w.setAspectRatioMode(Qt.KeepAspectRatio) + s = w.sizeHint(Qt.PreferredSize) + self.assertEqual(s, QSizeF(100., 100.)) + s = w.sizeHint(Qt.PreferredSize, QSizeF(200., -1.)) + self.assertEqual(s, QSizeF(200., 200.)) + s = w.sizeHint(Qt.PreferredSize, QSizeF(-1., 200.)) + self.assertEqual(s, QSizeF(200., 200.)) + self.view.grab() diff --git a/Orange/widgets/utils/tests/test_graphicstextlist.py b/Orange/widgets/utils/tests/test_graphicstextlist.py index 903e410d111..44df4da43a6 100644 --- a/Orange/widgets/utils/tests/test_graphicstextlist.py +++ b/Orange/widgets/utils/tests/test_graphicstextlist.py @@ -1,8 +1,11 @@ import unittest -from AnyQt.QtCore import Qt, QSizeF +from AnyQt.QtCore import Qt, QSizeF, QPoint +from AnyQt.QtGui import QHelpEvent +from AnyQt.QtWidgets import QGraphicsView, QApplication, QToolTip from orangewidget.tests.base import GuiTest +from Orange.widgets.utils.graphicsscene import GraphicsScene from Orange.widgets.utils.graphicstextlist import TextListWidget, scaled @@ -57,6 +60,25 @@ def brect(item): w.setAlignment(Qt.AlignVCenter) self.assertTrue(45 <= brect(item).center().y() < 55) + def test_tool_tips(self): + scene = GraphicsScene() + view = QGraphicsView(scene) + w = TextListWidget() + text = "A" * 10 + w.setItems([text, text]) + scene.addItem(w) + view.grab() # ensure w is laid out + wrect = view.mapFromScene(w.mapToScene(w.contentsRect())).boundingRect() + p = QPoint(wrect.topLeft() + QPoint(5, 5)) + ev = QHelpEvent( + QHelpEvent.ToolTip, p, view.viewport().mapToGlobal(p) + ) + try: + QApplication.sendEvent(view.viewport(), ev) + self.assertEqual(QToolTip.text(), text) + finally: + QToolTip.hideText() + class TestUtils(unittest.TestCase): def test_scaled(self): diff --git a/Orange/widgets/utils/tests/test_headerview.py b/Orange/widgets/utils/tests/test_headerview.py index 0078b47fcb5..25b7a3af126 100644 --- a/Orange/widgets/utils/tests/test_headerview.py +++ b/Orange/widgets/utils/tests/test_headerview.py @@ -6,7 +6,7 @@ from Orange.widgets.tests.base import GuiTest -from Orange.widgets.utils.headerview import HeaderView +from Orange.widgets.utils.headerview import HeaderView, CheckableHeaderView from Orange.widgets.utils.textimport import StampIconEngine @@ -92,7 +92,7 @@ def test_header_view_clickable(self): pos = header.sectionViewportPosition(0) size = header.sectionSize(0) # center of first section - point = QPoint(pos + size // 2, header.viewport().height() / 2) + point = QPoint(pos + size // 2, header.viewport().height() // 2) QTest.mousePress(header.viewport(), Qt.LeftButton, Qt.NoModifier, point) opt = QStyleOptionHeader() @@ -103,3 +103,23 @@ def test_header_view_clickable(self): opt = QStyleOptionHeader() header.initStyleOptionForIndex(opt, 0) self.assertFalse(opt.state & QStyle.State_Sunken) + + +class TestCheckableHeaderView(GuiTest): + def test_view(self): + model = QStandardItemModel() + model.setColumnCount(1) + model.setRowCount(3) + view = CheckableHeaderView(Qt.Vertical) + view.setModel(model) + view.adjustSize() + model.setHeaderData(0, Qt.Vertical, Qt.Checked, Qt.CheckStateRole) + model.setHeaderData(1, Qt.Vertical, Qt.Unchecked, Qt.CheckStateRole) + view.grab() + style = view.style() + opt = view._CheckableHeaderView__viewItemOption(0) + hr = style.subElementRect(QStyle.SE_ItemViewItemCheckIndicator, opt, view) + QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=hr.center()) + self.assertEqual(model.headerData(0, Qt.Vertical, Qt.CheckStateRole), Qt.Unchecked) + QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=hr.center()) + self.assertEqual(model.headerData(0, Qt.Vertical, Qt.CheckStateRole), Qt.Checked) diff --git a/Orange/widgets/utils/tests/test_itemmodels.py b/Orange/widgets/utils/tests/test_itemmodels.py index d0a67b6176a..e278c10eca9 100644 --- a/Orange/widgets/utils/tests/test_itemmodels.py +++ b/Orange/widgets/utils/tests/test_itemmodels.py @@ -1,24 +1,29 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring - import unittest from unittest.mock import patch -import numpy as np +from scipy.sparse import csr_matrix from AnyQt.QtCore import Qt, QModelIndex from AnyQt.QtTest import QSignalSpy +from AnyQt.QtGui import QBrush, QColor + +from orangewidget.tests.base import GuiTest from Orange.data import \ - Domain, \ + Domain, Table, Value, \ ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable +from Orange.statistics.basic_stats import BasicStats from Orange.widgets.utils import colorpalettes from Orange.widgets.utils.itemmodels import \ - AbstractSortTableModel, PyTableModel,\ - PyListModel, VariableListModel, DomainModel, ContinuousPalettesModel, \ - _as_contiguous_range + PyTableModel, PyListModel, PyListModelTooltip,\ + VariableListModel, DomainModel, ContinuousPalettesModel, \ + TableModel, _as_contiguous_range from Orange.widgets.gui import TableVariable -from orangewidget.tests.base import GuiTest +from Orange.tests import test_filename +from Orange.tests.sql.base import DataBaseTest as dbt +from Orange.data.sql.table import SqlTable class TestUtils(unittest.TestCase): @@ -64,8 +69,8 @@ def test_data(self): def test_editable(self): editable_model = PyTableModel([[0]], editable=True) - self.assertFalse(int(self.model.flags(self.model.index(0, 0)) & Qt.ItemIsEditable)) - self.assertTrue(int(editable_model.flags(editable_model.index(0, 0)) & Qt.ItemIsEditable)) + self.assertFalse(bool(self.model.flags(self.model.index(0, 0)) & Qt.ItemIsEditable)) + self.assertTrue(bool(editable_model.flags(editable_model.index(0, 0)) & Qt.ItemIsEditable)) def test_sort(self): self.model.sort(1) @@ -158,11 +163,11 @@ def test_emits_column_changes_on_row_insert(self): model.append([2, 3]) self.assertEqual(list(inserted)[-1][1:], [1, 1]) del model[:] - self.assertEqual(list(removed)[0][1:], [0, 1]) + self.assertEqual(list(removed)[-1][1:], [0, 1]) model.extend([[0, 1], [0, 2]]) self.assertEqual(list(inserted)[-1][1:], [0, 1]) model.clear() - self.assertEqual(list(removed)[0][1:], [0, 1]) + self.assertEqual(list(removed)[-1][1:], [0, 1]) model[:] = [[1], [2]] self.assertEqual(list(inserted)[-1][1:], [0, 0]) @@ -481,5 +486,149 @@ def testIndexOf(self): self.assertIsNone(model.indexOf(42)) +class TestPyListModelTooltip(GuiTest): + def test_tooltips_size(self): + def data(i): + return model.data(model.index(i, 0)) + + def tip(i): + return model.data(model.index(i, 0), Qt.ToolTipRole) + + # Not enough tooptips - return None + model = PyListModelTooltip(["foo", "bar", "baz"], ["footip", "bartip"]) + self.assertEqual(data(1), "bar") + self.assertEqual(data(2), "baz") + self.assertIsNone(data(3)) + self.assertEqual(tip(1), "bartip") + self.assertIsNone(tip(2)) + + # No tooltips + model = PyListModelTooltip(["foo", "bar", "baz"]) + self.assertIsNone(tip(1)) + self.assertIsNone(tip(2)) + + # Too many tooltips + model = PyListModelTooltip(["foo", "bar"], ["footip", "bartip", "btip"]) + self.assertEqual(data(0), "foo") + self.assertEqual(data(1), "bar") + self.assertIsNone(data(2)) + self.assertEqual(tip(1), "bartip") + self.assertEqual(tip(2), "btip") + + def test_tooltip_arg(self): + def tip(i): + return model.data(model.index(i, 0), Qt.ToolTipRole) + + # Allow generators + s = dict(a="ta", b="tb") + model = PyListModelTooltip(s, s.values()) + self.assertEqual(tip(0), "ta") + self.assertEqual(tip(1), "tb") + + # Basically backward compatibility; this behaviour diverges from + # behaviour of data role + s = [] + model = PyListModelTooltip(["foo"], s) + self.assertIsNone(tip(0)) + + s += ["footip"] + self.assertEqual(tip(1), "footip") + + +class TestTableModel(unittest.TestCase, dbt): + def setUpDB(self): + # pylint: disable=attribute-defined-outside-init + self.conn, self.iris = self.create_iris_sql_table() + + def tearDownDB(self): + self.drop_iris_sql_table() + + @dbt.run_on(["postgres", "mssql"]) + def test_dense_data(self): + table = SqlTable(self.conn, self.iris, inspect_values=True) + if self.current_db == "mssql": + # when loading data from mssql db, Sql widget returns Table (not SqlTable) + table = Table(table) + new_domain = Domain(table.domain.attributes[:-1], table.domain.attributes[-1]) + table = table.transform(new_domain) + model = TableModel(table) + + self._dense_data(table, model.data, model.index) + + def test_local_dense_data(self): + table = Table("iris.tab") + model = TableModel(table) + + self._dense_data(table, model.data, model.index) + + def _dense_data(self, table, data, index): + # Y: categorical + self.assertEqual(table[0, 4], data(index(0, 0), Qt.DisplayRole)) + self.assertIsInstance(data(index(0, 0), Qt.DisplayRole), str) + self.assertEqual(table[0, 4], data(index(0, 0), Qt.EditRole)) + self.assertIsInstance(data(index(0, 0), Qt.EditRole), Value) + self.assertIsInstance(data(index(0, 0), Qt.BackgroundRole), QBrush) + self.assertIsInstance(data(index(0, 0), Qt.ForegroundRole), QColor) + self.assertEqual(table[0, 4], data(index(0, 0), TableModel.ValueRole)) + self.assertEqual(table[0, 4], data(index(0, 0), TableModel.ClassValueRole)) + self.assertEqual(table.domain[4], data(index(0, 0), TableModel.VariableRole)) + self.assertIsInstance(data(index(0, 0), TableModel.VariableStatsRole), BasicStats) + + # X: continuous + self.assertEqual(table[0, 0], data(index(0, 1), Qt.DisplayRole)) + self.assertIsInstance(data(index(0, 1), Qt.DisplayRole), str) + self.assertEqual(table[0, 0], data(index(0, 1), Qt.EditRole)) + self.assertIsInstance(data(index(0, 1), Qt.EditRole), Value) + self.assertIsNone(data(index(0, 1), Qt.BackgroundRole)) + self.assertIsNone(data(index(0, 1), Qt.ForegroundRole)) + self.assertEqual(table[0, 0], data(index(0, 1), TableModel.ValueRole)) + self.assertEqual(table[0, 4], data(index(0, 1), TableModel.ClassValueRole)) + self.assertEqual(table.domain[0], data(index(0, 1), TableModel.VariableRole)) + self.assertIsInstance(data(index(0, 1), TableModel.VariableStatsRole), BasicStats) + + def test_sparse_data(self): + table = Table(test_filename("datasets/iris_basket.basket")) + model = TableModel(table) + data, index = model.data, model.index + + # Y: 2d + self.assertListEqual([table[0, 4], table[0, 5], table[0, 6]], + [data(index(0, i), Qt.DisplayRole) for i in range(3)]) + self.assertIsInstance(data(index(0, 0), Qt.DisplayRole), str) + self.assertEqual(table[0, 4], data(index(0, 0), Qt.EditRole)) + self.assertIsInstance(data(index(0, 0), Qt.EditRole), Value) + self.assertIsInstance(data(index(0, 0), Qt.BackgroundRole), QBrush) + self.assertIsInstance(data(index(0, 0), Qt.ForegroundRole), QColor) + self.assertEqual(table[0, 4], data(index(0, 0), TableModel.ValueRole)) + self.assertIsNone(data(index(0, 0), TableModel.ClassValueRole)) + self.assertEqual(table.domain[4], data(index(0, 0), TableModel.VariableRole)) + self.assertIsInstance(data(index(0, 0), TableModel.VariableStatsRole), BasicStats) + + # X: sparse + self.assertEqual("sepal_length=1.5, sepal_width=5.3, petal_length=4.1, petal_width=2", + data(index(0, 3), Qt.DisplayRole)) + self.assertIsNone(data(index(0, 3), Qt.EditRole)) + self.assertIsNone(data(index(0, 3), Qt.BackgroundRole)) + self.assertIsNone(data(index(0, 3), Qt.ForegroundRole)) + self.assertIsNone(data(index(0, 3), TableModel.ValueRole)) + self.assertIsNone(data(index(0, 3), TableModel.ClassValueRole)) + self.assertIsNone(data(index(0, 3), TableModel.VariableRole)) + self.assertIsNone(data(index(0, 3), TableModel.VariableStatsRole)) + + # X: sparse_bool + table = Table.from_numpy(Domain(table.domain.class_vars, metas=table.domain.attributes), + csr_matrix(table.Y), metas=table.X) + model = TableModel(table) + data, index = model.data, model.index + + self.assertEqual("Iris-setosa", data(index(0, 1), Qt.DisplayRole)) + + # metas: sparse + self.assertEqual("sepal_length=1.5, sepal_width=5.3, petal_length=4.1, petal_width=2", + data(index(0, 0), Qt.DisplayRole)) + self.assertIsInstance(data(index(0, 0), Qt.BackgroundRole), QBrush) + self.assertIsInstance(data(index(0, 0), Qt.ForegroundRole), QColor) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/utils/tests/test_multi_target.py b/Orange/widgets/utils/tests/test_multi_target.py new file mode 100644 index 00000000000..54b5308591a --- /dev/null +++ b/Orange/widgets/utils/tests/test_multi_target.py @@ -0,0 +1,53 @@ +import unittest + +from Orange.data import Table, Domain, DiscreteVariable +from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.utils.signals import Input +from Orange.widgets.utils.multi_target import check_multiple_targets_input +from Orange.widgets.widget import OWWidget + + +class TestMultiTargetDecorator(WidgetTest): + class MockWidget(OWWidget): + name = "MockWidget" + keywords = "mockwidget" + + NotCalled = object() + + class Inputs: + data = Input("Data", Table) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.called_with = self.NotCalled + + @Inputs.data + @check_multiple_targets_input + def set_data(self, obj): + self.called_with = obj + + def pop_called_with(self): + t = self.called_with + self.called_with = self.NotCalled + return t + + def setUp(self): + self.widget = self.create_widget(self.MockWidget) + self.data = Table("iris") + + def test_check_multiple_targets_input(self): + class_vars = [self.data.domain.class_var, + DiscreteVariable("c1", values=("a", "b"))] + domain = Domain(self.data.domain.attributes, class_vars=class_vars) + multiple_targets_data = self.data.transform(domain) + self.send_signal(self.widget.Inputs.data, multiple_targets_data) + self.assertTrue(self.widget.Error.multiple_targets_data.is_shown()) + self.assertIs(self.widget.pop_called_with(), None) + + self.send_signal(self.widget.Inputs.data, self.data) + self.assertFalse(self.widget.Error.multiple_targets_data.is_shown()) + self.assertIs(self.widget.pop_called_with(), self.data) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/utils/tests/test_owbasesql.py b/Orange/widgets/utils/tests/test_owbasesql.py index 5840854c7b3..69b627b0a1b 100644 --- a/Orange/widgets/utils/tests/test_owbasesql.py +++ b/Orange/widgets/utils/tests/test_owbasesql.py @@ -23,6 +23,7 @@ def __init__(self, connection_params): class TestableSqlWidget(OWBaseSql): name = "SQL" + keywords = "mockwidget" def __init__(self): self.mocked_backend = Mock() diff --git a/Orange/widgets/utils/tests/test_owlearnerwidget.py b/Orange/widgets/utils/tests/test_owlearnerwidget.py index e027d4a7acd..5636cd729d2 100644 --- a/Orange/widgets/utils/tests/test_owlearnerwidget.py +++ b/Orange/widgets/utils/tests/test_owlearnerwidget.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock +from unittest.mock import Mock, patch import scipy.sparse as sp @@ -39,9 +39,9 @@ class OWFailingLearner(OWBaseLearner): auto_apply = True self.widget = self.create_widget(OWFailingLearner) - self.send_signal("Data", self.iris) + self.send_signal(self.widget.Inputs.data, self.iris) self.assertTrue(self.widget.Error.fitting_failed.is_shown()) - self.send_signal("Data", None) + self.send_signal(self.widget.Inputs.data, None) self.assertFalse(self.widget.Error.fitting_failed.is_shown()) def test_subclasses_do_not_share_outputs(self): @@ -68,6 +68,7 @@ class Outputs(WidgetA.Outputs): self.assertEqual(WidgetA.Outputs.learner.type, KNNLearner) self.assertFalse(hasattr(WidgetA.Outputs, "test")) + @WidgetTest.skipNonEnglish def test_send_backward_compatibility(self): class WidgetA(OWBaseLearner): name = "A" @@ -125,8 +126,9 @@ class WidgetLR(OWBaseLearner): multinomial_treatment=continuize.Continuize.AsOrdinal, transform_class=True, ) - data = self.iris.transform(pp(self.iris)) - data.Y = sp.csr_matrix(data.Y) + data = self.iris.transform(pp(self.iris)).copy() + with data.unlocked(): + data.Y = sp.csr_matrix(data.Y) self.send_signal(w.Inputs.data, data, widget=w) self.assertFalse(any(w.Error.active)) @@ -182,3 +184,108 @@ class WidgetLR(OWBaseLearner): self.send_signal(w.Inputs.data, None) self.assertFalse(error.is_shown()) + + def test_default_name(self): + class TestLearner(Fitter): + name = "Test" + __returns__ = Mock() + + class TestWidget(OWBaseLearner): + name = "Test" + LEARNER = TestLearner + + def check_name(name): + self.assertEqual(name, w.effective_learner_name()) + self.assertEqual(name, self.get_output(w.Outputs.learner, widget=w).name) + + w = self.create_widget(TestWidget) + + check_name("Test") + w.setCaption("Foo") + check_name("Foo") + w.set_default_learner_name("Bar") + check_name("Bar") + w.setCaption("Frob") + check_name("Bar") + w.learner_name = "This is not a test" + w.learner_name_changed() + check_name("This is not a test") + w.set_default_learner_name("Bar") + check_name("This is not a test") + w.setCaption("Blarg") + check_name("This is not a test") + w.learner_name = "" + w.learner_name_changed() + check_name("Bar") + w.set_default_learner_name("") + check_name("Blarg") + + def test_preprocessor_warning(self): + class TestLearnerNoPreprocess(Learner): + name = "Test" + __returns__ = Mock() + + class TestWidgetNoPreprocess(OWBaseLearner): + name = "Test" + LEARNER = TestLearnerNoPreprocess + + class TestLearnerPreprocess(Learner): + name = "Test" + preprocessors = [Mock()] + __returns__ = Mock() + + class TestWidgetPreprocess(OWBaseLearner): + name = "Test" + LEARNER = TestLearnerPreprocess + + class TestFitterPreprocess(Fitter): + name = "Test" + preprocessors = [Mock()] + __returns__ = Mock() + + class TestWidgetPreprocessFit(OWBaseLearner): + name = "Test" + LEARNER = TestFitterPreprocess + + wno = self.create_widget(TestWidgetNoPreprocess) + wyes = self.create_widget(TestWidgetPreprocess) + wfit = self.create_widget(TestWidgetPreprocessFit) + + self.assertFalse(wno.Information.ignored_preprocessors.is_shown()) + self.assertFalse(wyes.Information.ignored_preprocessors.is_shown()) + self.assertFalse(wfit.Information.ignored_preprocessors.is_shown()) + + pp = continuize.Continuize() + self.send_signal(wno.Inputs.preprocessor, pp) + self.send_signal(wyes.Inputs.preprocessor, pp) + self.send_signal(wfit.Inputs.preprocessor, pp) + + self.assertFalse(wno.Information.ignored_preprocessors.is_shown()) + self.assertTrue(wyes.Information.ignored_preprocessors.is_shown()) + self.assertFalse(wfit.Information.ignored_preprocessors.is_shown()) + + self.send_signal(wno.Inputs.preprocessor, None) + self.send_signal(wyes.Inputs.preprocessor, None) + self.send_signal(wfit.Inputs.preprocessor, None) + + self.assertFalse(wno.Information.ignored_preprocessors.is_shown()) + self.assertFalse(wyes.Information.ignored_preprocessors.is_shown()) + self.assertFalse(wfit.Information.ignored_preprocessors.is_shown()) + + def test_multiple_sends(self): + class TestLearner(Learner): + name = "Test" + __returns__ = Mock() + + class TestWidget(OWBaseLearner): + name = "Test" + LEARNER = TestLearner + + widget = self.create_widget(TestWidget) + pp = continuize.Continuize() + with patch.object(widget.Outputs.learner, "send") as model_send, \ + patch.object(widget.Outputs.model, "send") as learner_send: + self.send_signals([(widget.Inputs.data, self.iris), + (widget.Inputs.preprocessor, pp)]) + learner_send.assert_called_once() + model_send.assert_called_once() diff --git a/Orange/widgets/utils/tests/test_signals.py b/Orange/widgets/utils/tests/test_signals.py new file mode 100644 index 00000000000..5bf6e43a577 --- /dev/null +++ b/Orange/widgets/utils/tests/test_signals.py @@ -0,0 +1,27 @@ +import unittest +from unittest.mock import Mock + +from Orange.widgets.utils.signals import lazy_table_transform + + +class TestSignals(unittest.TestCase): + def test_lazy_table_transform(self): + data = Mock() + data.__len__ = lambda _: 42 + data.transform = Mock() + + domain = Mock() + + lazy_trans = lazy_table_transform(domain, data) + + data.transform.assert_not_called() + self.assertEqual(lazy_trans.length, 42) + self.assertIs(lazy_trans.domain, domain) + self.assertFalse(lazy_trans.is_cached) + + self.assertIs(lazy_trans.get_value(), data.transform.return_value) + data.transform.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/Orange/widgets/utils/tests/test_slidergraph.py b/Orange/widgets/utils/tests/test_slidergraph.py index 7eeafb2c982..f21c5b9af8a 100644 --- a/Orange/widgets/utils/tests/test_slidergraph.py +++ b/Orange/widgets/utils/tests/test_slidergraph.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import numpy as np from AnyQt.QtCore import Qt @@ -8,6 +10,7 @@ class SimpleWidget(widget.OWWidget): name = "Simple widget" + keywords = "simplewidget" def __init__(self): super().__init__() @@ -105,3 +108,25 @@ def test_plot_no_cutpoint(self): p.update(x, self.data, [Qt.red]) # pylint: disable=protected-access self.assertIsNone(p._line) + + def test_hover_event(self): + p = self.widget.plot + x = np.arange(len(self.data[0])) + p.update(x, self.data, [Qt.red], cutpoint_x=1) + + # pylint: disable=protected-access + self.assertIsNotNone(p._line) + + enter_event = Mock() + enter_event.isEnter.return_value = True + enter_event.isExit.return_value = False + + p._line.hoverEvent(enter_event) + self.assertEqual(p._line.pen, p._line._highlight_pen) + + exit_event = Mock() + exit_event.isEnter.return_value = False + exit_event.isExit.return_value = True + + p._line.hoverEvent(exit_event) + self.assertEqual(p._line.pen, p._line._normal_pen) diff --git a/Orange/widgets/utils/tests/test_sql.py b/Orange/widgets/utils/tests/test_sql.py index 7f04ee626a1..0fa538cac85 100644 --- a/Orange/widgets/utils/tests/test_sql.py +++ b/Orange/widgets/utils/tests/test_sql.py @@ -1,22 +1,25 @@ import unittest from unittest.mock import patch, MagicMock +from orangewidget.utils.signals import MultiInput from Orange.data import Table, Domain from Orange.data.sql.table import AUTO_DL_LIMIT, SqlTable from Orange.widgets.tests.base import WidgetTest from Orange.widgets.utils.signals import Input -from Orange.widgets.utils.sql import check_sql_input +from Orange.widgets.utils.sql import check_sql_input, check_sql_input_sequence from Orange.widgets.widget import OWWidget class TestSQLDecorator(WidgetTest): class MockWidget(OWWidget): name = "MockWidget" + keywords = "mockwidget" NotCalled = object() class Inputs: data = Input("Data", Table) + additional_data = MultiInput("Additional Data", Table) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -27,6 +30,20 @@ def __init__(self, *args, **kwargs): def set_data(self, obj): self.called_with = obj + @Inputs.additional_data + @check_sql_input_sequence + def set_additional_data(self, index, obj): + self.called_with = index, obj + + @Inputs.additional_data.insert + @check_sql_input_sequence + def insert_more_data(self, *_): + pass + + @Inputs.additional_data.remove + def remove_more_data(self, *_): + pass + def pop_called_with(self): t = self.called_with self.called_with = self.NotCalled @@ -48,22 +65,62 @@ def test_inputs_check_sql(self): d = SqlTable(None, None, MagicMock()) d.domain = Domain([]) - d.approx_len = MagicMock(return_value=AUTO_DL_LIMIT - 1) - self.send_signal(self.widget.Inputs.data, d) - table_mock.assert_called_once_with(d) - self.assertIs(self.widget.pop_called_with(), a_table) - table_mock.reset_mock() + with patch.object(SqlTable, "__len__", + return_value=AUTO_DL_LIMIT - 1): + self.send_signal(self.widget.Inputs.data, d) + table_mock.assert_called_once_with(d) + self.assertIs(self.widget.pop_called_with(), a_table) + table_mock.reset_mock() + + with patch.object(SqlTable, "__len__", + return_value=AUTO_DL_LIMIT + 1): + self.send_signal(self.widget.Inputs.data, d) + table_mock.assert_not_called() + self.assertIs(self.widget.pop_called_with(), None) + self.assertTrue(self.widget.Error.download_sql_data.is_shown()) + table_mock.reset_mock() - d.approx_len = MagicMock(return_value=AUTO_DL_LIMIT + 1) - self.send_signal(self.widget.Inputs.data, d) + self.send_signal(self.widget.Inputs.data, None) table_mock.assert_not_called() self.assertIs(self.widget.pop_called_with(), None) - self.assertTrue(self.widget.Error.download_sql_data.is_shown()) - table_mock.reset_mock() + self.assertFalse(self.widget.Error.download_sql_data.is_shown()) - self.send_signal(self.widget.Inputs.data, None) + def test_check_sql_input_sequence(self): + """Test if check_sql_input_sequence is called when data is sent to a widget.""" + d = Table() + self.send_signal(self.widget.Inputs.additional_data, d) + + a_table = object() + with patch("Orange.widgets.utils.sql.Table", + MagicMock(return_value=a_table)) as table_mock, \ + patch("Orange.widgets.utils.state_summary.format_summary_details"): + d = SqlTable(None, None, MagicMock()) + d.domain = Domain([]) + + with patch.object(SqlTable, "__len__", + return_value=AUTO_DL_LIMIT - 1): + self.send_signal(self.widget.Inputs.additional_data, d) + table_mock.assert_called_once_with(d) + index, obj = self.widget.pop_called_with() + self.assertIs(index, 0) + self.assertIs(obj, a_table) + table_mock.reset_mock() + + with patch.object(SqlTable, "__len__", + return_value=AUTO_DL_LIMIT + 1): + self.send_signal(self.widget.Inputs.additional_data, d) + table_mock.assert_not_called() + index, obj = self.widget.pop_called_with() + self.assertIs(index, 0) + self.assertIs(obj, None) + self.assertTrue(self.widget.Error.download_sql_data.is_shown()) + table_mock.reset_mock() + + self.send_signal(self.widget.Inputs.additional_data, None) table_mock.assert_not_called() - self.assertIs(self.widget.pop_called_with(), None) + index, obj = self.widget.pop_called_with() + self.assertIs(index, 0) + self.assertIs(obj, None) self.assertFalse(self.widget.Error.download_sql_data.is_shown()) diff --git a/Orange/widgets/utils/tests/test_state_summary.py b/Orange/widgets/utils/tests/test_state_summary.py index 50abe2b97c8..f49eec56983 100644 --- a/Orange/widgets/utils/tests/test_state_summary.py +++ b/Orange/widgets/utils/tests/test_state_summary.py @@ -1,13 +1,22 @@ import unittest +from unittest.mock import patch, Mock import datetime from collections import namedtuple import numpy as np +from AnyQt.QtCore import Qt +from AnyQt.QtWidgets import QTableView + +from orangecanvas.scheme.signalmanager import LazyValue +from orangewidget.utils.signals import summarize + from Orange.data import Table, Domain, StringVariable, ContinuousVariable, \ DiscreteVariable, TimeVariable +from Orange.misc import DistMatrix +from Orange.widgets.tests.base import WidgetTest from Orange.widgets.utils.state_summary import format_summary_details, \ - format_multiple_summaries + format_multiple_summaries, summarize_matrix VarDataPair = namedtuple('VarDataPair', ['variable', 'data']) @@ -101,11 +110,12 @@ def make_table(attributes, target=None, metas=None): class TestUtils(unittest.TestCase): + @WidgetTest.skipNonEnglish def test_details(self): """Check if details part of the summary is formatted correctly""" data = Table('zoo') n_features = len(data.domain.variables) + len(data.domain.metas) - details = f'zoo: {len(data)} instance, ' \ + details = f'zoo: {len(data)} instances, ' \ f'{n_features} variables\n' \ f'Features: {len(data.domain.attributes)} categorical ' \ f'(no missing values)\n' \ @@ -113,6 +123,12 @@ def test_details(self): f'Metas: string' self.assertEqual(details, format_summary_details(data)) + details = f'Table with {n_features} variables\n' \ + f'Features: {len(data.domain.attributes)} categorical\n' \ + f'Target: categorical\n' \ + f'Metas: string' + self.assertEqual(details, format_summary_details(data.domain)) + data = Table('housing') n_features = len(data.domain.variables) + len(data.domain.metas) details = f'housing: {len(data)} instances, ' \ @@ -136,7 +152,7 @@ def test_details(self): target=[rgb_full, rgb_missing], metas=[ints_full, ints_missing] ) n_features = len(data.domain.variables) + len(data.domain.metas) - details = f'{len(data)} instances, ' \ + details = f'Table with {len(data)} instances, ' \ f'{n_features} variables\n' \ f'Features: {len(data.domain.attributes)} numeric ' \ f'(10.0% missing values)\n' \ @@ -150,7 +166,7 @@ def test_details(self): metas=[string_full, string_missing] ) n_features = len(data.domain.variables) + len(data.domain.metas) - details = f'{len(data)} instances, ' \ + details = f'Table with {len(data)} instances, ' \ f'{n_features} variables\n' \ f'Features: {len(data.domain.attributes)} ' \ f'(2 categorical, 1 numeric, 1 time) (5.0% missing values)\n' \ @@ -161,7 +177,7 @@ def test_details(self): data = make_table([time_full, time_missing], target=[ints_missing], metas=None) - details = f'{len(data)} instances, ' \ + details = f'Table with {len(data)} instances, ' \ f'{len(data.domain.variables)} variables\n' \ f'Features: {len(data.domain.attributes)} time ' \ f'(10.0% missing values)\n' \ @@ -169,7 +185,7 @@ def test_details(self): self.assertEqual(details, format_summary_details(data)) data = make_table([rgb_full, ints_full], target=None, metas=None) - details = f'{len(data)} instances, ' \ + details = f'Table with {len(data)} instances, ' \ f'{len(data.domain.variables)} variables\n' \ f'Features: {len(data.domain.variables)} categorical ' \ f'(no missing values)\n' \ @@ -177,22 +193,32 @@ def test_details(self): self.assertEqual(details, format_summary_details(data)) data = make_table([rgb_full], target=None, metas=None) - details = f'{len(data)} instances, ' \ + details = f'Table with {len(data)} instances, ' \ f'{len(data.domain.variables)} variable\n' \ f'Features: categorical (no missing values)\n' \ f'Target: —' self.assertEqual(details, format_summary_details(data)) + data = Table.from_numpy(domain=None, X=np.random.random((10000, 1000))) + details = f'Table with {len(data):n} instances, ' \ + f'{len(data.domain.variables)} variables\n' \ + f'Features: {len(data.domain.variables)} numeric\n' \ + f'Target: —' + with patch.object(Table, "get_nan_frequency_attribute") as mock: + self.assertEqual(details, format_summary_details(data)) + mock.assert_not_called() + data = None self.assertEqual('', format_summary_details(data)) + @WidgetTest.skipNonEnglish def test_multiple_summaries(self): data = Table('zoo') extra_data = Table('zoo')[20:] n_features_data = len(data.domain.variables) + len(data.domain.metas) n_features_extra_data = len(extra_data.domain.variables) + \ len(extra_data.domain.metas) - details = f'Data:
      zoo: {len(data)} instance, ' \ + details = f'Data:
      zoo: {len(data)} instances, ' \ f'{n_features_data} variables
      ' \ f'Features: {len(data.domain.attributes)} categorical ' \ f'(no missing values)
      ' \ @@ -207,7 +233,7 @@ def test_multiple_summaries(self): inputs = [('Data', data), ('Extra Data', extra_data)] self.assertEqual(details, format_multiple_summaries(inputs)) - details = f'zoo: {len(data)} instance, ' \ + details = f'zoo: {len(data)} instances, ' \ f'{n_features_data} variables
      ' \ f'Features: {len(data.domain.attributes)} categorical ' \ f'(no missing values)
      ' \ @@ -235,5 +261,79 @@ def test_multiple_summaries(self): format_multiple_summaries(outputs, type_io='output')) +class TestSummarize(unittest.TestCase): + @patch("Orange.widgets.utils.state_summary._table_previewer") + def test_summarize_table(self, previewer): + data = Table('zoo') + summary = summarize(data) + self.assertEqual(summary.summary, len(data)) + self.assertEqual(summary.details, + format_summary_details(data, format=Qt.RichText)) + previewer.assert_not_called() + summary.preview_func() + previewer.assert_called_with(data) + + @patch("Orange.widgets.utils.state_summary._table_previewer") + def test_summarize_lazy_table(self, previewer): + data = Table('zoo') + + # lazy_data of unknown length and domain + lazy_data = LazyValue[Table](lambda: data) + lazy_data.get_value = Mock(return_value=data) + summary = summarize(lazy_data) + self.assertEqual(summary.summary, "?") + self.assertIsInstance(summary.details, str) + lazy_data.get_value.assert_not_called() + previewer.assert_not_called() + summary.preview_func() + lazy_data.get_value.assert_called() + previewer.assert_called_with(data) + previewer.reset_mock() + + # lazy_data with length and domain hint + lazy_data = LazyValue[Table]( + lambda: data, length=123, domain=data.domain) + lazy_data.get_value = Mock(return_value=data) + summary = summarize(lazy_data) + self.assertEqual(summary.summary, 123) + self.assertEqual(summary.details, + format_summary_details(data.domain, format=Qt.RichText)) + lazy_data.get_value.assert_not_called() + previewer.assert_not_called() + summary.preview_func() + lazy_data.get_value.assert_called() + previewer.assert_called_with(data) + previewer.reset_mock() + + # lazy_data that is already cached: complete summary even without hints + lazy_data = LazyValue[Table](lambda: data) + lazy_data.get_value() + summary = summarize(lazy_data) + self.assertEqual(summary.summary, len(data)) + self.assertEqual(summary.details, + format_summary_details(data, format=Qt.RichText)) + previewer.assert_not_called() + summary.preview_func() + previewer.assert_called_with(data) + + +class TestSummarizeMatrix(WidgetTest): + def test_summarize_matrix(self): + matrix = DistMatrix(np.arange(9).reshape(3, 3)) + summary = summarize_matrix(matrix) + self.assertEqual(summary.summary, "3×3") + self.assertEqual(summary.details, "3×3 distance matrix") + view = summary.preview_func() + self.assertIsInstance(view, QTableView) + + def test_summarize_matrix_empty(self): + matrix = DistMatrix(np.empty((0, 0))) + summary = summarize_matrix(matrix) + self.assertEqual(summary.summary, "0×0") + self.assertEqual(summary.details, "0×0 distance matrix") + view = summary.preview_func() + self.assertIsInstance(view, QTableView) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/utils/tests/test_stickygraphicsview.py b/Orange/widgets/utils/tests/test_stickygraphicsview.py index 9d5f1b468ea..db2cbc80e6b 100644 --- a/Orange/widgets/utils/tests/test_stickygraphicsview.py +++ b/Orange/widgets/utils/tests/test_stickygraphicsview.py @@ -121,16 +121,8 @@ def qWheelScroll( if pos.isNull(): pos = widget.rect().center() globalPos = widget.mapToGlobal(pos) - - if angleDelta.y() >= angleDelta.x(): - qt4orient = Qt.Vertical - qt4delta = angleDelta.y() - else: - qt4orient = Qt.Horizontal - qt4delta = angleDelta.x() - event = QWheelEvent( QPointF(pos), QPointF(globalPos), QPoint(), angleDelta, - qt4delta, qt4orient, buttons, modifiers + buttons, modifiers, Qt.NoScrollPhase, False ) QApplication.sendEvent(widget, event) diff --git a/Orange/widgets/utils/tests/test_textimport.py b/Orange/widgets/utils/tests/test_textimport.py index d48b2197705..324594d0629 100644 --- a/Orange/widgets/utils/tests/test_textimport.py +++ b/Orange/widgets/utils/tests/test_textimport.py @@ -1,12 +1,14 @@ import unittest import csv import io +from AnyQt.QtCore import Qt from AnyQt.QtWidgets import QComboBox, QWidget -from AnyQt.QtTest import QSignalSpy +from AnyQt.QtTest import QSignalSpy, QTest from Orange.widgets.utils import textimport from Orange.widgets.tests.base import GuiTest +from Orange.widgets.utils.textimport import TablePreview, TablePreviewModel ColumnTypes = textimport.ColumnType @@ -19,7 +21,7 @@ DATA5 = b'a\tb\n' * 1000 -class WidgetsTests(GuiTest): +class OptionsWidgetTests(GuiTest): def test_options_widget(self): w = textimport.CSVOptionsWidget() schanged = QSignalSpy(w.optionsChanged) @@ -52,6 +54,8 @@ def test_options_widget(self): self.assertEqual(d.delimiter, d1.delimiter) self.assertEqual(d.quotechar, d1.quotechar) + +class ImportWidgetTest(GuiTest): def test_import_widget(self): w = textimport.CSVImportWidget() w.setDialect(csv.excel()) @@ -101,6 +105,21 @@ def test_import_widget(self): self.assertGreater(model.rowCount(), rows) self.assertEqual(len(spy), 1) + def test_preview_view(self): + w = TablePreview() + model = TablePreviewModel() + model.setPreviewStream(csv.reader(io.StringIO(DATA4.decode('utf-8')))) + w.setModel(model) + QTest.mouseClick(w.verticalHeader().viewport(), Qt.LeftButton) + self.assertEqual(w.selectionBehavior(), TablePreview.SelectRows) + QTest.mouseClick(w.horizontalHeader().viewport(), Qt.LeftButton) + self.assertEqual(w.selectionBehavior(), TablePreview.SelectColumns) + + QTest.mouseClick(w.verticalHeader().viewport(), Qt.LeftButton) + self.assertEqual(w.selectionBehavior(), TablePreview.SelectRows) + QTest.mouseClick(w.viewport(), Qt.LeftButton) + self.assertEqual(w.selectionBehavior(), TablePreview.SelectColumns) + if __name__ == "__main__": unittest.main(__name__) diff --git a/Orange/widgets/utils/tests/test_userinput.py b/Orange/widgets/utils/tests/test_userinput.py new file mode 100644 index 00000000000..314f7a383f7 --- /dev/null +++ b/Orange/widgets/utils/tests/test_userinput.py @@ -0,0 +1,188 @@ +import unittest +from unittest.mock import patch +from Orange.widgets.utils.userinput import ( + _get_points, numbers_from_list) + + +class TestPointsFromList(unittest.TestCase): + @patch("Orange.widgets.utils.userinput._numbers_from_no_dots") + @patch("Orange.widgets.utils.userinput._numbers_from_dots") + def test_points_from_list(self, from_dots, no_dots): + numbers_from_list("1 2 3", int) + no_dots.assert_called() + no_dots.reset_mock() + from_dots.assert_not_called() + + numbers_from_list("5, 9", int) + no_dots.assert_called() + no_dots.reset_mock() + from_dots.assert_not_called() + + numbers_from_list("5.13", int) + no_dots.assert_called() + no_dots.reset_mock() + from_dots.assert_not_called() + + numbers_from_list("1 ... 3", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + numbers_from_list("1, 2, 3...5", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + numbers_from_list("...3, 4, 5", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + numbers_from_list("3, 4, 5...", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + numbers_from_list("3, 4, ...,5...", int) + no_dots.assert_not_called() + from_dots.assert_called() + from_dots.reset_mock() + + def test_get_points(self): + self.assertEqual(_get_points(["1", "2", "3"], int), (1, 2, 3)) + self.assertEqual(_get_points(["1", "2", "3"], float), (1, 2, 3)) + self.assertEqual(_get_points(["10.5", "2.25", "3"], float), (10.5, 2.25, 3)) + + with self.assertRaisesRegex(ValueError, "3.3"): + _get_points(["1", "2", "3.3", "4"], int) + + with self.assertRaisesRegex(ValueError, "asdf"): + _get_points(["1", "2", "asdf", "4"], int) + + def test_numbers_from_no_dots(self): + self.assertEqual(numbers_from_list("1 2 3", int), (1, 2, 3)) + self.assertEqual(numbers_from_list("1 2 3", float), (1, 2, 3)) + self.assertEqual(numbers_from_list("10.5 2.25 3", float), (2.25, 3, 10.5)) + + with self.assertRaisesRegex(ValueError, "3.3"): + numbers_from_list("1 2 3.3 4", int) + + with self.assertRaisesRegex(ValueError, "asdf"): + numbers_from_list("1 2 asdf 4", int) + + with self.assertRaisesRegex(ValueError, "value must be at least 2"): + numbers_from_list("1", int, 2) + + with self.assertRaisesRegex(ValueError, "value must be at most 2"): + numbers_from_list("3", int, None, 2) + + with self.assertRaisesRegex(ValueError, "value must be between 2 and 3"): + numbers_from_list("1 4 2 3", int, 2, 3) + + def test_numbers_from_dots(self): + def check(minimum, maximum, tests, typ=int, enforce_range=True): + for text, *expected in tests: + if not expected: + with self.assertRaises(ValueError): + numbers_from_list(text, typ, minimum, maximum, + enforce_range) + else: + self.assertEqual( + numbers_from_list(text, typ, minimum, maximum, + enforce_range), + expected[0], + f"for {text}") + + check(None, None, [ + ("1, 2, ..., 5", (1, 2, 3, 4, 5)), + ("1, 2, 3, ..., 5, 6, 7", (1, 2, 3, 4, 5, 6, 7)), + ("3, ..., 5, 6", (3, 4, 5, 6)), + ("..., 5, 6", ), + ("5, 6, ...", ), + ("1, 2, 3, 4, 5, ...", ), + ("1, ..., 5", ), + ("1, 2, ..., 5, 6, ..., 8", )]) + + # 5 to 10 + check(5, 10, [ + ("4, 5, ..., 8", ), + ("5, 6, ..., 12", ), + ("5, 6, ..., 9", (5, 6, 7, 8, 9)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("6, 7, ..., 8, 9", (6, 7, 8, 9)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ("6, 7, ...", (6, 7, 8, 9, 10)), + ("6, 7, 8, 9, ...", (6, 7, 8, 9, 10)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ]) + + check(5, None, [ + ("4, 5, ..., 8", ), + ("5, 6, ..., 12", (5, 6, 7, 8, 9, 10, 11, 12)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("6, 7, ..., 8, 9", (6, 7, 8, 9)), + ("..., 8, 9", (5, 6, 7, 8, 9)), + ("6, 7, ...", ) + ]) + + check(None, 10, [ + ("4, 5, ..., 8", (4, 5, 6, 7, 8)), + ("5, 6, ..., 12", ), + ("5, 6, ..., 9", (5, 6, 7, 8, 9)), + ("6, 7, ..., 9", (6, 7, 8, 9)), + ("..., 8, 9", ), + ("6, 7, ...", (6, 7, 8, 9, 10)), + ("6, 7, 8, 9, ...", (6, 7, 8, 9, 10)), + ("..., 8, 9", )]) + + check(5, 10, [ + ("5, 6..., 8", (5, 6, 7, 8)), + ("5,6...,8", (5, 6, 7, 8)), + ("5,6...8", (5, 6, 7, 8)), + ("5, 6 ... 8", (5, 6, 7, 8)), + ("5, 6 ... 8", (5, 6, 7, 8)), + ("5 6 ... ", (5, 6, 7, 8, 9, 10)), + ("..., 7, 8", (5, 6, 7, 8)), + ("..., 7, 8, ...", ), + ("5, 6, ..., 7, 8, ...", ), + ("5, 6, ..., 7, 8, ...", ), + ("5 6 8, ...", ), + ("5, 6, 8, ...", ), + ("5, 6, ..., 8, 10", ), + ("5, 7, ..., 8, 10", ), + ("8, 7, 6, ...", ), + ("5, 6, 7, ..., 7, 8", )]) + + check(5, 10, [ + ("3, 4, 5, 6..., 8", (3, 4, 5, 6, 7, 8)), + ("5, ..., 9, 11, 13", (5, 7, 9, 11, 13)), + ("5, 6, ..., ", (5, 6, 7, 8, 9, 10)), + ("..., 9, 10, 11", (5, 6, 7, 8, 9, 10, 11))], + int, False) + + check(1, None, [ + ("5, 6..., 8", (5, 6, 7, 8)), + ("..., 7, 9", (1, 3, 5, 7, 9)), + ("..., 8, 10", (2, 4, 6, 8, 10)), + ("..., 7, 10", (1, 4, 7, 10)), + ("..., 6, 10", (2, 6, 10)), + ("..., 5, 10", (5, 10)), + ("..., 4, 10", (4, 10)), + ]) + + check(None, 10, [ + ("2, 4, ...", (2, 4, 6, 8, 10)), + ("1, 3, ...", (1, 3, 5, 7, 9)), + ("1, 4, ...", (1, 4, 7, 10)), + ("1, 5, ...", (1, 5, 9)), + ("1, 6, ...", (1, 6)), + ]) + + check(None, None, [ + ("1.3, 1.6, ..., 2.5", (1.3, 1.6, 1.9, 2.2, 2.5)), + ("1.3, 1.6, ..., 2.55", )], + float) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/utils/textimport.py b/Orange/widgets/utils/textimport.py index ffc82fbf06f..3418fa34f3c 100644 --- a/Orange/widgets/utils/textimport.py +++ b/Orange/widgets/utils/textimport.py @@ -18,7 +18,7 @@ # TODO: Consider a wizard-like interface: # * 1. Select encoding, delimiter, ... (preview is all text) # * 2. Define column types (preview is parsed and rendered type appropriate) - +from __future__ import annotations import sys import io import enum @@ -38,13 +38,14 @@ from AnyQt.QtCore import ( Qt, QSize, QPoint, QRect, QRectF, QRegularExpression, QAbstractTableModel, - QModelIndex, QItemSelectionModel, QTextBoundaryFinder, QTimer, QEvent + QModelIndex, QItemSelectionModel, QTextBoundaryFinder, QTimer, QEvent, + QObject ) from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot from AnyQt.QtGui import ( QRegularExpressionValidator, QColor, QBrush, QPalette, QHelpEvent, QStandardItemModel, QStandardItem, QIcon, QIconEngine, QPainter, QPixmap, - QFont + QFont, QMouseEvent ) from AnyQt.QtWidgets import ( QWidget, QComboBox, QFormLayout, QHBoxLayout, QVBoxLayout, QLineEdit, @@ -54,7 +55,10 @@ ) from Orange.widgets.utils import encodings +from Orange.widgets.utils.tableview import TableView +from Orange.widgets.utils.headerview import CheckableHeaderView from Orange.widgets.utils.overlay import OverlayWidget +from Orange.widgets.utils.combobox import TextEditCombo __all__ = ["ColumnType", "RowSpec", "CSVOptionsWidget", "CSVImportWidget"] @@ -233,27 +237,6 @@ def minimumSizeHint(self): return super(LineEdit, self).sizeHint() -class TextEditCombo(QComboBox): - def text(self): - # type: () -> str - """ - Return the current text. - """ - return self.itemText(self.currentIndex()) - - def setText(self, text): - # type: (str) -> None - """ - Set `text` as the current text (adding it to the model if necessary). - """ - idx = self.findData(text, Qt.EditRole, Qt.MatchExactly) - if idx != -1: - self.setCurrentIndex(idx) - else: - self.addItem(text) - self.setCurrentIndex(self.count() - 1) - - class CSVOptionsWidget(QWidget): """ A widget presenting common CSV options. @@ -668,7 +651,7 @@ def __init__(self, *args, **kwargs): self.grouping_sep_edit_cb.setValidator( QRegularExpressionValidator(QRegularExpression(r"(\.|,| |')?"), self) ) - self.grouping_sep_edit_cb.activated[str].connect( + self.grouping_sep_edit_cb.textActivated.connect( self.__group_sep_activated) self.decimal_sep_edit_cb = TextEditCombo( @@ -680,7 +663,7 @@ def __init__(self, *args, **kwargs): self.decimal_sep_edit_cb.setValidator( QRegularExpressionValidator(QRegularExpression(r"(\.|,)"), self)) self.decimal_sep_edit_cb.addItems([".", ","]) - self.decimal_sep_edit_cb.activated[str].connect( + self.decimal_sep_edit_cb.textActivated.connect( self.__decimal_sep_activated) number_sep_layout.addWidget(QLabel("Grouping:")) @@ -721,7 +704,16 @@ def __init__(self, *args, **kwargs): self.column_type_edit_cb.setCurrentIndex(-1) form.addRow(QFrame(frameShape=QFrame.HLine)) - form.addRow("Column type", self.column_type_edit_cb) + cb_hint = QHBoxLayout() + cb_hint.addWidget(self.column_type_edit_cb) + hint = QLabel( + "Hint: right click on the row or column header for additional options.") + font = hint.font() + font.setPointSizeF(0.9 * font.pointSizeF()) + hint.setFont(font) + cb_hint.addWidget(hint) + form.addRow("Column type", cb_hint) + layout.addWidget(self.dataview) # Overlay error message widget in the bottom left corner of the data # view @@ -731,7 +723,7 @@ def __init__(self, *args, **kwargs): objectName="-error-overlay", visible=False, ) - overlay.setLayout(QVBoxLayout(margin=0)) + overlay.setLayout(QVBoxLayout()) self.__error_label = label = QLabel(objectName="-error-text-label") overlay.layout().addWidget(label) overlay.setWidget(self.dataview.viewport()) @@ -1049,10 +1041,17 @@ def __on_column_type_edit_activated(self, idx): self.__setColumnType(columns, coltype) def __dataview_context_menu(self, pos): + bhv = self.dataview.selectionBehavior() + selmodel = self.dataview.selectionModel() pos = self.dataview.viewport().mapToGlobal(pos) - cols = self.dataview.selectionModel().selectedColumns(0) - cols = [idx.column() for idx in cols] - self.__run_type_columns_menu(pos, cols) + if bhv == QTableView.SelectColumns: + cols = selmodel.selectedColumns(0) + cols = [midx.column() for midx in cols] + self.__run_type_columns_menu(pos, cols) + elif bhv == QTableView.SelectRows: + rows = selmodel.selectedRows(0) + rows = [midx.row() for midx in rows] + self.__run_row_menu(pos, rows) def __hheader_context_menu(self, pos): pos = self.dataview.horizontalHeader().mapToGlobal(pos) @@ -1064,18 +1063,43 @@ def __vheader_context_menu(self, pos): header = self.dataview.verticalHeader() # type: QHeaderView index = header.logicalIndexAt(pos) pos = header.mapToGlobal(pos) - model = header.model() # type: QAbstractTableModel + bhv = self.dataview.selectionBehavior() + if bhv == QAbstractItemView.SelectRows: + selmodel = self.dataview.selectionModel() + model = selmodel.model() + midxs = selmodel.selectedRows(0) + indices = [midx.row() for midx in midxs] + if index not in indices: + selmodel.select( + model.index(index, 0), + QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows + ) + indices = [index] + else: + indices = [index] + self.__run_row_menu(pos, indices) + def __run_row_menu(self, pos: QPoint, rows: List[int]): + model = self.__previewmodel + if model is None: + return + header = self.dataview.verticalHeader() RowStateRole = TablePreviewModel.RowStateRole - state = model.headerData(index, Qt.Vertical, RowStateRole) + rowstates = {model.headerData(i, Qt.Vertical, RowStateRole) + for i in rows} + if len(rowstates) == 1: + current = rowstates.pop() + else: + current = None + m = QMenu(header) skip_action = m.addAction("Skip") skip_action.setCheckable(True) - skip_action.setChecked(state == TablePreview.Skipped) + skip_action.setChecked(current == TablePreview.Skipped) m.addSection("") mark_header = m.addAction("Header") mark_header.setCheckable(True) - mark_header.setChecked(state == TablePreview.Header) + mark_header.setChecked(current == TablePreview.Header) def update_row_state(action): # type: (QAction) -> None @@ -1084,14 +1108,14 @@ def update_row_state(action): state = TablePreview.Header if action.isChecked() else None elif action is skip_action: state = TablePreview.Skipped if action.isChecked() else None - model.setHeaderData(index, Qt.Vertical, state, RowStateRole) - self.dataview.setRowHints({index: state}) + for index in rows: + model.setHeaderData(index, Qt.Vertical, state, RowStateRole) + self.dataview.setRowHints(dict.fromkeys(rows, state)) m.triggered.connect(update_row_state) m.popup(pos) - def __run_type_columns_menu(self, pos, columns): - # type: (QPoint, List[int]) -> None + def __run_type_columns_menu(self, pos: QPoint, columns: List[int]) -> None: # Open a QMenu at pos for setting column types for column indices list # `columns` model = self.__previewmodel @@ -1326,42 +1350,39 @@ class RowSpec(enum.IntEnum): Skipped = 2 -class TablePreview(QTableView): +class TablePreview(TableView): RowSpec = RowSpec Header, Skipped = RowSpec def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setItemDelegate(PreviewItemDelegate(self)) + header = CheckableHeaderView(Qt.Vertical) + header.setSectionsClickable(True) + self.setVerticalHeader(header) + self.horizontalHeader().viewport().installEventFilter(self) + self.verticalHeader().viewport().installEventFilter(self) + self.viewport().installEventFilter(self) def rowsInserted(self, parent, start, end): # type: (QModelIndex, int, int) -> None super().rowsInserted(parent, start, end) behavior = self.selectionBehavior() - if behavior & (QTableView.SelectColumns | QTableView.SelectRows): + if behavior in (QTableView.SelectColumns, QTableView.SelectRows): # extend the selection to the new rows smodel = self.selectionModel() selection = smodel.selection() command = QItemSelectionModel.Select - if behavior & QTableView.SelectRows: + if behavior == QTableView.SelectRows: command |= QItemSelectionModel.Rows - if behavior & QTableView.SelectColumns: + if behavior == QTableView.SelectColumns: command |= QItemSelectionModel.Columns smodel.select(selection, command) - def setRowHints(self, hints): - # type: (Dict[int, TablePreview.RowSpec]) -> None - for row, hint in hints.items(): - current = self.itemDelegateForRow(row) - if current is not None: - current.deleteLater() - if hint == TablePreview.Header: - delegate = HeaderItemDelegate(self) - elif hint == TablePreview.Skipped: - delegate = SkipItemDelegate(self) - else: - delegate = None - self.setItemDelegateForRow(row, delegate) + def setRowHints(self, hints: dict[int, TablePreview.RowSpec]) -> None: + model = self.model() + for index, hint in hints.items(): + model.setHeaderData(index, Qt.Vertical, hint) def sizeHint(self): sh = super().sizeHint() # type: QSize @@ -1371,6 +1392,24 @@ def sizeHint(self): vsection = vh.defaultSectionSize() return sh.expandedTo(QSize(8 * hsection, 20 * vsection)) + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + if event.type() == QEvent.MouseButtonPress: + event = typing.cast(QMouseEvent, event) + bhv = None + if event.button() == Qt.LeftButton: + if obj is self.horizontalHeader().viewport(): + bhv = QTableView.SelectColumns + elif obj is self.verticalHeader().viewport(): + bhv = QTableView.SelectRows + if event.button() in (Qt.LeftButton, Qt.RightButton) and \ + obj is self.viewport(): + bhv = QTableView.SelectColumns + if bhv != self.selectionBehavior(): + self.clearSelection() + if bhv is not None: + self.setSelectionBehavior(bhv) + return super().eventFilter(obj, event) + def is_surrogate_escaped(text: str) -> bool: """Does `text` contain any surrogate escape characters.""" @@ -1394,7 +1433,9 @@ def initStyleOption(self, option, index): if coltype == ColumnType.Numeric or coltype == ColumnType.Time: option.displayAlignment = Qt.AlignRight | Qt.AlignVCenter - if not self.validate(option.text): + rowhint = model.headerData(index.row(), Qt.Vertical, + TablePreviewModel.RowStateRole) + if not self.validate(option.text) and rowhint is None: option.palette.setBrush( QPalette.All, QPalette.Text, QBrush(Qt.red, Qt.SolidPattern) ) @@ -1403,7 +1444,21 @@ def initStyleOption(self, option, index): QBrush(Qt.red, Qt.SolidPattern) ) - def validate(self, value: str) -> bool: # pylint: disable=no-self-use + if rowhint == RowSpec.Skipped: + color = QColor(Qt.red) + base = option.palette.color(QPalette.Base) + if base.isValid() and base.value() > 127: + # blend on 'light' base, not on dark (low contrast) + color.setAlphaF(0.2) + option.backgroundBrush = QBrush(color, Qt.DiagCrossPattern) + elif rowhint == RowSpec.Header: + shadow = option.palette.color(QPalette.WindowText) + if shadow.isValid(): + shadow.setAlphaF(0.1) + option.backgroundBrush = QBrush(shadow, Qt.SolidPattern) + option.displayAlignment = Qt.AlignCenter + + def validate(self, value: str) -> bool: return not is_surrogate_escaped(value) def helpEvent(self, event, view, option, index): @@ -1418,39 +1473,6 @@ def helpEvent(self, event, view, option, index): return super().helpEvent(event, view, option, index) -class HeaderItemDelegate(PreviewItemDelegate): - """ - Paint the items with an alternate color scheme - """ - NoFeatures = 0 - AutoDecorate = 1 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__features = HeaderItemDelegate.NoFeatures - - def features(self): - return self.__features - - def initStyleOption(self, option, index): - # type: (QStyleOptionViewItem, QModelIndex) -> None - super().initStyleOption(option, index) - palette = option.palette - shadow = palette.color(QPalette.Foreground) # type: QColor - if shadow.isValid(): - shadow.setAlphaF(0.1) - option.backgroundBrush = QBrush(shadow, Qt.SolidPattern) - option.displayAlignment = Qt.AlignCenter - model = index.model() - if option.icon.isNull() and \ - self.__features & HeaderItemDelegate.AutoDecorate: - ctype = model.headerData(index.column(), Qt.Horizontal, - TablePreviewModel.ColumnTypeRole) - option.icon = icon_for_column_type(ctype) - if not option.icon.isNull(): - option.features |= QStyleOptionViewItem.HasDecoration - - def icon_for_column_type(coltype): # type: (ColumnType) -> QIcon if coltype == ColumnType.Numeric: @@ -1646,6 +1668,9 @@ def headerData(self, section, orientation, role=Qt.DisplayRole): """Reimplemented.""" if role == Qt.DisplayRole: return section + 1 + elif role == Qt.CheckStateRole and orientation == Qt.Vertical: + state = self.__headerData[orientation][section].get(TablePreviewModel.RowStateRole) + return Qt.Unchecked if state == RowSpec.Skipped else Qt.Checked else: return self.__headerData[orientation][section].get(role) @@ -1657,6 +1682,9 @@ def setHeaderData(self, section, orientation, value, role=Qt.EditRole): if value is None: del self.__headerData[orientation][section][role] else: + if role == Qt.CheckStateRole and orientation == Qt.Vertical: + role = TablePreviewModel.RowStateRole + value = RowSpec.Skipped if value == Qt.Unchecked else None self.__headerData[orientation][section][role] = value self.headerDataChanged.emit(orientation, section, section) return True @@ -1694,7 +1722,7 @@ def updateHeaderData(self, orientation, values): def flags(self, index): # type: (QModelIndex) -> Qt.ItemFlags """Reimplemented.""" - # pylint: disable=unused-argument,no-self-use + # pylint: disable=unused-argument return Qt.ItemFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) def errorString(self): diff --git a/Orange/widgets/utils/userinput.py b/Orange/widgets/utils/userinput.py new file mode 100644 index 00000000000..0adadfbf18a --- /dev/null +++ b/Orange/widgets/utils/userinput.py @@ -0,0 +1,110 @@ +from typing import Type, TypeVar, Optional +import re + +T = TypeVar("T", int, float) + + +def numbers_from_list( + text: str, + typ: Type[T], + minimum: Optional[T] = None, + maximum: Optional[T] = None, + enforce_range: Optional[bool] = True) -> tuple[T, ...]: + text = text.strip() + if not text: + return () + + if re.search(r"(^|[^.])\.\.\.($|[^.])", text): + return _numbers_from_dots(text, typ, minimum, maximum, enforce_range) + else: + if enforce_range: + return _numbers_from_no_dots(text, typ, minimum, maximum) + else: + return _numbers_from_no_dots(text, typ) + + +def _get_points(numbers: list[str], typ: Type[T]) -> tuple[T, ...]: + try: + return tuple(map(typ, numbers)) + except ValueError as exc: + msg = str(exc) + raise ValueError(f"invalid value ({msg[msg.rindex(':') + 1:]})") from exc + + +def _numbers_from_no_dots( + text: str, + typ: Type[T], + minimum: Optional[T] = None, + maximum: Optional[T] = None) -> tuple[T, ...]: + points = text.replace("...", " ... ").replace(", ", " ").split() + steps = tuple(sorted(set(_get_points(points, typ)))) + under = minimum is not None and steps[0] < minimum + over = maximum is not None and steps[-1] > maximum + if under and over: + raise ValueError(f"value must be between {minimum} and {maximum}") + if under: + raise ValueError(f"value must be at least {minimum}") + if over: + raise ValueError(f"value must be at most {maximum}") + return steps + + +def _numbers_from_dots( + text: str, + typ: Type[T], + minimum: Optional[T] = None, + maximum: Optional[T] = None, + enforce_range: Optional[bool] = True) -> tuple[T, ...]: + # many branches are results of many checks and don't degrade readability + # pylint: disable=too-many-branches + points = text.replace("...", " ... ").replace(",", " ").split() + if points.count("...") > 1: + raise ValueError("multiple '...'.") + dotind = points.index("...") + pre = _get_points(points[:dotind], typ) + post = _get_points(points[dotind + 1:], typ) + if pre and post and pre[-1] >= post[0]: + raise ValueError("values before '...' must be smaller than values after.") + + diffs = {y - x for x, y in zip(pre, pre[1:])} \ + | {y - x for x, y in zip(post, post[1:])} + if not diffs: + raise ValueError("at least two values are required before or after '...'.") + diff_of_diffs = max(diffs) - min(diffs) + if diff_of_diffs > 1e-10: + raise ValueError("points must be in uniform order.") + diff = next(iter(diffs)) + if typ is float: + diff = round(diff, 7) + if diff <= 0: + raise ValueError("points must be in increasing order.") + + minpoint = pre[0] if pre else minimum + maxpoint = post[-1] if post else maximum + if minpoint is None: + raise ValueError("minimum value is missing.") + if maxpoint is None: + raise ValueError("maximum value is missing.") + if enforce_range: + if minimum is not None and minpoint < minimum: + raise ValueError(f"minimum value is below the minimum {minimum}.") + if maximum is not None and maxpoint > maximum: + raise ValueError(f"maximum value is above the maximum {maximum}.") + steps = (maxpoint - minpoint) // diff + if (minpoint - maxpoint) % diff > 1e-10: + if pre and post: + raise ValueError( + "the sequence before '...' does not end with the sequence after it.") + if not pre: + minpoint = maxpoint - steps * diff + else: + maxpoint = minpoint + steps * diff + + if typ is int: + return tuple(range(minpoint, maxpoint + diff, diff)) + else: + points = [minpoint + i * diff + for i in range(int((maxpoint - minpoint) / diff) + 1)] + if maxpoint - points[-1] > 1e-10: + points.append(maxpoint) + return tuple(points) diff --git a/Orange/widgets/visualize/__init__.py b/Orange/widgets/visualize/__init__.py index ff3394c681d..725bd0a2cca 100644 --- a/Orange/widgets/visualize/__init__.py +++ b/Orange/widgets/visualize/__init__.py @@ -7,13 +7,11 @@ """ -# Category description for the widget registry - NAME = "Visualize" ID = "orange.widgets.visualize" -DESCRIPTION = "Widgets for data visualization." +DESCRIPTION = "Data visualization" BACKGROUND = "#FFB7B1" diff --git a/Orange/widgets/visualize/icons/BarPlot.svg b/Orange/widgets/visualize/icons/BarPlot.svg index b9f8f4ee2cd..8c91bc3df31 100644 --- a/Orange/widgets/visualize/icons/BarPlot.svg +++ b/Orange/widgets/visualize/icons/BarPlot.svg @@ -2,18 +2,22 @@ - - - diff --git a/Orange/widgets/visualize/icons/BoxPlot.svg b/Orange/widgets/visualize/icons/BoxPlot.svg index 76658aef794..ed418f41be0 100644 --- a/Orange/widgets/visualize/icons/BoxPlot.svg +++ b/Orange/widgets/visualize/icons/BoxPlot.svg @@ -3,16 +3,24 @@ - - +* { color: #333; } +.ColorScheme-Text { color: #333; } +.ColorScheme-DisabledText { color: #666; } +.ColorScheme-Background { color: #fff; } + + + + - - - - diff --git a/Orange/widgets/visualize/icons/CN2RuleViewer.svg b/Orange/widgets/visualize/icons/CN2RuleViewer.svg index 63a939fdd2d..eba23b5a29f 100644 --- a/Orange/widgets/visualize/icons/CN2RuleViewer.svg +++ b/Orange/widgets/visualize/icons/CN2RuleViewer.svg @@ -3,16 +3,21 @@ + - + - - - + diff --git a/Orange/widgets/visualize/icons/Distribution.svg b/Orange/widgets/visualize/icons/Distribution.svg index 8e57e0a65bf..ef0fef112ea 100644 --- a/Orange/widgets/visualize/icons/Distribution.svg +++ b/Orange/widgets/visualize/icons/Distribution.svg @@ -3,10 +3,17 @@ - - - - - + + + + + + + + diff --git a/Orange/widgets/visualize/icons/Freeviz.svg b/Orange/widgets/visualize/icons/Freeviz.svg index a526f35eccc..8052edf8245 100644 --- a/Orange/widgets/visualize/icons/Freeviz.svg +++ b/Orange/widgets/visualize/icons/Freeviz.svg @@ -2,6 +2,14 @@ + + +

      " \ f"{escape(desc)}: {int(freq)} " \ @@ -644,13 +744,13 @@ def _disc_split_plot(self): else: order = np.arange(len(conts)) - ordered_values = np.array(var.values)[order] - self.ploti.getAxis("bottom").setTicks([list(enumerate(ordered_values))]) + self.ordered_values = list(np.array(var.values)[order]) + self.ploti.getAxis("bottom").setTicks([list(enumerate(self.ordered_values))]) gcolors = [QColor(*col) for col in self.cvar.colors] gvalues = self.cvar.values total = len(self.data) - for i, freqs, desc in zip(count(), conts[order], ordered_values): + for i, freqs, desc in zip(count(), conts[order], self.ordered_values): self._add_bar( i - 0.5, 1, 0.1, freqs, gcolors, stacked=self.stacked_columns, expanded=self.show_probs, @@ -676,6 +776,7 @@ def _cont_plot(self): for i, (x0, x1), freq in zip(count(), zip(x, x[1:]), y): tot_freq += freq desc = self.str_int(x0, x1, not i, i == lasti, unique) + self.ordered_values.append(desc) tooltip = \ "

      " \ f"{escape(desc)}: " \ @@ -685,7 +786,9 @@ def _cont_plot(self): x0 + xoff, bar_width, 0, [tot_freq if self.cumulative_distr else freq], colors, stacked=False, expanded=False, tooltip=tooltip, - desc=desc, hidden=self.hide_bars) + desc=desc, hidden=self.hide_bars and self.fitted_distribution, + low=x0, high=x1 + ) if self.fitted_distribution: self._plot_approximations( @@ -725,15 +828,18 @@ def _cont_split_plot(self): tot_freqs += freqs plotfreqs = tot_freqs.copy() if self.cumulative_distr else freqs desc = self.str_int(x0, x1, not i, i == lasti, unique) + self.ordered_values.append(desc) bar_width = width if unique else x1 - x0 self._add_bar( x0 + xoff, bar_width, 0 if self.stacked_columns else 0.1, plotfreqs, gcolors, stacked=self.stacked_columns, expanded=self.show_probs, - hidden=self.hide_bars, + hidden=self.hide_bars and self.fitted_distribution, tooltip=self._split_tooltip( desc, np.sum(plotfreqs), total, gvalues, plotfreqs), - desc=desc) + desc=desc, + low=x0, high=x1 + ) if fitters: self._plot_approximations(bins[0], bins[-1], fitters, varcolors, @@ -777,6 +883,7 @@ def str_params(): if not y.size: return None, None + # false positive, pylint: disable=invalid-sequence-index _, dist, names, str_names = self.Fitters[self.fitted_distribution] fitted = dist.fit(y) params = dict(zip(names, fitted)) @@ -789,6 +896,7 @@ def _plot_approximations(self, x0, x1, fitters, colors, prior_probs): for y, (fitter, _) in zip(ys, fitters): if fitter is None: continue + # false positive, pylint: disable=invalid-sequence-index if self.Fitters[self.fitted_distribution][1] is AshCurve: y[:] = fitter(x, sigma=(22 - self.kde_smoothing) / 40) else: @@ -850,49 +958,74 @@ def _split_tooltip(valname, tot_group, total, gvalues, freqs): for value, freq in zip(gvalues, freqs)) + \ "" + def update_legend(self): + if self.is_valid: + self._display_legend() + def _display_legend(self): - assert self.is_valid # called only from replot, so assumes data is OK + # called only from replot and update_legend, so it assumes data is OK + assert self.is_valid + self._legend.clear() + if not self.show_legend or \ + self.cvar is None and ( + not self.curve_descriptions + or not self.curve_descriptions[0] + ): + self._legend.hide() + return + + param_setter = self.plotview.parameter_setter if self.cvar is None: - if not self.curve_descriptions or not self.curve_descriptions[0]: - self._legend.hide() - return self._legend.addItem( pg.PlotCurveItem(pen=pg.mkPen(width=5, color=0.0)), self.curve_descriptions[0]) else: + add_item = np.ones(len(self.cvar.values), dtype=bool) + if param_setter.figure_settings[ParameterSetter.HIDE_EMPTY_LABEL]: + add_item[:] = False + add_item[self.data.get_column(self.cvar).astype(int)] = True cvar_values = self.cvar.values colors = [QColor(*col) for col in self.cvar.colors] descriptions = self.curve_descriptions or repeat(None) - for color, name, desc in zip(colors, cvar_values, descriptions): + for color, name, desc, add in zip(colors, cvar_values, + descriptions, add_item): + if not add: + continue self._legend.addItem( ScatterPlotItem(pen=color, brush=color, size=10, shape="s"), escape(name + (f" ({desc})" if desc else ""))) self._legend.show() + Updater.update_legend_font(self._legend.items, + **param_setter.legend_settings) # ----------------------------- # Bins def recompute_binnings(self): + self.binnings = [] + max_bins = 0 if self.is_valid and self.var.is_continuous: # binning is computed on valid var data, ignoring any cvar nans - column = self.data.get_column_view(self.var)[0].astype(float) + column = self.data.get_column(self.var) if np.any(np.isfinite(column)): if self.var.is_time: self.binnings = time_binnings(column, min_unique=5) - self.bin_width_label.setFixedWidth(45) else: self.binnings = decimal_binnings( column, min_width=self.min_var_resolution(self.var), add_unique=10, min_unique=5) - self.bin_width_label.setFixedWidth(35) + fm = QFontMetrics(self.font()) + width = max(fm.size(Qt.TextSingleLine, + self._short_text(binning.width_label) + ).width() + for binning in self.binnings) + self.bin_width_label.setFixedWidth(width) max_bins = len(self.binnings) - 1 - else: - self.binnings = [] - max_bins = 0 self.controls.number_of_bins.setMaximum(max_bins) self.number_of_bins = min( max_bins, self._user_var_bins.get(self.var, self.number_of_bins)) + self._user_var_bins[self.var] = self.number_of_bins self._set_bin_width_slider_label() @staticmethod @@ -922,28 +1055,29 @@ def str_int(self, x0, x1, first, last, unique=False): # Selection def _on_item_clicked(self, item, modifiers, drag): - def add_or_remove(idx, add): + def add_or_remove(value, add): self.drag_operation = [self.DragRemove, self.DragAdd][add] if add: - self.selection.add(idx) + self.selected_bars.add(value) else: - if idx in self.selection: + if value in self.selected_bars: # This can be False when removing with dragging and the # mouse crosses unselected items - self.selection.remove(idx) + self.selected_bars.remove(value) def add_range(add): if self.last_click_idx is None: add = True - idx_range = {idx} + idx_range = {self.ordered_values[idx]} else: from_idx, to_idx = sorted((self.last_click_idx, idx)) - idx_range = set(range(from_idx, to_idx + 1)) + idx_range = {self.ordered_values[idx] + for idx in range(from_idx, to_idx + 1)} self.drag_operation = [self.DragRemove, self.DragAdd][add] if add: - self.selection |= idx_range + self.selected_bars |= idx_range else: - self.selection -= idx_range + self.selected_bars -= idx_range self.key_operation = None if item is None: @@ -955,19 +1089,20 @@ def add_range(add): # Dragging has to add a range, otherwise fast dragging skips bars add_range(self.drag_operation == self.DragAdd) else: + value = self.ordered_values[idx] if modifiers & Qt.ShiftModifier: add_range(self.drag_operation == self.DragAdd) elif modifiers & Qt.ControlModifier: - add_or_remove(idx, add=idx not in self.selection) + add_or_remove(value, add=value not in self.selected_bars) else: - if self.selection == {idx}: - # Clicking on a single selected bar deselects it, + if self.selected_bars == {value}: + # Clicking on a single selected bar deselects it, # but dragging from here will select - add_or_remove(idx, add=False) + add_or_remove(value, add=False) self.drag_operation = self.DragAdd else: - self.selection.clear() - add_or_remove(idx, add=True) + self.selected_bars.clear() + add_or_remove(value, add=True) self.last_click_idx = idx self.show_selection() @@ -976,14 +1111,14 @@ def _on_blank_clicked(self): self.reset_select() def reset_select(self): - self.selection.clear() + self.selected_bars.clear() self.last_click_idx = None self.drag_operation = None self.key_operation = None self.show_selection() def _on_end_selecting(self): - self.apply() + self.apply.deferred() def show_selection(self): self.plot_mark.clear() @@ -999,14 +1134,16 @@ def show_selection(self): group = list(group) left_idx, right_idx = group[0], group[-1] left_pad, right_pad = self._determine_padding(left_idx, right_idx) - x0 = self.bar_items[left_idx].x0 - left_pad - x1 = self.bar_items[right_idx].x1 + right_pad + left, right = (self.bar_items[it] for it in (left_idx, right_idx)) + x0 = left.x0 - left_pad + x1 = right.x1 + right_pad item = QGraphicsRectItem(x0, 0, x1 - x0, 1) item.setPen(pen) item.setBrush(brush) if self.var.is_continuous: valname = self.str_int( - x0, x1, not left_idx, right_idx == len(self.bar_items) - 1) + left.low, right.high, + self._is_first_bar(left_idx), self._is_last_bar(right_idx)) inside = sum(np.sum(self.bar_items[i].freqs) for i in group) total = len(self.valid_data) item.setToolTip( @@ -1029,6 +1166,9 @@ def _padding(i): if right_idx < len(self.bar_items) - 1: right_pad = _padding(right_idx) else: + # if (left_idx, right_idx) span across all, we would have returned + # above, so left_idx > 0 here, + # pylint: disable=possibly-used-before-assignment right_pad = left_pad if left_idx == 0: left_pad = right_pad @@ -1036,8 +1176,20 @@ def _padding(i): def grouped_selection(self): return [[g[1] for g in group] - for _, group in groupby(enumerate(sorted(self.selection)), + for _, group in groupby(enumerate(sorted(map(self.ordered_values.index, + self.selected_bars))), key=lambda x: x[1] - x[0])] + # Alternative: + # groups = [] + # last = None + # for idx, value in enumerate(self.ordered_values): + # if value in self.selected_bars: + # if last is None: + # groups.append(last := []) + # last.append(idx) + # else: + # last = None + # return groups def keyPressEvent(self, e): def on_nothing_selected(): @@ -1045,65 +1197,98 @@ def on_nothing_selected(): self.last_click_idx = len(self.bar_items) - 1 else: self.last_click_idx = 0 - self.selection.add(self.last_click_idx) + self.selected_bars.add(self.ordered_values[self.last_click_idx]) def on_key_left(): + # first and last are defined, pylint: possibly-used-before-assignment if e.modifiers() & Qt.ShiftModifier: if self.key_operation == Qt.Key_Right and first != last: - self.selection.remove(last) + self.selected_bars.remove(self.ordered_values[last]) self.last_click_idx = last - 1 elif first: self.key_operation = Qt.Key_Left - self.selection.add(first - 1) + self.selected_bars.add(self.ordered_values[first - 1]) self.last_click_idx = first - 1 else: - self.selection.clear() + self.selected_bars.clear() self.last_click_idx = max(first - 1, 0) - self.selection.add(self.last_click_idx) + self.selected_bars.add(self.ordered_values[self.last_click_idx]) def on_key_right(): if e.modifiers() & Qt.ShiftModifier: if self.key_operation == Qt.Key_Left and first != last: - self.selection.remove(first) + self.selected_bars.remove(self.ordered_values[first]) self.last_click_idx = first + 1 elif not self._is_last_bar(last): self.key_operation = Qt.Key_Right - self.selection.add(last + 1) + self.selected_bars.add(self.ordered_values[last + 1]) self.last_click_idx = last + 1 else: - self.selection.clear() + self.selected_bars.clear() self.last_click_idx = min(last + 1, len(self.bar_items) - 1) - self.selection.add(self.last_click_idx) + self.selected_bars.add(self.ordered_values[self.last_click_idx]) if not self.is_valid or not self.bar_items \ or e.key() not in (Qt.Key_Left, Qt.Key_Right): super().keyPressEvent(e) return - prev_selection = self.selection.copy() - if not self.selection: + prev_selection = self.selected_bars.copy() + if not self.selected_bars: on_nothing_selected() else: - first, last = min(self.selection), max(self.selection) + sel_indices = list(map(self.ordered_values.index, self.selected_bars)) + first, last = min(sel_indices), max(sel_indices) if e.key() == Qt.Key_Left: on_key_left() else: on_key_right() - if self.selection != prev_selection: + if self.selected_bars != prev_selection: self.drag_operation = self.DragAdd self.show_selection() - self.apply() + self.apply.deferred() def keyReleaseEvent(self, ev): if ev.key() == Qt.Key_Shift: self.key_operation = None super().keyReleaseEvent(ev) + def _reduce_selection(self): + """ + Unselect any bars that no longer appear in the plot; migrate from ints + + This function is called after plotting to remove any bars that have + been selected but are no longer plotted. This occurs in particular + when the widget receives new data with discrete variables that lack + some values. + + This function also migrates from previous settings, which stored ints + instead of values. This migration requires bar labels and cannot be + (easily) done before plotting. + """ + if self.selected_bars \ + and isinstance(next(iter(self.selected_bars)), int): + self.selected_bars = { + self.ordered_values[idx] for idx in self.selected_bars + if idx < len(self.ordered_values)} + else: + self.selected_bars = {value for value in self.selected_bars + if value in self.ordered_values} + + @classmethod + def migrate_context(cls, context, version): + # settings_version 2 has `selected_bars: set[str]` instead of + # `selection: set[int]`. Actual migration can only be done after + # plotting (see `_reduce_selection`), but we need to rename the setting + # so that handler assigns it to the widget instance + if "selection" in context.values: + context.values["selected_bars"] = context.values["selection"] # ----------------------------- # Output + @gui.deferred def apply(self): data = self.data selected_data = annotated_data = histogram_data = None @@ -1130,15 +1315,22 @@ def apply(self): def _get_output_indices_disc(self): group_indices = np.zeros(len(self.data), dtype=np.int32) - col = self.data.get_column_view(self.var)[0].astype(float) - for group_idx, val_idx in enumerate(self.selection, start=1): - group_indices[col == val_idx] = group_idx - values = [self.var.values[i] for i in self.selection] + col = self.data.get_column(self.var) + group_idx = 1 + values = [] + # self.selected_bars is a set, so its order is random; + # we iterate through ordered_value to get the same order as in chart + for value in self.ordered_values: + if value not in self.selected_bars: + continue + group_indices[col == self.var.to_val(value)] = group_idx + group_idx += 1 + values.append(value) return group_indices, values def _get_output_indices_cont(self): group_indices = np.zeros(len(self.data), dtype=np.int32) - col = self.data.get_column_view(self.var)[0].astype(float) + col = self.data.get_column(self.var) values = [] for group_idx, group in enumerate(self.grouped_selection(), start=1): x0 = x1 = None @@ -1150,10 +1342,13 @@ def _get_output_indices_cont(self): group_indices[mask] = group_idx # pylint: disable=undefined-loop-variable values.append( - self.str_int(x0, x1, not bar_idx, self._is_last_bar(bar_idx))) + self.str_int( + x0, x1, + self._is_first_bar(bar_idx), self._is_last_bar(bar_idx))) return group_indices, values def _get_histogram_table(self): + # bar is OK; pylint: disable=disallowed-name var_bin = DiscreteVariable("Bin", [bar.desc for bar in self.bar_items]) var_freq = ContinuousVariable("Count") X = [] @@ -1170,22 +1365,28 @@ def _get_histogram_table(self): def _get_histogram_indices(self): group_indices = np.zeros(len(self.data), dtype=np.int32) - col = self.data.get_column_view(self.var)[0].astype(float) + col = self.data.get_column(self.var) values = [] for bar_idx in range(len(self.bar_items)): x0, x1, mask = self._get_cont_baritem_indices(col, bar_idx) group_indices[mask] = bar_idx + 1 values.append( - self.str_int(x0, x1, not bar_idx, self._is_last_bar(bar_idx))) + self.str_int( + x0, x1, + self._is_first_bar(bar_idx), self._is_last_bar(bar_idx))) return group_indices, values def _get_cont_baritem_indices(self, col, bar_idx): bar_item = self.bar_items[bar_idx] - minx = bar_item.x0 - maxx = bar_item.x1 + (bar_idx == len(self.bar_items) - 1) + minx = bar_item.low + maxx = bar_item.high + self._is_last_bar(bar_idx) with np.errstate(invalid="ignore"): return minx, maxx, (col >= minx) * (col < maxx) + @staticmethod + def _is_first_bar(idx): + return idx == 0 + def _is_last_bar(self, idx): return idx == len(self.bar_items) - 1 @@ -1208,6 +1409,18 @@ def send_report(self): text += f" with columns split by '{self.cvar.name}'" self.report_caption(text) + # ----------------------------- + # Visual (plot) settings + + def set_visual_settings(self, key: KeyType, value: ValueType): + self.visual_settings[key] = value + self.plotview.parameter_setter.set_parameter(key, value) + if self.is_valid and key[:2] in ( + (ParameterSetter.ANNOT_BOX, ParameterSetter.X_AXIS_LABEL), + (ParameterSetter.ANNOT_BOX, ParameterSetter.Y_AXIS_LABEL)): + # an empty custom title falls back to the auto-generated label + self._set_axis_names() + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWDistributions).run(Table("heart_disease.tab")) diff --git a/Orange/widgets/visualize/owfreeviz.py b/Orange/widgets/visualize/owfreeviz.py index a0e40f35ab6..c309cb37616 100644 --- a/Orange/widgets/visualize/owfreeviz.py +++ b/Orange/widgets/visualize/owfreeviz.py @@ -5,11 +5,12 @@ import numpy as np from AnyQt.QtCore import Qt, QRectF, QLineF, QPoint -from AnyQt.QtGui import QColor +from AnyQt.QtGui import QPalette, QFontMetrics +from AnyQt.QtWidgets import QSizePolicy import pyqtgraph as pg -from Orange.data import Table +from Orange.data import Table, Domain from Orange.projection import FreeViz from Orange.projection.freeviz import FreeVizModel from Orange.widgets import widget, gui, settings @@ -56,6 +57,7 @@ def run_freeviz(data: Table, projector: FreeViz, state: TaskState): class OWFreeVizGraph(OWGraphWithAnchors): hide_radius = settings.Setting(0) + aggregate_dense_regions = settings.Setting(True) @property def scaled_radius(self): @@ -88,7 +90,7 @@ def update_anchors(self): self.anchor_items = [] for point, label in zip(points, labels): anchor = AnchorItem(line=QLineF(0, 0, *point), text=label) - anchor.setVisible(np.linalg.norm(point) > r) + anchor.setVisible(bool(np.linalg.norm(point) > r)) anchor.setPen(pg.mkPen((100, 100, 100))) anchor.setFont(self.parameter_setter.anchor_font) self.plot_widget.addItem(anchor) @@ -97,7 +99,7 @@ def update_anchors(self): for anchor, point, label in zip(self.anchor_items, points, labels): anchor.setLine(QLineF(0, 0, *point)) anchor.setText(label) - anchor.setVisible(np.linalg.norm(point) > r) + anchor.setVisible(bool(np.linalg.norm(point) > r)) anchor.setFont(self.parameter_setter.anchor_font) def update_circle(self): @@ -105,7 +107,8 @@ def update_circle(self): if self.circle_item is not None: r = self.scaled_radius self.circle_item.setRect(QRectF(-r, -r, 2 * r, 2 * r)) - pen = pg.mkPen(QColor(Qt.lightGray), width=1, cosmetic=True) + color = self.plot_widget.palette().color(QPalette.Disabled, QPalette.Text) + pen = pg.mkPen(color, width=1, cosmetic=True) self.circle_item.setPen(pen) def _add_indicator_item(self, anchor_idx): @@ -131,13 +134,17 @@ class OWFreeViz(OWAnchorProjectionWidget, ConcurrentWidgetMixin): description = "Displays FreeViz projection" icon = "icons/Freeviz.svg" priority = 240 - keywords = ["viz"] + keywords = "freeviz, viz" settings_version = 3 initialization = settings.Setting(InitType.Circular) + balance = settings.Setting(False) + gravity_index = settings.Setting(4) GRAPH_CLASS = OWFreeVizGraph graph = settings.SettingProvider(OWFreeVizGraph) + GravityValues = [0.1, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5] + class Error(OWAnchorProjectionWidget.Error): no_class_var = widget.Msg("Data must have a target variable.") multiple_class_vars = widget.Msg( @@ -157,6 +164,7 @@ class Warning(OWAnchorProjectionWidget.Warning): def __init__(self): OWAnchorProjectionWidget.__init__(self) ConcurrentWidgetMixin.__init__(self) + self.__optimized = False def _add_controls(self): self.__add_controls_start_box() @@ -172,7 +180,23 @@ def __add_controls_start_box(self): gui.comboBox( box, self, "initialization", label="Initialization:", items=InitType.items(), orientation=Qt.Horizontal, - labelWidth=90, callback=self.__init_combo_changed) + callback=self.__init_combo_changed, + sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + ) + box2 = gui.hBox(box) + gui.checkBox( + box2, self, "balance", "Gravity", + callback=self.__gravity_changed) + self.grav_slider = gui.hSlider( + box2, self, "gravity_index", + minValue=0, maxValue=len(self.GravityValues) - 1, + callback=self.__gravity_dragged, createLabel=False) + self.gravity_label = gui.widgetLabel(box2) + self.gravity_label.setFixedWidth( + max(QFontMetrics(self.font()).horizontalAdvance(str(x)) + for x in self.GravityValues)) + self.gravity_label.setAlignment(Qt.AlignRight) + self.__update_gravity_label() self.run_button = gui.button(box, self, "Start", self._toggle_run) @property @@ -180,6 +204,26 @@ def effective_variables(self): return [a for a in self.data.domain.attributes if a.is_continuous or a.is_discrete and len(a.values) == 2] + @property + def effective_data(self): + return self.data.transform(Domain(self.effective_variables, + self.data.domain.class_vars)) + + def __gravity_dragged(self): + self.balance = True + self.__gravity_changed() + + def __update_gravity_label(self): + self.gravity_label.setText(str(self.GravityValues[self.gravity_index])) + + def __gravity_changed(self): + gravity = self.GravityValues[self.gravity_index] + if self.projector is not None: + self.projector.gravity = gravity if self.balance else None + self.__update_gravity_label() + if self.task is None and self.__optimized: + self._run() + def __radius_slider_changed(self): self.graph.update_radius() @@ -187,7 +231,7 @@ def __init_combo_changed(self): self.Error.proj_error.clear() self.init_projection() self.setup_plot() - self.commit() + self.commit.deferred() if self.task is not None: self._run() @@ -196,7 +240,7 @@ def _toggle_run(self): self.cancel() self.graph.set_sample_size(None) self.run_button.setText("Resume") - self.commit() + self.commit.deferred() else: self._run() @@ -223,7 +267,8 @@ def on_done(self, result: Result): self.projection = result.projection self.graph.set_sample_size(None) self.run_button.setText("Start") - self.commit() + self.__optimized = True + self.commit.deferred() def on_exception(self, ex: Exception): self.Error.proj_error(ex) @@ -231,6 +276,7 @@ def on_exception(self, ex: Exception): self.run_button.setText("Start") # OWAnchorProjectionWidget + @OWAnchorProjectionWidget.Inputs.data def set_data(self, data): super().set_data(data) self.graph.set_sample_size(None) @@ -243,14 +289,19 @@ def init_projection(self): anchors = FreeViz.init_radial(len(self.effective_variables)) \ if self.initialization == InitType.Circular \ else FreeViz.init_random(len(self.effective_variables), 2) + if self.balance: + gravity = self.GravityValues[self.gravity_index] + else: + gravity = None self.projector = FreeViz(scale=False, center=False, - initial=anchors, maxiter=10) + initial=anchors, maxiter=10, gravity=gravity) data = self.projector.preprocess(self.effective_data) self.projector.domain = data.domain self.projector.components_ = anchors.T self.projection = FreeVizModel(self.projector, self.projector.domain, 2) self.projection.pre_domain = data.domain self.projection.name = self.projector.name + self.__optimized = False def check_data(self): def error(err): diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py index a51c747a1d4..3a2858daef9 100644 --- a/Orange/widgets/visualize/owheatmap.py +++ b/Orange/widgets/visualize/owheatmap.py @@ -10,8 +10,8 @@ import scipy.sparse as sp from AnyQt.QtWidgets import ( - QGraphicsScene, QGraphicsView, QFormLayout, QComboBox, QGroupBox, - QMenu, QAction, QSizePolicy + QGraphicsView, QFormLayout, QComboBox, QGroupBox, QMenu, QAction, + QSizePolicy ) from AnyQt.QtGui import QStandardItemModel, QStandardItem, QFont, QKeySequence from AnyQt.QtCore import Qt, QSize, QRectF, QObject @@ -27,6 +27,7 @@ from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView from Orange.widgets.utils.graphicsview import GraphicsWidgetView +from Orange.widgets.utils.graphicsscene import GraphicsScene from Orange.widgets.utils.colorpalettes import Palette from Orange.widgets.utils.annotated_data import (create_annotated_table, @@ -134,9 +135,9 @@ def create_list_model( class OWHeatMap(widget.OWWidget): name = "Heat Map" description = "Plot a data matrix heatmap." - icon = "icons/Heatmap.svg" + icon = "icons/Heatmap-symbolic.svg" priority = 260 - keywords = [] + keywords = "heat map" class Inputs: data = Input("Data", Table) @@ -186,11 +187,11 @@ class Outputs: auto_commit = settings.Setting(True) - graph_name = "scene" + graph_name = "scene" # QGraphicsScene (HeatmapScene) class Information(widget.OWWidget.Information): sampled = Msg("Data has been sampled") - discrete_ignored = Msg("{} categorical feature{} ignored") + discrete_ignored = Msg("Categorical features are ignored.") row_clust = Msg("{}") col_clust = Msg("{}") sparse_densified = Msg("Showing this data may require a lot of memory") @@ -228,8 +229,8 @@ def __init__(self): #: The original data with all features (retained to #: preserve the domain on the output) self.input_data = None - #: The effective data striped of discrete features, and often - #: merged using k-means + #: The effective data stripped of discrete features and hidden + #: attributes, and often merged using k-means self.data = None self.effective_data = None #: Source of column annotations (derived from self.data) @@ -444,7 +445,7 @@ def _(idx, cb=cb): gui.auto_send(self.buttonsArea, self, "auto_commit") # Scene with heatmap - class HeatmapScene(QGraphicsScene): + class HeatmapScene(GraphicsScene): widget: Optional[HeatmapGridWidget] = None self.scene = self.scene = HeatmapScene(parent=self) @@ -574,7 +575,7 @@ def set_dataset(self, data=None): self.clear_messages() if isinstance(data, SqlTable): - if data.approx_len() < 4000: + if len(data) < 4000: data = Table(data) else: self.Information.sampled() @@ -582,7 +583,7 @@ def set_dataset(self, data=None): data_sample.download_data(2000, partial=True) data = Table(data_sample) - if data is not None and not len(data): + if data is not None and np.all(np.isnan(data.X)): data = None if data is not None and sp.issparse(data.X): @@ -601,21 +602,23 @@ def set_dataset(self, data=None): self.Error.no_continuous() input_data = data = None - # Data contains some discrete attributes which must be filtered + # Data contains some discrete or hidden attributes which must be + # filtered if data is not None and \ - any(var.is_discrete for var in data.domain.attributes): + any(var.is_discrete or var.attributes.get('hidden', False) + for var in data.domain.attributes): ndisc = sum(var.is_discrete for var in data.domain.attributes) data = data.transform( Domain([var for var in data.domain.attributes - if var.is_continuous], + if var.is_continuous and + not var.attributes.get('hidden', False)], data.domain.class_vars, data.domain.metas)) if not data.domain.attributes: self.Error.no_continuous() input_data = data = None else: - self.Information.discrete_ignored( - ndisc, "s" if ndisc > 1 else "") + self.Information.discrete_ignored() self.data = data self.input_data = input_data @@ -662,12 +665,12 @@ def is_variable(obj): self.update_heatmaps() if data is not None and self.__pending_selection is not None: - assert self.scene.widget is not None - self.scene.widget.selectRows(self.__pending_selection) + if self.scene.widget is not None: + self.scene.widget.selectRows(self.__pending_selection) self.selected_rows = self.__pending_selection self.__pending_selection = None - self.unconditional_commit() + self.commit.now() def __on_split_rows_activated(self): self.set_split_variable(self.row_split_cb.currentData(Qt.EditRole)) @@ -688,7 +691,11 @@ def set_column_split_var(self, var: Optional[Variable]): def update_heatmaps(self): if self.data is not None: self.clear_scene() - self.clear_messages() + self.Error.clear() + self.Warning.clear() + self.Information.row_clust.clear() + self.Information.col_clust.clear() + self.Information.sampled.clear() if self.col_clustering != Clustering.None_ and \ len(self.data.domain.attributes) < 2: self.Error.not_enough_features() @@ -710,7 +717,7 @@ def update_merge(self): self.merge_indices = None if self.data is not None and self.merge_kmeans: self.update_heatmaps() - self.commit() + self.commit.deferred() def _make_parts(self, data, group_var=None, column_split_key=None): """ @@ -1055,11 +1062,11 @@ def update_color_schema(self): def __update_column_clustering(self): self.update_heatmaps() - self.commit() + self.commit.deferred() def __update_row_clustering(self): self.update_heatmaps() - self.commit() + self.commit.deferred() def update_legend(self): widget = self.scene.widget @@ -1107,7 +1114,7 @@ def row_side_colors(self): var = self.annotation_color_var if var is None: return None - column_data = column_data_from_table(self.input_data, var) + column_data = self.input_data.get_column(var) merges = self._merge_row_indices() if merges is not None: column_data = aggregate(var, column_data, merges) @@ -1207,8 +1214,9 @@ def on_selection_finished(self): self.selected_rows = list(self.scene.widget.selectedRows()) else: self.selected_rows = [] - self.commit() + self.commit.deferred() + @gui.deferred def commit(self): data = None indices = None @@ -1351,26 +1359,15 @@ def column_str_from_table( column: Union[int, Orange.data.Variable], ) -> np.ndarray: var = table.domain[column] - data, _ = table.get_column_view(column) + data = table.get_column(column) return np.asarray([var.str_val(v) for v in data], dtype=object) -def column_data_from_table( - table: Orange.data.Table, - column: Union[int, Orange.data.Variable], -) -> np.ndarray: - var = table.domain[column] - data, _ = table.get_column_view(column) - if var.is_primitive() and data.dtype.kind != "f": - data = data.astype(float) - return data - - def color_annotation_data( table: Table, var: Union[int, str, Variable] ) -> Tuple[np.ndarray, ColorMap, Variable]: var = table.domain[var] - column_data = column_data_from_table(table, var) + column_data = table.get_column(var) data, colormap = colorize(var, column_data) return data, colormap, var diff --git a/Orange/widgets/visualize/owlinearprojection.py b/Orange/widgets/visualize/owlinearprojection.py index 8541969d099..e419f1f85d3 100644 --- a/Orange/widgets/visualize/owlinearprojection.py +++ b/Orange/widgets/visualize/owlinearprojection.py @@ -3,31 +3,33 @@ ------------------------ """ -from itertools import islice, permutations, chain -from math import factorial +from itertools import islice, permutations, chain, combinations +from math import factorial, comb import numpy as np from sklearn.neighbors import NearestNeighbors from sklearn.metrics import r2_score -from AnyQt.QtGui import QStandardItem, QColor -from AnyQt.QtCore import Qt, QRectF, QLineF, pyqtSignal as Signal +from AnyQt.QtGui import QPalette +from AnyQt.QtCore import QRectF, QLineF import pyqtgraph as pg -from Orange.data import Table, Domain +from Orange.data import Table, Domain, IsDefined from Orange.preprocess import Normalize from Orange.preprocess.score import ReliefF, RReliefF from Orange.projection import PCA, LDA, LinearProjector from Orange.util import Enum from Orange.widgets import gui, report -from Orange.widgets.gui import OWComponent from Orange.widgets.settings import Setting, ContextSetting, SettingProvider +from Orange.widgets.utils.localization import pl from Orange.widgets.utils.plot import variables_selection from Orange.widgets.utils.plot.owplotgui import VariableSelectionModel from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.visualize.utils import VizRankDialog +from Orange.widgets.visualize.utils import vizrank +from Orange.widgets.visualize.utils.vizrank import VizRankDialogNAttrs, \ + VizRankMixin from Orange.widgets.visualize.utils.component import OWGraphWithAnchors from Orange.widgets.visualize.utils.plotutils import AnchorItem from Orange.widgets.visualize.utils.widget import OWAnchorProjectionWidget @@ -37,153 +39,66 @@ MAX_LABEL_LEN = 20 -class LinearProjectionVizRank(VizRankDialog, OWComponent): - captionTitle = "Score Plots" - n_attrs = Setting(3) +class LinearProjectionVizRank(VizRankDialogNAttrs): minK = 10 + show_bars = False - attrsSelected = Signal([]) - _AttrRole = next(gui.OrangeUserRole) - - def __init__(self, master): - # Add the spin box for a number of attributes to take into account. - VizRankDialog.__init__(self, master) - OWComponent.__init__(self, master) - - box = gui.hBox(self) - max_n_attrs = len(master.model_selected) - self.n_attrs_spin = gui.spin( - box, self, "n_attrs", 3, max_n_attrs, label="Number of variables: ", - controlWidth=50, alignment=Qt.AlignRight, callback=self._n_attrs_changed) - gui.rubber(box) - self.last_run_n_attrs = None - self.attr_color = master.attr_color - self.attrs = [] - - def initialize(self): - super().initialize() - self.attr_color = self.master.attr_color - - def before_running(self): - """ - Disable the spin for number of attributes before running and - enable afterwards. Also, if the number of attributes is different than - in the last run, reset the saved state (if it was paused). - """ - if self.n_attrs != self.last_run_n_attrs: - self.saved_state = None - self.saved_progress = 0 - if self.saved_state is None: - self.scores = [] - self.rank_model.clear() - self.last_run_n_attrs = self.n_attrs - self.n_attrs_spin.setDisabled(True) - - def stopped(self): - self.n_attrs_spin.setDisabled(False) - - def check_preconditions(self): - master = self.master - if not super().check_preconditions(): - return False - elif not master.btn_vizrank.isEnabled(): - return False - n_cont_var = len([v for v in master.continuous_variables - if v is not master.attr_color]) - self.n_attrs_spin.setMaximum(n_cont_var) - return True - - def state_count(self): - n_all_attrs = len(self.attrs) - if not n_all_attrs: - return 0 - n_attrs = self.n_attrs - return factorial(n_all_attrs) // (2 * factorial(n_all_attrs - n_attrs) * n_attrs) - - def iterate_states(self, state): - if state is None: # on the first call, compute order - self.attrs = self._score_heuristic() - state = list(range(self.n_attrs)) - else: - state = list(state) - - def combinations(n, s): - while True: - yield s - for up, _ in enumerate(s): - s[up] += 1 - if up + 1 == len(s) or s[up] < s[up + 1]: - break - s[up] = up - if s[-1] == n: - break - - for c in combinations(len(self.attrs), state): - for p in islice(permutations(c[1:]), factorial(len(c) - 1) // 2): - yield (c[0], ) + p - - def compute_score(self, state): - master = self.master - data = master.data - domain = Domain([self.attrs[i] for i in state], data.domain.class_vars) - projection = master.projector(data.transform(domain)) - ec = projection(data).X - y = column_data(data, self.attr_color, dtype=float) - if ec.shape[0] < self.minK: - return None - n_neighbors = min(self.minK, len(ec) - 1) - knn = NearestNeighbors(n_neighbors=n_neighbors).fit(ec) - ind = knn.kneighbors(return_distance=False) - # pylint: disable=invalid-unary-operand-type - if self.attr_color.is_discrete: - return -np.sum(y[ind] == y.reshape(-1, 1)) / n_neighbors / len(y) - return -r2_score(y, np.mean(y[ind], axis=1)) * (len(y) / len(data)) - - def bar_length(self, score): - return max(0, -score) - - def _score_heuristic(self): + def score_attributes(self): def normalized(a): span = np.max(a, axis=0) - np.min(a, axis=0) span[span == 0] = 1 return (a - np.mean(a, axis=0)) / span - domain = self.master.data.domain - attr_color = self.master.attr_color domain = Domain( - attributes=[v for v in chain(domain.variables, domain.metas) - if v.is_continuous and v is not attr_color], - class_vars=attr_color + attributes=[v for v in self.attrs if v is not self.attr_color], + class_vars=self.attr_color ) - data = self.master.data.transform(domain) - data.X = normalized(data.X) - relief = ReliefF if attr_color.is_discrete else RReliefF + data = self.data.transform(domain).copy() + with data.unlocked(): + data.X = normalized(data.X) + relief = ReliefF if self.attr_color.is_discrete else RReliefF weights = relief(n_iterations=100, k_nearest=self.minK)(data) - results = sorted(zip(weights, domain.attributes), key=lambda x: (-x[0], x[1].name)) + results = sorted(zip(weights, domain.attributes), + key=lambda x: (-x[0], x[1].name)) return [attr for _, attr in results] - def row_for_state(self, score, state): - attrs = [self.attrs[i] for i in state] - item = QStandardItem(", ".join(a.name for a in attrs)) - item.setData(attrs, self._AttrRole) - return [item] + def state_count(self): + n_all_attrs = self.max_attrs() + if not n_all_attrs: + return 0 + return comb(n_all_attrs, self.n_attrs) * factorial(self.n_attrs - 1) // 2 - def on_selection_changed(self, selected, deselected): - if not selected.indexes(): - return - attrs = selected.indexes()[0].data(self._AttrRole) - self.selectionChanged.emit([attrs]) + def state_generator(self): + return ( + (c[0], *p) + for c in combinations(list(range(len(self.attr_order))), self.n_attrs) + for p in islice(permutations(c[1:]), factorial(len(c) - 1) // 2) + ) - def _n_attrs_changed(self): - if self.n_attrs != self.last_run_n_attrs or self.saved_state is None: - self.button.setText("Start") + def compute_score(self, state): + domain = Domain([self.attr_order[i] for i in state], [self.attr_color]) + reduced = IsDefined()(self.data.transform(domain)) + if len(reduced) < self.minK: # cancel early if not enough data + return None + projection = self.parent().projector(reduced) + ec = projection(reduced).X + if ec.shape[0] < self.minK: # projection preprocessors can remove data(?) + return None + n_neighbors = min(self.minK, len(ec) - 1) + knn = NearestNeighbors(n_neighbors=n_neighbors).fit(ec) + ind = knn.kneighbors(return_distance=False) + y = reduced.get_column(self.attr_color) + if self.attr_color.is_discrete: + score = -np.sum(y[ind] == y.reshape(-1, 1)) / n_neighbors else: - self.button.setText("Continue") - self.button.setEnabled(self.check_preconditions()) + score = -r2_score(y, np.mean(y[ind], axis=1)) + # treat missing data as misclassified + return score * len(reduced) / len(self.data) class OWLinProjGraph(OWGraphWithAnchors): hide_radius = Setting(0) + aggregate_dense_regions = Setting(True) @property def always_show_axes(self): @@ -227,7 +142,7 @@ def update_anchors(self): anchor.setFont(self.parameter_setter.anchor_font) visible = self.always_show_axes or np.linalg.norm(point) > r - anchor.setVisible(visible) + anchor.setVisible(bool(visible)) anchor.setPen(pg.mkPen((100, 100, 100))) self.plot_widget.addItem(anchor) self.anchor_items.append(anchor) @@ -235,7 +150,7 @@ def update_anchors(self): for anchor, point, label in zip(self.anchor_items, points, labels): anchor.setLine(QLineF(0, 0, *point)) visible = self.always_show_axes or np.linalg.norm(point) > r - anchor.setVisible(visible) + anchor.setVisible(bool(visible)) anchor.setFont(self.parameter_setter.anchor_font) def update_circle(self): @@ -252,7 +167,8 @@ def update_circle(self): r = self.scaled_radius * np.max(np.linalg.norm(points, axis=1)) self.circle_item.setRect(QRectF(-r, -r, 2 * r, 2 * r)) - pen = pg.mkPen(QColor(Qt.lightGray), width=1, cosmetic=True) + color = self.plot_widget.palette().color(QPalette.Disabled, QPalette.Text) + pen = pg.mkPen(color, width=1, cosmetic=True) self.circle_item.setPen(pen) @@ -260,13 +176,14 @@ def update_circle(self): qualname="Placement") -class OWLinearProjection(OWAnchorProjectionWidget): +class OWLinearProjection(OWAnchorProjectionWidget, + VizRankMixin(LinearProjectionVizRank)): name = "Linear Projection" description = "A multi-axis projection of data onto " \ "a two-dimensional plane." - icon = "icons/LinearProjection.svg" + icon = "icons/LinearProjection-symbolic.svg" priority = 240 - keywords = [] + keywords = "linear projection" Projection_name = {Placement.Circular: "Circular Placement", Placement.LDA: "Linear Discriminant Analysis", @@ -276,13 +193,16 @@ class OWLinearProjection(OWAnchorProjectionWidget): placement = Setting(Placement.Circular) selected_vars = ContextSetting([]) - vizrank = SettingProvider(LinearProjectionVizRank) GRAPH_CLASS = OWLinProjGraph graph = SettingProvider(OWLinProjGraph) + n_attrs_vizrank = Setting(3) class Error(OWAnchorProjectionWidget.Error): no_cont_features = Msg("Plotting requires numeric features") + class Information(OWAnchorProjectionWidget.Information): + no_lda = Msg("LDA placement is disabled due to unsuitable target.\n{}") + def _add_controls(self): box = gui.vBox(self.controlArea, box="Features") self._add_controls_variables(box) @@ -299,8 +219,9 @@ def _add_controls_variables(self, box): variables_selection(box, self, self.model_selected) self.model_selected.selection_changed.connect( self.__model_selected_changed) - self.vizrank, self.btn_vizrank = LinearProjectionVizRank.add_vizrank( - None, self, "Suggest Features", self.__vizrank_set_attrs) + self.btn_vizrank = self.vizrank_button("Suggest Features") + self.vizrankSelectionChanged.connect(self.vizrank_set_attrs) + self.vizrankRunStateChanged.connect(self.store_vizrank_n_attrs) box.layout().addWidget(self.btn_vizrank) def _add_controls_placement(self, box): @@ -327,43 +248,52 @@ def effective_variables(self): @property def effective_data(self): - return self.data.transform(Domain(self.effective_variables)) + cvs = None + if self.placement == Placement.LDA: + cvs = self.data.domain.class_vars + return self.data.transform(Domain(self.effective_variables, cvs)) - def __vizrank_set_attrs(self, attrs): + def vizrank_set_attrs(self, attrs): if not attrs: return + # False positive, pylint: disable=unsupported-assignment-operation self.selected_vars[:] = attrs # Ugly, but the alternative is to have yet another signal to which # the view will have to connect self.model_selected.selection_changed.emit() + def store_vizrank_n_attrs(self, state, data): + if state == vizrank.RunState.Running: + self.n_attrs_vizrank = data["n_attrs"] + def __model_selected_changed(self): self.projection = None self._check_options() self.init_projection() self.setup_plot() - self.commit() + self.commit.deferred() def __placement_radio_changed(self): self.controls.graph.hide_radius.setEnabled( self.placement != Placement.Circular) self.projection = self.projector = None - self._init_vizrank() + self.init_vizrank() self.init_projection() self.setup_plot() - self.commit() + self.commit.deferred() def __radius_slider_changed(self): self.graph.update_radius() def colors_changed(self): super().colors_changed() - self._init_vizrank() + self.init_vizrank() + @OWAnchorProjectionWidget.Inputs.data def set_data(self, data): super().set_data(data) self._check_options() - self._init_vizrank() + self.init_vizrank() self.init_projection() def _check_options(self): @@ -371,18 +301,33 @@ def _check_options(self): for btn in buttons: btn.setEnabled(True) + problem = None if self.data is not None: - has_discrete_class = self.data.domain.has_discrete_class - if not has_discrete_class or len(np.unique(self.data.Y)) < 3: - buttons[Placement.LDA].setEnabled(False) - if self.placement == Placement.LDA: - self.placement = Placement.Circular + if (class_var := self.data.domain.class_var) is None: + problem = "Current data has no target variable" + elif not class_var.is_discrete: + problem = f"{class_var.name} is not categorical" + elif (nclasses := len(distinct := np.unique(self.data.Y))) == 0: + problem = f"Data has no defined values for {class_var.name}" + elif nclasses < 3: + vals = " and ".join(f"'{class_var.values[int(i)]}'" for i in distinct) + problem = \ + f"Data contains just {['one', 'two'][nclasses - 1]} distinct " \ + f"{pl(nclasses, 'value')} ({vals}) for '{class_var.name}'; " \ + "at least three are required." + if problem is None: + self.Information.no_lda.clear() + else: + self.Information.no_lda(problem) + buttons[Placement.LDA].setEnabled(False) + if self.placement == Placement.LDA: + self.placement = Placement.Circular self.controls.graph.hide_radius.setEnabled( self.placement != Placement.Circular) - def _init_vizrank(self): - is_enabled, msg = False, "" + def init_vizrank(self): + msg = "" if self.data is None: msg = "There is no data." elif self.attr_color is None: @@ -397,13 +342,12 @@ def _init_vizrank(self): msg = "Not enough available continuous variables" elif np.sum(np.all(np.isfinite(self.data.X), axis=1)) < 2: msg = "Not enough valid data instances" + if not msg: + super().init_vizrank( + self.data, self.continuous_variables, self.attr_color, + self.n_attrs_vizrank) else: - is_enabled = not np.isnan(self.data.get_column_view( - self.attr_color)[0].astype(float)).all() - self.btn_vizrank.setToolTip(msg) - self.btn_vizrank.setEnabled(is_enabled) - if is_enabled: - self.vizrank.initialize() + self.disable_vizrank(msg) def check_data(self): def error(err): @@ -514,21 +458,6 @@ def migrate_context(cls, context, version): Placement = Placement -def column_data(table, var, dtype): - dtype = np.dtype(dtype) - col, copy = table.get_column_view(var) - if not isinstance(col.dtype.type, np.inexact): - col = col.astype(float) - copy = True - if dtype != col.dtype: - col = col.astype(dtype) - copy = True - - if not copy: - col = col.copy() - return col - - class CircularPlacement(LinearProjector): def get_components(self, X, Y): # Return circular axes for linear projection diff --git a/Orange/widgets/visualize/owlineplot.py b/Orange/widgets/visualize/owlineplot.py index 7c9ca2a0c27..acfcb724d60 100644 --- a/Orange/widgets/visualize/owlineplot.py +++ b/Orange/widgets/visualize/owlineplot.py @@ -17,7 +17,7 @@ from Orange.data import Table, DiscreteVariable from Orange.data.sql.table import SqlTable -from Orange.statistics.util import countnans, nanmean, nanmin, nanmax, nanstd +from Orange.statistics.util import nanmean, nanmin, nanmax, nanstd from Orange.widgets import gui, report from Orange.widgets.settings import ( Setting, ContextSetting, DomainContextHandler @@ -32,7 +32,7 @@ from Orange.widgets.visualize.owdistributions import LegendItem from Orange.widgets.visualize.utils.customizableplot import Updater, \ CommonParameterSetter -from Orange.widgets.visualize.utils.plotutils import AxisItem +from Orange.widgets.visualize.utils.plotutils import AxisItem, PlotWidget from Orange.widgets.widget import OWWidget, Input, Output, Msg @@ -195,7 +195,9 @@ def reset(self): class ParameterSetter(CommonParameterSetter): MEAN_LABEL = "Mean" LINE_LABEL = "Lines" + MISSING_LINE_LABEL = "Lines (missing value)" SEL_LINE_LABEL = "Selected lines" + SEL_MISSING_LINE_LABEL = "Selected lines (missing value)" RANGE_LABEL = "Range" SEL_RANGE_LABEL = "Selected range" @@ -214,12 +216,24 @@ def update_setters(self): Updater.STYLE_LABEL: Updater.DEFAULT_LINE_STYLE, Updater.ANTIALIAS_LABEL: True, } + self.missing_line_settings = { + Updater.WIDTH_LABEL: LinePlotStyle.UNSELECTED_LINE_WIDTH, + Updater.ALPHA_LABEL: LinePlotStyle.UNSELECTED_LINE_ALPHA, + Updater.STYLE_LABEL: "Dash line", + Updater.ANTIALIAS_LABEL: True, + } self.sel_line_settings = { Updater.WIDTH_LABEL: LinePlotStyle.SELECTED_LINE_WIDTH, Updater.ALPHA_LABEL: LinePlotStyle.SELECTED_LINE_ALPHA, Updater.STYLE_LABEL: Updater.DEFAULT_LINE_STYLE, Updater.ANTIALIAS_LABEL: False, } + self.sel_missing_line_settings = { + Updater.WIDTH_LABEL: LinePlotStyle.SELECTED_LINE_WIDTH, + Updater.ALPHA_LABEL: LinePlotStyle.SELECTED_LINE_ALPHA, + Updater.STYLE_LABEL: "Dash line", + Updater.ANTIALIAS_LABEL: False, + } self.range_settings = { Updater.ALPHA_LABEL: LinePlotStyle.RANGE_ALPHA, } @@ -255,6 +269,15 @@ def update_setters(self): LinePlotStyle.UNSELECTED_LINE_ALPHA), Updater.ANTIALIAS_LABEL: (None, True), }, + self.MISSING_LINE_LABEL: { + Updater.WIDTH_LABEL: (range(1, 15), + LinePlotStyle.UNSELECTED_LINE_WIDTH), + Updater.STYLE_LABEL: (list(Updater.LINE_STYLES), + "Dash line"), + Updater.ALPHA_LABEL: (range(0, 255, 5), + LinePlotStyle.UNSELECTED_LINE_ALPHA), + Updater.ANTIALIAS_LABEL: (None, True), + }, self.SEL_LINE_LABEL: { Updater.WIDTH_LABEL: (range(1, 15), LinePlotStyle.SELECTED_LINE_WIDTH), @@ -264,6 +287,15 @@ def update_setters(self): LinePlotStyle.SELECTED_LINE_ALPHA), Updater.ANTIALIAS_LABEL: (None, False), }, + self.SEL_MISSING_LINE_LABEL: { + Updater.WIDTH_LABEL: (range(1, 15), + LinePlotStyle.SELECTED_LINE_WIDTH), + Updater.STYLE_LABEL: (list(Updater.LINE_STYLES), + "Dash line"), + Updater.ALPHA_LABEL: (range(0, 255, 5), + LinePlotStyle.SELECTED_LINE_ALPHA), + Updater.ANTIALIAS_LABEL: (None, True), + }, self.RANGE_LABEL: { Updater.ALPHA_LABEL: (range(0, 255, 5), LinePlotStyle.RANGE_ALPHA), @@ -283,10 +315,20 @@ def update_lines(**settings): self.line_settings.update(**settings) Updater.update_lines(self.lines_items, **self.line_settings) + def update_missing_lines(**settings): + self.missing_line_settings.update(**settings) + Updater.update_lines(self.missing_lines_items, + **self.missing_line_settings) + def update_sel_lines(**settings): self.sel_line_settings.update(**settings) Updater.update_lines(self.sel_lines_items, **self.sel_line_settings) + def update_sel_missing_lines(**settings): + self.sel_missing_line_settings.update(**settings) + Updater.update_lines(self.sel_missing_lines_items, + **self.sel_missing_line_settings) + def _update_brush(items, **settings): for item in items: brush = item.brush() @@ -306,7 +348,9 @@ def update_sel_range(**settings): self._setters[self.PLOT_BOX] = { self.MEAN_LABEL: update_mean, self.LINE_LABEL: update_lines, + self.MISSING_LINE_LABEL: update_missing_lines, self.SEL_LINE_LABEL: update_sel_lines, + self.SEL_MISSING_LINE_LABEL: update_sel_missing_lines, self.RANGE_LABEL: update_range, self.SEL_RANGE_LABEL: update_sel_range, } @@ -331,11 +375,20 @@ def mean_lines_items(self): def lines_items(self): return [group.profiles for group in self.master.groups] + @property + def missing_lines_items(self): + return [group.missing_profiles for group in self.master.groups] + @property def sel_lines_items(self): return [group.sel_profiles for group in self.master.groups] + \ [group.sub_profiles for group in self.master.groups] + @property + def sel_missing_lines_items(self): + return [group.sel_missing_profiles for group in self.master.groups] + \ + [group.sub_missing_profiles for group in self.master.groups] + @property def range_items(self): return [group.range for group in self.master.groups] @@ -348,8 +401,9 @@ def sel_range_items(self): def getAxis(self): return self.master.getAxis + # Customizable plot widget -class LinePlotGraph(pg.PlotWidget): +class LinePlotGraph(PlotWidget): def __init__(self, parent): self.groups: List[ProfileGroup] = [] self.bottom_axis = BottomAxisItem(orientation="bottom") @@ -357,7 +411,7 @@ def __init__(self, parent): left_axis = AxisItem(orientation="left") left_axis.setLabel("") super().__init__(parent, viewBox=LinePlotViewBox(), - background="w", enableMenu=False, + enableMenu=False, axisItems={"bottom": self.bottom_axis, "left": left_axis}) self.view_box = self.getViewBox() @@ -433,11 +487,11 @@ def __init__(self, data, indices, color, graph): self.color = color self.graph = graph - self.profiles_added = False - self.sub_profiles_added = False - self.range_added = False - self.mean_added = False - self.error_bar_added = False + self._profiles_added = False + self._sub_profiles_added = False + self._range_added = False + self._mean_added = False + self._error_bar_added = False self.graph_items = [] self.__mean = nanmean(self.y_data, axis=0) @@ -445,15 +499,20 @@ def __init__(self, data, indices, color, graph): def __create_curves(self): self.profiles = self._get_profiles_curve() + self.missing_profiles = self._get_missing_profiles_curve() self.sub_profiles = self._get_sel_profiles_curve() + self.sub_missing_profiles = self._get_sel_missing_profiles_curve() self.sel_profiles = self._get_sel_profiles_curve() + self.sel_missing_profiles = self._get_sel_missing_profiles_curve() self.range = self._get_range_curve() self.sel_range = self._get_sel_range_curve() self.mean = self._get_mean_curve() self.error_bar = self._get_error_bar() self.graph_items = [ self.mean, self.range, self.sel_range, self.profiles, - self.sub_profiles, self.sel_profiles, self.error_bar + self.sub_profiles, self.sel_profiles, self.error_bar, + self.missing_profiles, self.sel_missing_profiles, + self.sub_missing_profiles, ] def _get_profiles_curve(self): @@ -463,11 +522,25 @@ def _get_profiles_curve(self): Updater.update_lines([curve], **self.graph.parameter_setter.line_settings) return curve + def _get_missing_profiles_curve(self): + x, y, con = self.__get_disconnected_curve_missing_data(self.y_data) + pen = self.make_pen(self.color) + curve = pg.PlotCurveItem(x=x, y=y, connect=con, pen=pen) + settings = self.graph.parameter_setter.missing_line_settings + Updater.update_lines([curve], **settings) + return curve + def _get_sel_profiles_curve(self): curve = pg.PlotCurveItem(x=None, y=None, pen=self.make_pen(self.color)) Updater.update_lines([curve], **self.graph.parameter_setter.sel_line_settings) return curve + def _get_sel_missing_profiles_curve(self): + curve = pg.PlotCurveItem(x=None, y=None, pen=self.make_pen(self.color)) + settings = self.graph.parameter_setter.sel_missing_line_settings + Updater.update_lines([curve], **settings) + return curve + def _get_range_curve(self): color = QColor(self.color) color.setAlpha(self.graph.parameter_setter.range_settings[Updater.ALPHA_LABEL]) @@ -500,38 +573,44 @@ def remove_items(self): self.graph_items = [] def set_visible_profiles(self, show_profiles=True, show_range=True, **_): - if not self.profiles_added and show_profiles: - self.profiles_added = True + if not self._profiles_added and show_profiles: + self._profiles_added = True self.graph.addItem(self.profiles) + self.graph.addItem(self.missing_profiles) self.graph.addItem(self.sel_profiles) - if not self.sub_profiles_added and (show_profiles or show_range): - self.sub_profiles_added = True + self.graph.addItem(self.sel_missing_profiles) + if not self._sub_profiles_added and (show_profiles or show_range): + self._sub_profiles_added = True self.graph.addItem(self.sub_profiles) + self.graph.addItem(self.sub_missing_profiles) self.profiles.setVisible(show_profiles) + self.missing_profiles.setVisible(show_profiles) self.sel_profiles.setVisible(show_profiles) + self.sel_missing_profiles.setVisible(show_profiles) self.sub_profiles.setVisible(show_profiles or show_range) + self.sub_missing_profiles.setVisible(show_profiles or show_range) def set_visible_range(self, show_profiles=True, show_range=True, **_): - if not self.range_added and show_range: - self.range_added = True + if not self._range_added and show_range: + self._range_added = True self.graph.addItem(self.range) self.graph.addItem(self.sel_range) - if not self.sub_profiles_added and (show_profiles or show_range): - self.sub_profiles_added = True + if not self._sub_profiles_added and (show_profiles or show_range): + self._sub_profiles_added = True self.graph.addItem(self.sub_profiles) self.range.setVisible(show_range) self.sel_range.setVisible(show_range) self.sub_profiles.setVisible(show_profiles or show_range) def set_visible_mean(self, show_mean=True, **_): - if not self.mean_added and show_mean: - self.mean_added = True + if not self._mean_added and show_mean: + self._mean_added = True self.graph.addItem(self.mean) self.mean.setVisible(show_mean) def set_visible_error(self, show_error=True, **_): - if not self.error_bar_added and show_error: - self.error_bar_added = True + if not self._error_bar_added and show_error: + self._error_bar_added = True self.graph.addItem(self.error_bar) self.error_bar.setVisible(show_error) @@ -544,11 +623,24 @@ def update_profiles_color(self, selection): pen.setColor(color) self.profiles.setPen(pen) + color = QColor(self.color) + alpha = self.graph.parameter_setter.missing_line_settings[ + Updater.ALPHA_LABEL] if not selection else \ + LinePlotStyle.UNSELECTED_LINE_ALPHA_SEL + color.setAlpha(alpha) + pen = self.missing_profiles.opts["pen"] + pen.setColor(color) + self.missing_profiles.setPen(pen) + def update_sel_profiles(self, y_data): x, y, connect = self.__get_disconnected_curve_data(y_data) \ if y_data is not None else (None, None, None) self.sel_profiles.setData(x=x, y=y, connect=connect) + x, y, connect = self.__get_disconnected_curve_missing_data(y_data) \ + if y_data is not None else (None, None, None) + self.sel_missing_profiles.setData(x=x, y=y, connect=connect) + def update_sel_profiles_color(self, subset): color = QColor(Qt.black) if subset else QColor(self.color) color.setAlpha(self.graph.parameter_setter.sel_line_settings[Updater.ALPHA_LABEL]) @@ -556,11 +648,23 @@ def update_sel_profiles_color(self, subset): pen.setColor(color) self.sel_profiles.setPen(pen) + color = QColor(Qt.black) if subset else QColor(self.color) + alpha = self.graph.parameter_setter.sel_missing_line_settings[ + Updater.ALPHA_LABEL] + color.setAlpha(alpha) + pen = self.sel_missing_profiles.opts["pen"] + pen.setColor(color) + self.sel_missing_profiles.setPen(pen) + def update_sub_profiles(self, y_data): x, y, connect = self.__get_disconnected_curve_data(y_data) \ if y_data is not None else (None, None, None) self.sub_profiles.setData(x=x, y=y, connect=connect) + x, y, connect = self.__get_disconnected_curve_missing_data(y_data) \ + if y_data is not None else (None, None, None) + self.sub_missing_profiles.setData(x=x, y=y, connect=connect) + def update_sel_range(self, y_data): if y_data is None: curve1 = curve2 = pg.PlotDataItem(x=self.x_data, y=self.__mean) @@ -573,9 +677,24 @@ def update_sel_range(self, y_data): def __get_disconnected_curve_data(y_data): m, n = y_data.shape x = np.arange(m * n) % n + 1 - y = y_data.A.flatten() if sp.issparse(y_data) else y_data.flatten() - connect = np.ones_like(y, bool) - connect[n - 1:: n] = False + y = y_data.toarray().flatten() if sp.issparse(y_data) else y_data.flatten() + connect = ~np.isnan(y_data.toarray() if sp.issparse(y_data) else y_data) + connect[:, -1] = False + connect = connect.flatten() + return x, y, connect + + @staticmethod + def __get_disconnected_curve_missing_data(y_data): + m, n = y_data.shape + x = np.arange(m * n) % n + 1 + y = y_data.toarray().flatten() if sp.issparse(y_data) else y_data.flatten() + connect = np.isnan(y_data.toarray() if sp.issparse(y_data) else y_data) + # disconnect until the first non nan + first_non_nan = np.argmin(connect, axis=1) + for row in np.flatnonzero(first_non_nan): + connect[row, :first_non_nan[row]] = False + connect[:, -1] = False + connect = connect.flatten() return x, y, connect @staticmethod @@ -594,6 +713,7 @@ class OWLinePlot(OWWidget): description = "Visualization of data profiles (e.g., time series)." icon = "icons/LinePlot.svg" priority = 180 + keywords = "line plot" buttons_area_orientation = Qt.Vertical enable_selection = Signal(bool) @@ -616,17 +736,15 @@ class Outputs: selection = Setting(None, schema_only=True) visual_settings = Setting({}, schema_only=True) - graph_name = "graph.plotItem" + graph_name = "graph.plotItem" # QGraphicsScene (pg.PlotWidget -> LinePlotGraph) class Error(OWWidget.Error): not_enough_attrs = Msg("Need at least one numeric feature.") - no_valid_data = Msg("No plot due to no valid data.") class Warning(OWWidget.Warning): no_display_option = Msg("No display option is selected.") class Information(OWWidget.Information): - hidden_instances = Msg("Instances with unknown values are not shown.") too_many_features = Msg("Data has too many features. Only first {}" " are shown.".format(MAX_FEATURES)) @@ -634,7 +752,6 @@ def __init__(self, parent=None): super().__init__(parent) self.__groups = [] self.data = None - self.valid_data = None self.subset_data = None self.subset_indices = None self.__pending_selection = self.selection @@ -714,12 +831,12 @@ def __group_var_changed(self): @check_sql_input def set_data(self, data): self.closeContext() - self.data = data + self.data = data or None self.clear() self.check_data() self.check_display_options() - if self.data is not None: + if self.data: self.group_vars.set_domain(self.data.domain) self.group_view.setEnabled(len(self.group_vars) > 1) self.group_var = self.data.domain.class_var \ @@ -727,7 +844,7 @@ def set_data(self, data): self.openContext(data) self.setup_plot() - self.unconditional_commit() + self.commit.now() def check_data(self): def error(err): @@ -738,14 +855,9 @@ def error(err): if self.data is not None: self.graph_variables = [var for var in self.data.domain.attributes if var.is_continuous] - self.valid_data = ~countnans(self.data.X, axis=1).astype(bool) if len(self.graph_variables) < 1: error(self.Error.not_enough_attrs) - elif not np.sum(self.valid_data): - error(self.Error.no_valid_data) else: - if not np.all(self.valid_data): - self.Information.hidden_instances() if len(self.graph_variables) > MAX_FEATURES: self.Information.too_many_features() self.graph_variables = self.graph_variables[:MAX_FEATURES] @@ -756,7 +868,7 @@ def check_display_options(self): if not (self.show_profiles or self.show_range or self.show_mean): self.Warning.no_display_option() enable = (self.show_profiles or self.show_range) and \ - len(self.data[self.valid_data]) < SEL_MAX_INSTANCES + len(self.data) < SEL_MAX_INSTANCES self.enable_selection.emit(enable) @Inputs.data_subset @@ -776,8 +888,7 @@ def set_subset_ids(self): if self.subset_data is not None else {} self.subset_indices = None if self.data is not None and sub_ids: - self.subset_indices = [x.id for x in self.data[self.valid_data] - if x.id in sub_ids] + self.subset_indices = [x.id for x in self.data if x.id in sub_ids] def setup_plot(self): if self.data is None: @@ -792,15 +903,14 @@ def setup_plot(self): def plot_groups(self): self._remove_groups() - data = self.data[self.valid_data][:, self.graph_variables] + data = self.data[:, self.graph_variables] if self.group_var is None: - self._plot_group(data, np.where(self.valid_data)[0]) + self._plot_group(data, np.arange(len(data))) else: - class_col_data, _ = self.data.get_column_view(self.group_var) + class_col_data = self.data.get_column(self.group_var) for index in range(len(self.group_var.values)): - mask = np.logical_and(class_col_data == index, self.valid_data) - indices = np.flatnonzero(mask) - if not len(indices): + indices = np.flatnonzero(class_col_data == index) + if len(indices) == 0: continue group_data = self.data[indices, self.graph_variables] self._plot_group(group_data, indices, index) @@ -876,7 +986,7 @@ def _update_sub_profiles(self): group.update_sub_profiles(table) def _update_visibility(self, obj_name): - if not len(self.__groups): + if len(self.__groups) == 0: return self._update_profiles_color() self._update_sel_profiles_and_range() @@ -891,15 +1001,13 @@ def apply_selection(self): sel = [i for i in self.__pending_selection if i < len(self.data)] mask = np.zeros(len(self.data), dtype=bool) mask[sel] = True - mask = mask[self.valid_data] self.selection_changed(mask) self.__pending_selection = None def selection_changed(self, mask): if self.data is None: return - # need indices for self.data: mask refers to self.data[self.valid_data] - indices = np.arange(len(self.data))[self.valid_data][mask] + indices = np.arange(len(self.data))[mask] self.graph.select(indices) old = self.selection self.selection = None if self.data and isinstance(self.data, SqlTable)\ @@ -908,8 +1016,9 @@ def selection_changed(self, mask): self._update_profiles_color() self._update_sel_profiles_and_range() self._update_sel_profiles_color() - self.commit() + self.commit.deferred() + @gui.deferred def commit(self): selected = self.data[self.selection] \ if self.data is not None and bool(self.selection) else None @@ -930,7 +1039,6 @@ def sizeHint(self): return QSize(1132, 708) def clear(self): - self.valid_data = None self.selection = None self.__groups = [] self.graph_variables = [] diff --git a/Orange/widgets/visualize/owmosaic.py b/Orange/widgets/visualize/owmosaic.py index a75cd441bb7..0f4b611fe98 100644 --- a/Orange/widgets/visualize/owmosaic.py +++ b/Orange/widgets/visualize/owmosaic.py @@ -1,6 +1,6 @@ from collections import defaultdict from functools import reduce -from itertools import product, chain, repeat +from itertools import product, chain, repeat, combinations from math import sqrt, log from operator import mul, attrgetter @@ -10,7 +10,7 @@ from AnyQt.QtCore import Qt, QSize, pyqtSignal as Signal from AnyQt.QtGui import QColor, QPainter, QPen, QStandardItem from AnyQt.QtWidgets import ( - QGraphicsScene, QGraphicsLineItem, QGraphicsItemGroup) + QGraphicsScene, QGraphicsLineItem, QGraphicsItemGroup, QLabel, QComboBox) from Orange.data import Table, filter, Variable, Domain, DiscreteVariable from Orange.data.sql.table import SqlTable, LARGE_TABLE, DEFAULT_SAMPLE_TIME @@ -19,117 +19,92 @@ from Orange.preprocess.score import ReliefF from Orange.statistics.distribution import get_distribution, get_distributions from Orange.widgets import gui, settings -from Orange.widgets.gui import OWComponent from Orange.widgets.settings import ( - Setting, DomainContextHandler, ContextSetting, SettingProvider) + Setting, DomainContextHandler, ContextSetting) from Orange.widgets.utils import to_html, get_variable_values_sorted from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.visualize.utils import ( - CanvasText, CanvasRectangle, ViewWithPress, VizRankDialog) + CanvasText, CanvasRectangle, ViewWithPress) +from Orange.widgets.visualize.utils import vizrank +from Orange.widgets.visualize.utils.vizrank import ( + VizRankDialogAttrs, VizRankMixin) from Orange.widgets.visualize.utils.plotutils import wrap_legend_items from Orange.widgets.widget import OWWidget, Msg, Input, Output -class MosaicVizRank(VizRankDialog, OWComponent): - """VizRank dialog for Mosaic""" - captionTitle = "Mosaic Ranking" - max_attrs = ContextSetting(6) - +class MosaicVizRank(VizRankDialogAttrs): pairSelected = Signal(Variable, Variable, Variable, Variable) _AttrRole = next(gui.OrangeUserRole) - def __init__(self, master): - """Add the spin box for maximal number of attributes""" - VizRankDialog.__init__(self, master) - OWComponent.__init__(self, master) + def __init__(self, parent, data, attr_color, attr_range_index): + self.attr_range_index = attr_range_index + super().__init__(parent, data, attr_color=attr_color) + self.marginal = {} box = gui.hBox(self) - self.max_attr_combo = gui.comboBox( - box, self, "max_attrs", - label="Number of variables:", orientation=Qt.Horizontal, - items=["one", "two", "three", "four", - "at most two", "at most three", "at most four"], - callback=self.max_attr_changed) + label = QLabel("Score Mosaics with ") + + combo = self.attrs_combo = QComboBox() + combo.addItems( + ["a single variable", "two variables", "three variables", + "four variables", "at most two variables", + "at most three variables", "at most four variables"]) + + def disable_item(i): + enabled = Qt.ItemIsSelectable | Qt.ItemIsEnabled + item = combo.model().item(i) + item.setFlags(item.flags() & ~enabled) + if self.attr_range_index == i: + # select one attribute less, or a pair instead of a single + self.attr_range_index = i - 1 if i else 1 + + if self.attr_color is None: + disable_item(0) # can't do a single attribute with Pearson + if len(data.domain.attributes) < 4: + disable_item(3) # four attributes + if len(data.domain.attributes) < 3: + disable_item(2) # three attributes + + combo.activated.connect(self.on_attrs_changed) + combo.setCurrentIndex(self.attr_range_index) + box.layout().addWidget(label) + box.layout().addWidget(combo) gui.rubber(box) self.layout().addWidget(self.button) - self.attr_ordering = None - self.marginal = {} - self.last_run_max_attr = None - - self.master.attrs_changed_manually.connect(self.on_manual_change) - - def sizeHint(self): - return QSize(400, 512) - - def initialize(self): - """Clear the ordering to trigger recomputation when needed""" - super().initialize() - self.attr_ordering = None - def initialize_keep_ordering(self): - """Initialize triggered by change of coloring""" - super().initialize() + def on_attrs_changed(self): + if self.run_state.state == vizrank.RunState.Running: + self.pause_computation() - def before_running(self): - """ - Disable the spin for maximal number of attributes before running and - enable afterwards. Also, if the number of attributes is different than - in the last run, reset the saved state (if it was paused). - """ - if self.max_attrs != self.last_run_max_attr: - self.saved_state = None - self.saved_progress = 0 - if self.saved_state is None: - self.scores = [] - self.rank_model.clear() - self.compute_attr_order() - self.last_run_max_attr = self.max_attrs - self.max_attr_combo.setDisabled(True) - - def stopped(self): - self.max_attr_combo.setDisabled(False) - - def max_attr_changed(self): - """ - Change the button label when the maximal number of attributes changes. - - The method does not reset anything so the user can still see the - results until actually restarting the search. - """ - if self.max_attrs != self.last_run_max_attr or self.saved_state is None: - self.button.setText("Start") + new_attrs = self.attrs_combo.currentIndex() + if new_attrs == self.attr_range(): + self.set_button_state() else: - self.button.setText("Continue") - self.button.setEnabled(self.check_preconditions()) - - def coloring_changed(self): - item = self.max_attr_combo.model().item(0) - actflags = Qt.ItemIsSelectable | Qt.ItemIsEnabled - if self._compute_class_dists(): - item.setFlags(item.flags() | actflags) + self.set_button_state( + label="Restart with new settings", + enabled=True) + + def prepare_run(self): + super().prepare_run() + data = self.data + if self.attr_color is not None: + self.marginal = get_distribution(data, self.attr_color) + self.marginal.normalize() else: - item.setFlags(item.flags() & ~actflags) - if self.max_attrs == 0: - self.max_attrs = 1 - - self.stop_and_reset(self.initialize_keep_ordering) - - def check_preconditions(self): - """Require at least one variable to allow ranking.""" - self.Information.add_message("no_attributes", "No variables to rank.") - self.Information.no_attributes.clear() - data = self.master.discrete_data - if not super().check_preconditions() or data is None: - return False - if not data.domain.attributes: - self.Information.no_attributes() - return False - return True - - def compute_attr_order(self): + self.marginal = get_distributions(data) + for dist in self.marginal: + dist.normalize() + + def start_computation(self): + if self.attr_range_index != self.attrs_combo.currentIndex(): + self.attr_range_index = self.attrs_combo.currentIndex() + self.set_run_state(vizrank.RunState.Initialized) + super().start_computation() + + def score_attributes(self): """ Order attributes by Relief if there is a target variable. In case of ties or without target, order by name. @@ -140,78 +115,31 @@ def compute_attr_order(self): If `self.attrs` is not `None`, keep the ordering and just add or remove the class as needed. """ - data = self.master.discrete_data - class_var = data.domain.class_var - if not self.attr_ordering: - if class_var is None: - self.attr_ordering = sorted(data.domain, key=attrgetter("name")) - else: - weights = ReliefF(n_iterations=100, k_nearest=10)(data) - attrs = sorted(zip(weights, data.domain.attributes), - key=lambda x: (-x[0], x[1].name)) - self.attr_ordering = [a for _, a in attrs] - - def _compute_class_dists(self): - return self.master.variable_color is not None + if self.attr_color is None: + return sorted(self.data.domain, key=attrgetter("name")) + data = self.data.transform(Domain( + [attr for attr in self.attrs if attr is not self.attr_color], + self.attr_color)) + weights = ReliefF(n_iterations=100, k_nearest=10)(data) + attrs = sorted(zip(weights, data.domain.attributes), + key=lambda x: (-x[0], x[1].name)) + return [a for _, a in attrs] def attr_range(self): - n_attrs = len(self.master.discrete_data.domain.attributes) - mm = 1 if self._compute_class_dists() else 2 - max_attrs = min(n_attrs, [mm, 2, 3, 4, 2, 3, 4][self.max_attrs]) - min_attrs = [mm, 2, 3, 4, mm, mm, mm][self.max_attrs] - return min_attrs, max_attrs + n_attrs = len(self.data.domain.attributes) + mm = 2 if self.attr_color is None else 1 + min_attrs, max_attrs = [ + (mm, mm), (2, 2), (3, 3), (4, 4), + (mm, 2), (mm, 3), (mm, 4)][self.attr_range_index] + return min_attrs, 1 + min(n_attrs, max_attrs) def state_count(self): - """ - Return the number of combinations, starting with a single attribute - if Mosaic is colored by class distributions, and two if by Pearson - """ - n_attrs = len(self.master.discrete_data.domain.attributes) - min_attrs, max_attrs = self.attr_range() - if min_attrs > max_attrs: - return 0 - return sum(comb(n_attrs, k, exact=True) - for k in range(min_attrs, max_attrs + 1)) - - def iterate_states(self, state): - """ - Iterate through all combinations of attributes as ordered by Relief, - starting with a single attribute if Mosaic is colored by class - distributions, and two if by Pearson. - """ - # If we put initialization of `self.attrs` to `initialize`, - # `score_heuristic` would be run on every call to master's `set_data`. - master = self.master - data = master.discrete_data - min_attrs, max_attrs = self.attr_range() - if min_attrs > max_attrs: - return - if state is None: # on the first call, compute order - if self._compute_class_dists(): - self.marginal = get_distribution(data, data.domain.class_var) - self.marginal.normalize() - state = list(range(min_attrs)) - else: - self.marginal = get_distributions(data) - for dist in self.marginal: - dist.normalize() - state = list(range(min_attrs)) - n_attrs = len(data.domain.attributes) - while True: - yield state - # Reset while running; just abort - if self.attr_ordering is None: - break - for up, _ in enumerate(state): - state[up] += 1 - if up + 1 == len(state) or state[up] < state[up + 1]: - break - state[up] = up - if state[-1] == len(self.attr_ordering): - if len(state) < min(max_attrs, n_attrs): - state = list(range(len(state) + 1)) - else: - break + return sum(comb(len(self.attr_order), k, exact=True) + for k in range(*self.attr_range())) + + def state_generator(self): + for n_attrs in range(*self.attr_range()): + yield from combinations(list(range(len(self.attr_order))), n_attrs) def compute_score(self, state): """ @@ -221,15 +149,14 @@ def compute_score(self, state): comparing the expected (prior) and observed class distribution in each cell. Otherwise, compute the independence of the shown attributes. """ - master = self.master - data = master.discrete_data + data = self.data domain = data.domain - attrlist = [self.attr_ordering[i] for i in state] + attrlist = [self.attr_order[i] for i in state] cond_dist = get_conditional_distribution(data, attrlist)[0] n = cond_dist[""] ss = 0 - if self._compute_class_dists(): - class_values = domain.class_var.values + if self.attr_color is not None: + class_values = self.attr_color.values else: class_values = None attr_indices = [domain.index(attr) for attr in attrlist] @@ -259,14 +186,14 @@ def compute_score(self, state): return distributions.chi2.sf(ss, dof) def bar_length(self, score): - return 1 if score == 0 else -log(score, 10) / 50 + return 1 if score == 0 else min(1, -log(score, 10) / 50) def on_selection_changed(self, selected, deselected): if not selected.isEmpty(): attrs = selected.indexes()[0].data(self._AttrRole) self.selectionChanged.emit(attrs + (None, ) * (4 - len(attrs))) - def on_manual_change(self, attrs): + def auto_select(self, attrs): model = self.rank_model self.rank_table.selectionModel().clear() for row in range(model.rowCount()): @@ -276,23 +203,30 @@ def on_manual_change(self, attrs): return def row_for_state(self, score, state): - """The row consists of attributes sorted by name; class is at the - beginning, if present, so it's on the x-axis and not lost somewhere.""" - class_var = self.master.color_data.domain.class_var + # Override the inherited method to put the class (if present) + # to the start of the list, so it's on the x-axis.""" attrs = tuple( - sorted((self.attr_ordering[x] for x in state), - key=lambda attr: (1 - (attr is class_var), attr.name))) + sorted((self.attr_order[x] for x in state), + key=lambda attr: (attr is not self.attr_color, attr.name))) item = QStandardItem(", ".join(a.name for a in attrs)) item.setData(attrs, self._AttrRole) return [item] + def emit_run_state_changed(self): + self.runStateChanged.emit(self.run_state.state, + {"attr_range_index": self.attr_range_index}) + + def closeEvent(self, event): + self.attrs_combo.setCurrentIndex(self.attr_range_index) + super().closeEvent(event) -class OWMosaicDisplay(OWWidget): + +class OWMosaicDisplay(OWWidget, VizRankMixin(MosaicVizRank)): name = "Mosaic Display" description = "Display data in a mosaic plot." icon = "icons/MosaicDisplay.svg" priority = 220 - keywords = [] + keywords = "mosaic display" class Inputs: data = Input("Data", Table, default=True) @@ -303,7 +237,6 @@ class Outputs: annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table) settingsHandler = DomainContextHandler() - vizrank = SettingProvider(MosaicVizRank) settings_version = 2 use_boxes = Setting(True) variable1: Variable = ContextSetting(None) @@ -312,6 +245,7 @@ class Outputs: variable4: Variable = ContextSetting(None) variable_color: DiscreteVariable = ContextSetting(None) selection = Setting(set(), schema_only=True) + vizrank_attr_range_index = Setting(6) BAR_WIDTH = 5 SPACING = 4 @@ -321,9 +255,7 @@ class Outputs: QColor(110, 110, 255), QColor(0, 0, 255)] RED_COLORS = [QColor(255, 255, 255), QColor(255, 200, 200), QColor(255, 100, 100), QColor(255, 0, 0)] - graph_name = "canvas" - - attrs_changed_manually = Signal(list) + graph_name = "canvas" # QGraphicsScene class Warning(OWWidget.Warning): incompatible_subset = Msg("Data subset is incompatible with Data") @@ -367,8 +299,9 @@ def __init__(self): callback=self.attr_changed, model=self.model_1 if i == 1 else self.model_234) for i in range(1, 5)] - self.vizrank, self.vizrank_button = MosaicVizRank.add_vizrank( - box, self, "Find Informative Mosaics", self.set_attr) + box.layout().addWidget(self.vizrank_button("Find Informative Mosaics")) + self.vizrankSelectionChanged.connect(self.set_attr) + self.vizrankRunStateChanged.connect(self.store_vizrank_attr_range) box2 = gui.vBox(self.controlArea, box="Interior Coloring") self.color_model = DomainModel( @@ -432,13 +365,17 @@ def get_disc_attr_list(self): self.variable3, self.variable4) if var] - def set_attr(self, *attrs): + def set_attr(self, attrs): self.variable1, self.variable2, self.variable3, self.variable4 = [ attr and self.data.domain[attr.name] for attr in attrs] self.reset_graph() + def store_vizrank_attr_range(self, state, data): + if state == vizrank.RunState.Running: + self.vizrank_attr_range_index = data["attr_range_index"] + def attr_changed(self): - self.attrs_changed_manually.emit(self.get_disc_attr_list()) + self.vizrankAutoSelect.emit(self.get_disc_attr_list()) self.reset_graph() def resizeEvent(self, e): @@ -451,17 +388,12 @@ def showEvent(self, ev): @Inputs.data def set_data(self, data): - if isinstance(data, SqlTable) and data.approx_len() > LARGE_TABLE: + if isinstance(data, SqlTable) and len(data) > LARGE_TABLE: data = data.sample_time(DEFAULT_SAMPLE_TIME) self.closeContext() self.data = data - self.vizrank.stop_and_reset() - self.vizrank_button.setEnabled( - self.data is not None and len(self.data) > 1 - and len(self.data.domain.attributes) >= 1) - if self.data is None: self.discrete_data = None self.init_combos(None) @@ -470,6 +402,22 @@ def set_data(self, data): self.init_combos(self.data) self.openContext(self.data) + def init_vizrank(self): + if self.discrete_data is not None and len(self.discrete_data) > 1 \ + and len(self.discrete_data.domain.attributes) >= 2: + attr_range_index = self.vizrank_attr_range_index + if self.variable_color is None: + if attr_range_index == 0: + attr_range_index = 1 + attr_color = None + else: + attr_color = self.discrete_data.domain[self.variable_color.name] + super().init_vizrank( + self.discrete_data, attr_color=attr_color, + attr_range_index=attr_range_index) + else: + self.disable_vizrank("Not enough data") + @Inputs.data_subset def set_subset_data(self, data): self.subset_data = data @@ -501,10 +449,6 @@ def clear_selection(self): self.update_selection_rects() self.send_selection() - def coloring_changed(self): - self.vizrank.coloring_changed() - self.update_graph() - def reset_graph(self): self.clear_selection() self.update_graph() @@ -517,9 +461,8 @@ def set_color_data(self): domain = Domain(attrs, self.variable_color, None) self.color_data = self.data.from_table(domain, self.data) self.discrete_data = self._get_discrete_data(self.color_data) - self.vizrank.stop_and_reset() - self.vizrank_button.setEnabled(True) - self.coloring_changed() + self.init_vizrank() + self.update_graph() def update_selection_rects(self): pens = (QPen(), QPen(Qt.black, 3, Qt.DotLine)) @@ -568,7 +511,7 @@ def send_selection(self): create_annotated_table(self.data, sel_idx)) def send_report(self): - self.report_plot(self.canvas) + self.report_plot() def update_graph(self): spacing = self.SPACING diff --git a/Orange/widgets/visualize/ownomogram.py b/Orange/widgets/visualize/ownomogram.py index c0c5c67ea6f..81188cc65e3 100644 --- a/Orange/widgets/visualize/ownomogram.py +++ b/Orange/widgets/visualize/ownomogram.py @@ -9,16 +9,21 @@ import numpy as np from AnyQt.QtWidgets import ( - QGraphicsView, QGraphicsScene, QGraphicsItem, QGraphicsSimpleTextItem, + QGraphicsView, QGraphicsScene, QGraphicsItem, QGraphicsTextItem, QGraphicsLineItem, QGraphicsWidget, QGraphicsRectItem, QGraphicsEllipseItem, QGraphicsLinearLayout, QGridLayout, QLabel, QFrame, - QSizePolicy + QSizePolicy, QFormLayout ) -from AnyQt.QtGui import QColor, QPainter, QFont, QPen, QBrush +from AnyQt.QtGui import QColor, QPainter, QFont, QPen, QBrush, QFontMetrics, \ + QPalette from AnyQt.QtCore import Qt, QRectF, QSize, QPropertyAnimation, QObject, \ - pyqtProperty + pyqtProperty, QEvent -from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable +from orangewidget.io import ClipboardFormat +from orangewidget.utils import saveplot + +from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable, \ + Variable from Orange.statistics.util import nanmin, nanmax, nanmean, unique from Orange.classification import Model from Orange.classification.naive_bayes import NaiveBayesModel @@ -40,7 +45,7 @@ class SortBy(IntEnum): @staticmethod def items(): - return ["No sorting", "Name", "Absolute importance", + return ["Original order", "Alphabetically", "Absolute importance", "Positive influence", "Negative influence"] @@ -303,7 +308,6 @@ def clear(self): self.__items = [] - class ContinuousItemMixin: def get_tooltip_text(self): return self.TOOLTIP_TEMPLATE.format( @@ -365,6 +369,13 @@ def _show_horizontal_line(self): self.horizontal_line.setVisible(True) +def _line(x0, y0, x1, y1, parent): + foreground = parent.palette().color(QPalette.WindowText) + line = QGraphicsLineItem(x0, y0, x1, y1, parent) + line.setPen(foreground) + return line + + class RulerItem(QGraphicsWidget): tick_height = 6 tick_width = 0 @@ -389,21 +400,19 @@ def __init__(self, name, values, scale, name_offset, offset, labels=None): values[-1]) self.dot.setParentItem(self) - # pylint: disable=unused-variable - # line - line = QGraphicsLineItem(min(values) * scale + offset, 0, - max(values) * scale + offset, 0, - self) + _line(min(values) * scale + offset, 0, + max(values) * scale + offset, 0, + self) if labels is None: labels = [str(abs(v) if v == -0 else v) for v in values] old_x_tick = None shown_items = [] - w = QGraphicsSimpleTextItem(labels[0]).boundingRect().width() + w = QGraphicsTextItem(labels[0]).boundingRect().width() text_finish = values[0] * scale - w + offset - 10 for i, (label, value) in enumerate(zip(labels, values)): - text = QGraphicsSimpleTextItem(label) + text = QGraphicsTextItem(label) x_text = value * scale - text.boundingRect().width() / 2 + offset if text_finish > x_text - 10: y_text, y_tick = self.DOT_RADIUS * 0.7, 0 @@ -421,12 +430,12 @@ def __init__(self, name, values, scale, name_offset, offset, labels=None): tick = QGraphicsRectItem( x_tick, y_tick, self.tick_width, self.tick_height, self) - tick.setBrush(QColor(Qt.black)) + foreground = self.palette().color(QPalette.WindowText) + tick.setBrush(foreground) if self.half_tick_height and i: x = x_tick - (x_tick - old_x_tick) / 2 - half_tick = QGraphicsLineItem(x, - self.half_tick_height, x, 0, - self) + _line(x, - self.half_tick_height, x, 0, self) # half_tick old_x_tick = x_tick @@ -461,22 +470,19 @@ def __init__(self, name, values, scale, name_offset, offset, get_points, self.dot.setPos(0, (- self.DOT_RADIUS + self.y_diff) / 2) self.dot.setParentItem(self) - # pylint: disable=unused-variable - # two lines - t_line = QGraphicsLineItem(self.min_val * scale + offset, 0, - self.max_val * scale + offset, 0, - self) - p_line = QGraphicsLineItem(self.min_val * scale + offset, self.y_diff, - self.max_val * scale + offset, self.y_diff, - self) + _line(self.min_val * scale + offset, 0, + self.max_val * scale + offset, 0, + self) # t_line + _line(self.min_val * scale + offset, self.y_diff, + self.max_val * scale + offset, self.y_diff, + self) # p_line # ticks and labels old_x_tick = values[0] * scale + offset for i, value in enumerate(values[1:]): x_tick = value * scale + offset x = x_tick - (x_tick - old_x_tick) / 2 - half_tick = QGraphicsLineItem(x, - self.tick_height / 2, x, 0, - self) + _line(x, - self.tick_height / 2, x, 0, self) # half tick old_x_tick = x_tick if i == len(values) - 2: break @@ -485,12 +491,11 @@ def __init__(self, name, values, scale, name_offset, offset, get_points, x_text = value * scale - text.boundingRect().width() / 2 + offset y_text = - text.boundingRect().height() - self.DOT_RADIUS * 0.7 text.setPos(x_text, y_text) - tick = QGraphicsLineItem(x_tick, -self.tick_height, x_tick, 0, - self) + _line(x_tick, -self.tick_height, x_tick, 0, self) # tick self.prob_items = [ (i / 10, QGraphicsTextItem(" " + str(i * 10) + " "), - QGraphicsLineItem(0, 0, 0, 0)) for i in range(1, 10)] + _line(0, 0, 0, 0, self)) for i in range(1, 10)] def rescale(self): shown_items = [] @@ -585,11 +590,11 @@ def __init__(self, name, _, data_extremes, values, scale, name_offset, ascending = data_start < data_stop y_start, y_stop = (self.y_diff, 0) if ascending else (0, self.y_diff) for i in range(self.n_tck): - text = QGraphicsSimpleTextItem(labels[i], self) + text = QGraphicsTextItem(labels[i], self) w = text.boundingRect().width() y = y_start + (y_stop - y_start) / (self.n_tck - 1) * i text.setPos(-5 - w, y - 8) - tick = QGraphicsLineItem(-2, y, 2, y, self) + _line(-2, y, 2, y, self) # tick # prediction marker self.dot = Continuous2DMovableDotItem( @@ -597,19 +602,17 @@ def __init__(self, name, _, data_extremes, values, scale, name_offset, self.dot.tooltip_labels = labels self.dot.tooltip_values = values self.dot.setParentItem(self) - h_line = QGraphicsLineItem(values[0] * scale + offset, self.y_diff / 2, - values[-1] * scale + offset, self.y_diff / 2, - self) + h_line = _line(values[0] * scale + offset, self.y_diff / 2, + values[-1] * scale + offset, self.y_diff / 2, + self) pen = QPen(Qt.DashLine) pen.setBrush(QColor(Qt.red)) h_line.setPen(pen) self.dot.horizontal_line = h_line - # pylint: disable=unused-variable - # line - line = QGraphicsLineItem(values[0] * scale + offset, y_start, - values[-1] * scale + offset, y_stop, - self) + _line(values[0] * scale + offset, y_start, + values[-1] * scale + offset, y_stop, + self) # ticks for value in values: @@ -651,7 +654,7 @@ class OWNomogram(OWWidget): " and Logistic Regression Classifiers." icon = "icons/Nomogram.svg" priority = 2000 - keywords = [] + keywords = "nomogram" class Inputs: classifier = Input("Classifier", Model) @@ -674,7 +677,11 @@ class Outputs: sort_index = Setting(SortBy.ABSOLUTE) cont_feature_dim_index = Setting(0) - graph_name = "scene" + # This is defined so that base widget shows the button for saving graph + # and connects the shortcut for copying to clipboard. The value itself + # is not used because send_report, save_graph and copy_to_clipboard are + # overridden. + graph_name = "scene" # QGraphicsScene class Error(OWWidget.Error): invalid_classifier = Msg("Nomogram accepts only Naive Bayes and " @@ -704,45 +711,77 @@ def __init__(self): self.repaint = False # GUI - box = gui.vBox(self.controlArea, "Target class") + lab_align = QFormLayout().labelAlignment() + + grid = QGridLayout() + grid.setColumnStretch(1, 1) + gui.widgetBox(self.controlArea, True, orientation=grid) self.class_combo = gui.comboBox( - box, self, "target_class_index", callback=self._class_combo_changed, - contentsLength=12, searchable=True) + None, self, "target_class_index", + callback=self._class_combo_changed, + sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed), + searchable=True) + grid.addWidget(QLabel("Target class: "), 0, 0, lab_align) + grid.addWidget(self.class_combo, 0, 1) + self.norm_check = gui.checkBox( - box, self, "normalize_probabilities", "Normalize probabilities", + None, self, "normalize_probabilities", "Normalize probabilities", hidden=True, callback=self.update_scene, tooltip="For multiclass data 1 vs. all probabilities do not" " sum to 1 and therefore could be normalized.") + self.norm_check.setStyleSheet("margin-bottom: 12px") + grid.addWidget(self.norm_check, 1, 1) - self.scale_radio = gui.radioButtons( - self.controlArea, self, "scale", ["Point scale", "Log odds ratios"], - box="Scale", callback=self.update_scene) + group = gui.radioButtons( + None, self, "scale", callback=self.update_scene) + grid.addWidget(QLabel("Scale: "), 2, 0, lab_align) + grid.addWidget(gui.appendRadioButton( + group, "Point scale", addToLayout=False), 2, 1) + grid.addWidget(gui.appendRadioButton( + group, "Log odds ratios", addToLayout=False), 3, 1) - box = gui.vBox(self.controlArea, "Display features") grid = QGridLayout() - radio_group = gui.radioButtonsInBox( - box, self, "display_index", [], orientation=grid, - callback=self.update_scene) + gui.widgetBox(self.controlArea, "Displayed features", orientation=grid) + + self.sort_combo = gui.comboBox( + None, self, "sort_index", items=SortBy.items(), + callback=self.update_scene, + tooltips=[ + "Do not sort features, display them in original order", + "Sort features alphabetically by name", + "Sort features by absolute importance", + "Sort features by positive influence on the class", + "Sort features by negative influence on the class" + ], + sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + ) + grid.addWidget(QLabel("Order: "), 0, 0, lab_align) + grid.addWidget(self.sort_combo, 0, 1, 1, 2) + + radio_group = gui.radioButtons( + None, self, "display_index", callback=self.update_scene) radio_all = gui.appendRadioButton( - radio_group, "All", addToLayout=False) + radio_group, "All features", addToLayout=False) radio_best = gui.appendRadioButton( radio_group, "Best ranked:", addToLayout=False) - spin_box = gui.hBox(None, margin=0) self.n_spin = gui.spin( - spin_box, self, "n_attributes", 1, self.MAX_N_ATTRS, label=" ", - controlWidth=60, callback=self._n_spin_changed) - grid.addWidget(radio_all, 1, 1) + None, self, "n_attributes", 1, self.MAX_N_ATTRS, + callback=self._n_spin_changed, alignment=Qt.AlignRight, + sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + ) + grid.addWidget(QLabel("Show: "), 1, 0, lab_align) + grid.addWidget(radio_all, 1, 1, 1, 2) grid.addWidget(radio_best, 2, 1) - grid.addWidget(spin_box, 2, 2) - - self.sort_combo = gui.comboBox( - box, self, "sort_index", label="Rank by:", items=SortBy.items(), - orientation=Qt.Horizontal, callback=self.update_scene) + grid.addWidget(self.n_spin, 2, 2, Qt.AlignLeft) self.cont_feature_dim_combo = gui.comboBox( - box, self, "cont_feature_dim_index", label="Numeric features: ", + None, self, "cont_feature_dim_index", label="Numeric features:", items=["1D projection", "2D curve"], orientation=Qt.Horizontal, - callback=self.update_scene) + callback=self.update_scene, + sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)) + grid.setRowMinimumHeight(3, 12) + grid.addWidget(self.cont_feature_dim_combo.box, 4, 0, 1, 3) + gui.rubber(self.controlArea) @@ -765,8 +804,7 @@ def __init__(self, scene, parent, **kwargs): class GraphicsView(_GraphicsView): def __init__(self, scene, parent): super().__init__(scene, parent, - verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, - styleSheet='QGraphicsView {background: white}') + verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn) self.viewport().setMinimumWidth(300) # XXX: This prevents some tests failing self._is_resizing = False @@ -784,7 +822,7 @@ def is_resizing(self): return self._is_resizing def sizeHint(self): - return QSize(400, 200) + return QSize(500, 200) class FixedSizeGraphicsView(_GraphicsView): def __init__(self, scene, parent): @@ -849,10 +887,18 @@ def update_controls(self): self.cont_feature_dim_combo.setEnabled(False) self.cont_feature_dim_index = 0 model = self.sort_combo.model() - item = model.item(SortBy.POSITIVE) - item.setFlags(item.flags() | Qt.ItemIsEnabled) - item = model.item(SortBy.NEGATIVE) - item.setFlags(item.flags() | Qt.ItemIsEnabled) + + is_logistic = isinstance(self.classifier, LogisticRegressionClassifier) + inapplicable = (SortBy.POSITIVE, SortBy.NEGATIVE) + if is_logistic and self.sort_index in inapplicable: + self.sort_index = SortBy.ABSOLUTE + for idx in inapplicable: + item = model.item(idx) + if is_logistic: + item.setFlags(item.flags() & ~Qt.ItemIsEnabled) + else: + item.setFlags(item.flags() | Qt.ItemIsEnabled) + self.align = OWNomogram.ALIGN_ZERO if self.classifier and isinstance(self.classifier, LogisticRegressionClassifier): @@ -910,8 +956,7 @@ def calculate_log_reg_coefficients(self): if not isinstance(self.classifier, LogisticRegressionClassifier): return - self.domain = self.reconstruct_domain(self.classifier.original_domain, - self.domain) + self.domain = self.reconstruct_domain(self.classifier, self.domain) self.data = self.classifier.original_data.transform(self.domain) attrs, ranges, start = self.domain.attributes, [], 0 for attr in attrs: @@ -936,10 +981,15 @@ def calculate_log_reg_coefficients(self): coef = self.log_reg_coeffs[i] self.log_reg_coeffs[i] = np.hstack((coef * min_t, coef * max_t)) self.log_reg_cont_data_extremes.append( - [sorted([min_t, max_t], reverse=(c < 0)) for c in coef.flat]) + [sorted([min_t, max_t], reverse=bool(c < 0)) for c in coef.flat]) else: self.log_reg_cont_data_extremes.append([None]) + def changeEvent(self, event): + if event.type() == QEvent.PaletteChange: + self.update_scene() + super().changeEvent(event) + def update_scene(self): self.clear_scene() if self.domain is None or not len(self.points[0]): @@ -950,11 +1000,20 @@ def update_scene(self): attr_inds, attributes = zip(*self.get_ordered_attributes()[:n_attrs]) self.Outputs.features.send(AttributeList(attributes)) - name_items = [QGraphicsTextItem(attr.name) for attr in attributes] point_text = QGraphicsTextItem("Points") + metric = QFontMetrics(point_text.font()) + + def text_item(text): + elided_text = metric.elidedText(text, Qt.ElideRight, 200) + item = QGraphicsTextItem(elided_text) + item.setToolTip(text) + return item + + name_items = [text_item(attr.name) for attr in attributes] + probs_text = QGraphicsTextItem("Probabilities (%)") all_items = name_items + [point_text, probs_text] - name_offset = -max(t.boundingRect().width() for t in all_items) - 10 + name_offset = -max(t.boundingRect().width() for t in all_items) - 30 w = self.view.viewport().rect().width() max_width = w + name_offset - 30 @@ -1007,7 +1066,7 @@ def update_scene(self): # Clip top and bottom (60 and 150) parts from the main view self.view.setSceneRect(rect.x(), rect.y() + 80, rect.width() - 10, rect.height() - 160) - self.view.viewport().setMaximumHeight(rect.height() - 160) + self.view.viewport().setMaximumHeight(int(rect.height() - 160)) # Clip main part from top/bottom views # below point values are imprecise (less/more than required) but this # is not a problem due to clipped scene content still being drawn @@ -1029,7 +1088,7 @@ def offset(name, point): names = list(chain.from_iterable( [_get_labels(a, lr and lr[i] and lr[i][0] and lr[i][cls_index], OWNomogram.get_ruler_values(p.min(), p.max(), - scale * p.ptp(), False)) + scale * np.ptp(p), False)) for i, a, p in zip(attr_inds, attributes, points)])) points = list(chain.from_iterable(points)) @@ -1074,7 +1133,7 @@ def create_main_nomogram(self, attributes, attr_inds, name_items, points, name_item, attr, self.log_reg_cont_data_extremes[i][cls_index], self.get_ruler_values( point.min(), point.max(), - scale_x * point.ptp(), False), + scale_x * np.ptp(point), False), scale_x, name_offset, - scale_x * min_p) for i, attr, name_item, point in zip(attr_inds, attributes, name_items, points)] @@ -1085,7 +1144,8 @@ def create_main_nomogram(self, attributes, attr_inds, name_items, points, x = - scale_x * min_p y = self.nomogram_main.layout().preferredHeight() + 10 self.vertical_line = QGraphicsLineItem(x, -6, x, y) - self.vertical_line.setPen(QPen(Qt.DotLine)) + foreground = self.palette().color(QPalette.WindowText) + self.vertical_line.setPen(QPen(foreground, 1, Qt.DotLine)) self.vertical_line.setParentItem(point_item) self.hidden_vertical_line = QGraphicsLineItem(x, -6, x, y) pen = QPen(Qt.DashLine) @@ -1118,7 +1178,7 @@ def key(x): def key(x): i, attr = x if attr.is_discrete: - ptp = self.points[i][class_value].ptp() + ptp = np.ptp(self.points[i][class_value]) else: coef = np.abs(self.log_reg_coeffs_orig[i][class_value]).mean() ptp = coef * np.ptp(self.log_reg_cont_data_extremes[i][class_value]) @@ -1269,22 +1329,50 @@ def clear_scene(self): self.dot_animator.clear() self.scene.clear() + def get_nomogram_view(self): + view = QGraphicsView(self.scene, self) + scene_rect = self.scene.itemsBoundingRect() + view.setSceneRect(scene_rect) + view.resize(scene_rect.size().toSize()) + return view + + def copy_to_clipboard(self): + ClipboardFormat.write_image(None, self.get_nomogram_view()) + + def save_graph(self): + saveplot.save_plot(self.get_nomogram_view(), self.graph_writers) + def send_report(self): - self.report_plot() + # self.report_plot(name="", plot=self.get_nomogram_view()) + # would work, but the resulting nomogram is too small + # The drawback of the below is that the space between top_view and view + self.report_plot(name="", plot=self.top_view) + self.report_plot(name="", plot=self.view) + self.report_plot(name="", plot=self.bottom_view) @staticmethod - def reconstruct_domain(original, preprocessed): + def reconstruct_domain(classifier: Model, preprocessed: Domain) -> Domain: # abuse dict to make "in" comparisons faster + original = classifier.original_domain attrs = OrderedDict() for attr in preprocessed.attributes: cv = attr._compute_value.variable._compute_value - var = cv.variable if cv else original[attr.name] + if cv and isinstance(getattr(cv, "variable", None), Variable): + var = cv.variable + else: + var = original[attr.name] var = original[var.name] if var.name in original else attr if var in attrs: # the reason for OrderedDict continue attrs[var] = None # we only need keys attrs = list(attrs.keys()) - return Domain(attrs, original.class_var, original.metas) + + orig_clv = original.class_var + orig_data = classifier.original_data + values = (orig_clv.values[int(i)] + for i in np.unique(orig_data.get_column(orig_clv))) + class_var = DiscreteVariable(original.class_var.name, values) + return Domain(attrs, class_var, original.metas) @staticmethod def get_ruler_values(start, stop, max_width, round_to_nearest=True): @@ -1337,7 +1425,8 @@ def reset_settings(self): if __name__ == "__main__": # pragma: no cover - from Orange.classification import NaiveBayesLearner #, LogisticRegressionLearner + # pylint: disable=import-outside-toplevel, unused-import + from Orange.classification import NaiveBayesLearner, LogisticRegressionLearner data = Table("heart_disease") clf = NaiveBayesLearner()(data) # clf = LogisticRegressionLearner()(data) diff --git a/Orange/widgets/visualize/owpythagorastree.py b/Orange/widgets/visualize/owpythagorastree.py index 0d68549b797..c6149f44514 100644 --- a/Orange/widgets/visualize/owpythagorastree.py +++ b/Orange/widgets/visualize/owpythagorastree.py @@ -28,8 +28,8 @@ ) from Orange.widgets.visualize.utils.scene import \ UpdateItemsOnSelectGraphicsScene -from Orange.widgets.visualize.utils.tree.skltreeadapter import SklTreeAdapter -from Orange.widgets.visualize.utils.tree.treeadapter import TreeAdapter +from Orange.utils.tree.skltreeadapter import SklTreeAdapter +from Orange.utils.tree.treeadapter import TreeAdapter from Orange.widgets.visualize.utils.view import ( PannableGraphicsView, ZoomableGraphicsView, @@ -39,10 +39,10 @@ class OWPythagorasTree(OWWidget): - name = 'Pythagorean Tree' + name = "Pythagorean Tree" description = 'Pythagorean Tree visualization for tree like-structures.' - icon = 'icons/PythagoreanTree.svg' - keywords = ["fractal"] + icon = 'icons/PythagoreanTree-symbolic.svg' + keywords = "pythagorean tree, fractal" priority = 1000 @@ -54,7 +54,7 @@ class Outputs: annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table) # Enable the save as feature - graph_name = 'scene' + graph_name = 'scene' # QGraphicsScene (TreeGraphicsScene) # Settings settingsHandler = settings.ClassValuesContextHandler() @@ -96,9 +96,11 @@ def __init__(self): # Display settings area box_display = gui.widgetBox(self.controlArea, 'Display Settings') + # maxValue is set to a wide three-digit number to probably ensure the + # proper label width. The maximum is later set to match the tree depth self.depth_slider = gui.hSlider( box_display, self, 'depth_limit', label='Depth', ticks=False, - callback=self.update_depth) + maxValue=900, callback=self.update_depth) self.target_class_combo = gui.comboBox( box_display, self, 'target_class_index', label='Target class', orientation=Qt.Horizontal, items=[], contentsLength=8, diff --git a/Orange/widgets/visualize/owpythagoreanforest.py b/Orange/widgets/visualize/owpythagoreanforest.py index a1a5ff4d003..ced3ec940a0 100644 --- a/Orange/widgets/visualize/owpythagoreanforest.py +++ b/Orange/widgets/visualize/owpythagoreanforest.py @@ -3,11 +3,13 @@ from typing import Any, Callable, Optional from AnyQt.QtCore import Qt, QRectF, QSize, QPointF, QSizeF, QModelIndex, \ - QItemSelection, QItemSelectionModel, QT_VERSION + QItemSelection, QItemSelectionModel, QT_VERSION, QByteArray, QBuffer, \ + QIODevice from AnyQt.QtGui import QPainter, QPen, QColor, QBrush, QMouseEvent from AnyQt.QtWidgets import QSizePolicy, QGraphicsScene, QLabel, QSlider, \ QListView, QStyledItemDelegate, QStyleOptionViewItem, QStyle +from orangewidget.io import PngFormat from Orange.base import RandomForestModel, TreeModel from Orange.data import Table from Orange.widgets import gui, settings @@ -18,11 +20,57 @@ PythagorasTreeViewer, ContinuousTreeNode, ) -from Orange.widgets.visualize.utils.tree.skltreeadapter import \ - SklTreeAdapter +from Orange.utils.tree.skltreeadapter import SklTreeAdapter from Orange.widgets.widget import OWWidget +REPORT_STYLE = """ + +""" + + class PythagoreanForestModel(PyListModel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -137,7 +185,7 @@ def paint(self, painter, option, index): # be painted in the centre of the rect offset_w = (option.rect.width() - scene_w) / 2 offset_h = (option.rect.height() - scene_h) / 2 - offset = option.rect.topLeft() + QPointF(offset_w, offset_h) + offset = QPointF(option.rect.topLeft()) + QPointF(offset_w, offset_h) # Finally, we have all the data for the new rect in which to render target_rect = QRectF(offset, QSizeF(scene_w, scene_h)) @@ -156,11 +204,11 @@ def mousePressEvent(self, event): class OWPythagoreanForest(OWWidget): - name = 'Pythagorean Forest' + name = "Pythagorean Forest" description = 'Pythagorean forest for visualising random forests.' - icon = 'icons/PythagoreanForest.svg' + icon = 'icons/PythagoreanForest-symbolic.svg' settings_version = 2 - keywords = ["fractal"] + keywords = "pythagorean forest, fractal" priority = 1001 @@ -170,9 +218,6 @@ class Inputs: class Outputs: tree = Output("Tree", TreeModel) - # Enable the save as feature - graph_name = 'scene' - # Settings settingsHandler = settings.ClassValuesContextHandler() @@ -213,8 +258,11 @@ def __init__(self): # Display controls area box_display = gui.widgetBox(self.controlArea, 'Display') + # maxValue is set to a wide three-digit number to probably ensure the + # proper label width. The maximum is later set to match the tree depth self.ui_depth_slider = gui.hSlider( box_display, self, 'depth_limit', label='Depth', ticks=False, + maxValue=900 ) # type: QSlider self.ui_target_class_combo = gui.comboBox( box_display, self, 'target_class_index', label='Target class', @@ -374,7 +422,30 @@ def commit(self, selection: QItemSelection) -> None: def send_report(self): """Send report.""" - self.report_plot() + model = self.forest_model + max_rows = 30 + + def item_html(row): + img_data = model.data(model.index(row)) + byte_array = QByteArray() + filename = QBuffer(byte_array) + filename.open(QIODevice.WriteOnly) + PngFormat.write(filename, img_data) + img_encoded = byte_array.toBase64().data().decode("utf-8") + return f'' + + html = ["

      "] + for i in range(model.rowCount())[:max_rows]: + html.append("
      ") + html.extend(item_html(i)) + html.append("
      ") + html.append("
      ") + + html = REPORT_STYLE + "".join(html) + if model.rowCount() > max_rows: + html += "

      . . .

      " + self.report_raw(html) class SklRandomForestAdapter: diff --git a/Orange/widgets/visualize/owradviz.py b/Orange/widgets/visualize/owradviz.py index 37235c06793..2a923bc28ed 100644 --- a/Orange/widgets/visualize/owradviz.py +++ b/Orange/widgets/visualize/owradviz.py @@ -1,5 +1,5 @@ -from itertools import islice, permutations, chain -from math import factorial +from itertools import islice, permutations, chain, combinations +from math import factorial, comb import warnings import numpy as np @@ -7,22 +7,23 @@ from sklearn.model_selection import cross_val_score from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor -from AnyQt.QtGui import QStandardItem, QColor -from AnyQt.QtCore import Qt, QRectF, QPoint, pyqtSignal as Signal +from AnyQt.QtGui import QColor, QPalette +from AnyQt.QtCore import Qt, QRectF, QPoint import pyqtgraph as pg from pyqtgraph.graphicsItems.ScatterPlotItem import ScatterPlotItem -from Orange.data import Table, Domain +from Orange.data import Table, Domain, IsDefined from Orange.preprocess.score import ReliefF, RReliefF from Orange.projection import RadViz from Orange.widgets import widget, gui -from Orange.widgets.gui import OWComponent from Orange.widgets.settings import Setting, ContextSetting, SettingProvider from Orange.widgets.utils.plot.owplotgui import VariableSelectionModel, \ variables_selection from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.visualize.utils import VizRankDialog +from Orange.widgets.visualize.utils import vizrank +from Orange.widgets.visualize.utils.vizrank import VizRankDialogNAttrs, \ + VizRankMixin from Orange.widgets.visualize.utils.component import OWGraphWithAnchors from Orange.widgets.visualize.utils.plotutils import TextItem from Orange.widgets.visualize.utils.widget import OWAnchorProjectionWidget @@ -32,186 +33,57 @@ MAX_LABEL_LEN = 16 -class RadvizVizRank(VizRankDialog, OWComponent): - captionTitle = "Score Plots" - n_attrs = Setting(3) +class RadvizVizRank(VizRankDialogNAttrs): minK = 10 - attrsSelected = Signal([]) - _AttrRole = next(gui.OrangeUserRole) - - percent_data_used = Setting(100) - - def __init__(self, master): - """Add the spin box for maximal number of attributes""" - VizRankDialog.__init__(self, master) - OWComponent.__init__(self, master) - - self.master = master - self.n_neighbors = 10 - - box = gui.hBox(self) - max_n_attrs = min(MAX_DISPLAYED_VARS, len(master.model_selected)) - self.n_attrs_spin = gui.spin( - box, self, "n_attrs", 3, max_n_attrs, label="Maximum number of variables: ", - controlWidth=50, alignment=Qt.AlignRight, callback=self._n_attrs_changed) - gui.rubber(box) - self.last_run_n_attrs = None - self.attr_color = master.attr_color - self.attr_ordering = None - self.data = None - self.valid_data = None - - self.rank_table.clicked.connect(self.on_row_clicked) - self.rank_table.verticalHeader().sectionClicked.connect( - self.on_header_clicked) - - def initialize(self): - super().initialize() - self.attr_color = self.master.attr_color - - def _compute_attr_order(self): - """ - used by VizRank to evaluate attributes - """ - master = self.master - attrs = [v for v in master.primitive_variables - if v is not self.attr_color] - data = self.master.data.transform(Domain(attributes=attrs, class_vars=self.attr_color)) - self.data = data - self.valid_data = np.hstack((~np.isnan(data.X), ~np.isnan(data.Y.reshape(len(data.Y), 1)))) + def __init__(self, parent, data, attributes, color, n_attrs): + super().__init__(parent, data, attributes, color, n_attrs, + spin_label="Maximum number of variables: ") + + def score_attributes(self): + attrs = [v for v in self.attrs if v is not self.attr_color] + data = self.data.transform(Domain(attrs, self.attr_color)) relief = ReliefF if self.attr_color.is_discrete else RReliefF weights = relief(n_iterations=100, k_nearest=self.minK)(data) attrs = sorted(zip(weights, attrs), key=lambda x: (-x[0], x[1].name)) - self.attr_ordering = attr_ordering = [a for _, a in attrs] - return attr_ordering - - def _evaluate_projection(self, x, y): - """ - kNNEvaluate - evaluate class separation in the given projection using a k-NN method - Parameters - ---------- - x - variables to evaluate - y - class - - Returns - ------- - scores - """ - if self.percent_data_used != 100: - rand = np.random.choice(len(x), int(len(x) * self.percent_data_used / 100), - replace=False) - x = x[rand] - y = y[rand] - neigh = KNeighborsClassifier(n_neighbors=3) if self.attr_color.is_discrete else \ - KNeighborsRegressor(n_neighbors=3) - assert ~(np.isnan(x).any(axis=None) | np.isnan(x).any(axis=None)) - neigh.fit(x, y) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=UserWarning) - scores = cross_val_score(neigh, x, y, cv=3) - return scores.mean() - - def _n_attrs_changed(self): - """ - Change the button label when the number of attributes changes. The method does not reset - anything so the user can still see the results until actually restarting the search. - """ - if self.n_attrs != self.last_run_n_attrs or self.saved_state is None: - self.button.setText("Start") - else: - self.button.setText("Continue") - self.button.setEnabled(self.check_preconditions()) - - def progressBarSet(self, value): - self.setWindowTitle(self.captionTitle + " Evaluated {} permutations".format(value)) - - def check_preconditions(self): - master = self.master - if not super().check_preconditions(): - return False - elif not master.btn_vizrank.isEnabled(): - return False - self.n_attrs_spin.setMaximum(min(MAX_DISPLAYED_VARS, - len(master.model_selected))) - return True - - def on_selection_changed(self, selected, _): - self.on_row_clicked(selected.indexes()[0]) - - def on_row_clicked(self, index): - self.selectionChanged.emit(index.data(self._AttrRole)) - - def on_header_clicked(self, section): - self.on_row_clicked(self.rank_model.index(section, 0)) - - def iterate_states(self, state): - if state is None: # on the first call, compute order - self.attrs = self._compute_attr_order() - state = list(range(3)) - else: - state = list(state) - - def combinations(n, s): - while True: - yield s - for up, _ in enumerate(s): - s[up] += 1 - if up + 1 == len(s) or s[up] < s[up + 1]: - break - s[up] = up - if s[-1] == n: - if len(s) < self.n_attrs: - s = list(range(len(s) + 1)) - else: - break - - for c in combinations(len(self.attrs), state): - for p in islice(permutations(c[1:]), factorial(len(c) - 1) // 2): - yield (c[0],) + p + return [a for _, a in attrs] + + def state_count(self): + n_all_attrs = self.max_attrs() + if not n_all_attrs: + return 0 + return sum(comb(n_all_attrs, n_attrs) * factorial(n_attrs - 1) // 2 + for n_attrs in range(3, self.n_attrs + 1)) + + def state_generator(self): + return ( + (c[0], *p) + for k in range(3, self.n_attrs + 1) + for c in combinations(list(range(len(self.attr_order))), k) + for p in islice(permutations(c[1:]), factorial(len(c) - 1) // 2)) def compute_score(self, state): - attrs = [self.attrs[i] for i in state] + attrs = [self.attr_order[i] for i in state] domain = Domain(attributes=attrs, class_vars=[self.attr_color]) data = self.data.transform(domain) + valid_data = IsDefined()(data) projector = RadViz() - projection = projector(data) - radviz_xy = projection(data) - y = projector.preprocess(data).Y - return -self._evaluate_projection(radviz_xy, y) - - def bar_length(self, score): - return -score - - def row_for_state(self, score, state): - attrs = [self.attrs[s] for s in state] - item = QStandardItem("[{:0.6f}] ".format(-score) + ", ".join(a.name for a in attrs)) - item.setData(attrs, self._AttrRole) - return [item] - - def _update_progress(self): - self.progressBarSet(int(self.saved_progress)) - - def before_running(self): - """ - Disable the spin for number of attributes before running and - enable afterwards. Also, if the number of attributes is different than - in the last run, reset the saved state (if it was paused). - """ - if self.n_attrs != self.last_run_n_attrs: - self.saved_state = None - self.saved_progress = 0 - if self.saved_state is None: - self.scores = [] - self.rank_model.clear() - self.last_run_n_attrs = self.n_attrs - self.n_attrs_spin.setDisabled(True) - - def stopped(self): - self.n_attrs_spin.setDisabled(False) + projection = projector(valid_data) + radviz_xy = projection(valid_data).X + y = projector.preprocess(valid_data).Y + + neigh = (KNeighborsClassifier if self.attr_color.is_discrete else + KNeighborsRegressor)(n_neighbors=3) + neigh.fit(radviz_xy, y) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=UserWarning) + scores = cross_val_score(neigh, radviz_xy, y, cv=3) + return -scores.mean() * len(valid_data) / len(data) class OWRadvizGraph(OWGraphWithAnchors): + aggregate_dense_regions = Setting(True) + def __init__(self, scatter_widget, parent): super().__init__(scatter_widget, parent) self.anchors_scatter_item = None @@ -244,8 +116,9 @@ def update_anchors(self): self.anchor_items = [] label_len = 1 + foreground = self.plot_widget.palette().color(QPalette.Text) for point, label in zip(points, labels): - anchor = TextItem() + anchor = TextItem(color=foreground) anchor.textItem.setToolTip(f"{label}") if len(label) > MAX_LABEL_LEN: @@ -266,7 +139,6 @@ def update_anchors(self): anchor.setText(label) anchor.setFont(self.parameter_setter.anchor_font) label_len = min(MAX_LABEL_LEN, len(label)) - anchor.setColor(QColor(0, 0, 0)) x, y = point angle = np.rad2deg(np.arctan2(y, x)) @@ -309,25 +181,29 @@ def _add_indicator_item(self, anchor_idx): self.plot_widget.addItem(self.indicator_item) -class OWRadviz(OWAnchorProjectionWidget): +class OWRadviz(OWAnchorProjectionWidget, VizRankMixin(RadvizVizRank)): name = "Radviz" description = "Display Radviz projection" - icon = "icons/Radviz.svg" + icon = "icons/Radviz-symbolic.svg" priority = 241 - keywords = ["viz"] + keywords = "radviz, viz" settings_version = 3 selected_vars = ContextSetting([]) - vizrank = SettingProvider(RadvizVizRank) GRAPH_CLASS = OWRadvizGraph graph = SettingProvider(OWRadvizGraph) + n_attrs_vizrank = Setting(3) class Warning(OWAnchorProjectionWidget.Warning): - invalid_embedding = widget.Msg("No projection for selected features") - removed_vars = widget.Msg("Categorical variables with more than" - " two values are not shown.") - max_vars_selected = widget.Msg("Maximum number of selected variables reached.") + removed_vars = widget.Msg( + "Categorical variables with more than two values are not shown.") + max_vars_selected = widget.Msg( + "Maximum number of selected variables reached.") + + def __init__(self): + VizRankMixin.__init__(self) # pylint: disable=non-parent-init-called + OWAnchorProjectionWidget.__init__(self) def _add_controls(self): box = gui.vBox(self.controlArea, box="Features") @@ -336,9 +212,9 @@ def _add_controls(self): variables_selection(box, self, self.model_selected) self.model_selected.selection_changed.connect( self.__model_selected_changed) - self.vizrank, self.btn_vizrank = RadvizVizRank.add_vizrank( - None, self, "Suggest features", self.vizrank_set_attrs) - box.layout().addWidget(self.btn_vizrank) + box.layout().addWidget(self.vizrank_button("Suggest features")) + self.vizrankSelectionChanged.connect(self.vizrank_set_attrs) + self.vizrankRunStateChanged.connect(self.store_vizrank_n_attrs) super()._add_controls() def _add_buttons(self): @@ -361,7 +237,11 @@ def effective_variables(self): def effective_data(self): return self.data.transform(Domain(self.effective_variables)) - def vizrank_set_attrs(self, *attrs): + def store_vizrank_n_attrs(self, state, data): + if state == vizrank.RunState.Running: + self.n_attrs_vizrank = data["n_attrs"] + + def vizrank_set_attrs(self, attrs): if not attrs: return self.selected_vars[:] = attrs @@ -376,28 +256,39 @@ def __model_selected_changed(self): self.Warning.max_vars_selected.clear() self.init_projection() self.setup_plot() - self.commit() + self.commit.deferred() def colors_changed(self): super().colors_changed() - self._init_vizrank() + self.init_vizrank() + @OWAnchorProjectionWidget.Inputs.data def set_data(self, data): super().set_data(data) - self._init_vizrank() + self.init_vizrank() self.init_projection() - def _init_vizrank(self): - is_enabled = self.data is not None and \ - len(self.primitive_variables) > 3 and \ - self.attr_color is not None and \ - not np.isnan(self.data.get_column_view( - self.attr_color)[0].astype(float)).all() and \ - np.sum(np.all(np.isfinite(self.data.X), axis=1)) > 1 and \ - np.all(np.nan_to_num(np.nanstd(self.data.X, 0)) != 0) - self.btn_vizrank.setEnabled(is_enabled) - if is_enabled: - self.vizrank.initialize() + def init_vizrank(self): + msgerr = "" + if self.data is None: + msgerr = "No data" + elif len(self.primitive_variables) <= 3: + msgerr = "Not enough variables" + elif self.attr_color is None: + msgerr = "Color is not set." + elif np.isnan(self.data.get_column(self.attr_color)).all(): + msgerr = "No rows with defined color variable" + elif np.sum(np.all(np.isfinite(self.data.X), axis=1)) <= 1: + msgerr = "Not enough rows without missing data" + elif not np.all(np.nan_to_num(np.nanstd(self.data.X, 0)) != 0): + msgerr = "Constant data" + + if not msgerr: + super().init_vizrank( + self.data, self.primitive_variables, self.attr_color, + self.n_attrs_vizrank) + else: + self.disable_vizrank(msgerr) def check_data(self): super().check_data() @@ -420,7 +311,7 @@ def _manual_move(self, anchor_idx, x, y): def _send_components_x(self): components_ = super()._send_components_x() angle = np.arctan2(*components_[::-1]) - return np.row_stack((components_, angle)) + return np.vstack((components_, angle)) def _send_components_metas(self): return np.vstack((super()._send_components_metas(), ["angle"])) @@ -477,5 +368,5 @@ def boundingRect(self): if __name__ == "__main__": # pragma: no cover - data = Table("brown-selected") - WidgetPreview(OWRadviz).run(set_data=data, set_subset_data=data[::10]) + brown = Table("brown-selected") + WidgetPreview(OWRadviz).run(set_data=brown, set_subset_data=brown[::10]) diff --git a/Orange/widgets/visualize/owruleviewer.py b/Orange/widgets/visualize/owruleviewer.py index a36c4e759bb..c73075f1ff4 100644 --- a/Orange/widgets/visualize/owruleviewer.py +++ b/Orange/widgets/visualize/owruleviewer.py @@ -22,7 +22,7 @@ class OWRuleViewer(widget.OWWidget): description = "Review rules induced from data." icon = "icons/CN2RuleViewer.svg" priority = 1140 - keywords = [] + keywords = "cn2 rule viewer" class Inputs: data = Input("Data", Table) @@ -173,8 +173,6 @@ def commit(self): def send_report(self): if self.classifier is not None: - self.report_domain("Data domain", self.classifier.original_domain) - self.report_items("Rule induction algorithm", self.classifier.params) self.report_table("Induced rules", self.view) def sizeHint(self): diff --git a/Orange/widgets/visualize/owscatterplot.py b/Orange/widgets/visualize/owscatterplot.py index c09da3d7125..867a5c2ef92 100644 --- a/Orange/widgets/visualize/owscatterplot.py +++ b/Orange/widgets/visualize/owscatterplot.py @@ -1,17 +1,20 @@ -from itertools import chain +import math +from typing import List, Callable, Optional from xml.sax.saxutils import escape import numpy as np -from AnyQt.QtWidgets import QGroupBox, QPushButton +import scipy.stats as ss from scipy.stats import linregress from sklearn.neighbors import NearestNeighbors from sklearn.metrics import r2_score -from AnyQt.QtCore import Qt, QTimer, QPointF, Signal -from AnyQt.QtGui import QColor +from AnyQt.QtCore import Qt, QTimer, QPointF +from AnyQt.QtGui import QColor, QFont, QFontMetrics +from AnyQt.QtWidgets import QGroupBox, QSizePolicy, QPushButton import pyqtgraph as pg +from orangewidget.utils import load_styled_icon from orangewidget.utils.combobox import ComboBoxSearch from Orange.data import Table, Domain, DiscreteVariable, Variable @@ -27,46 +30,21 @@ from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase, \ ScatterBaseParameterSetter -from Orange.widgets.visualize.utils import VizRankDialogAttrPair +from Orange.widgets.visualize.utils.error_bars_dialog import ErrorBarsDialog +from Orange.widgets.visualize.utils.vizrank import VizRankDialogAttrPair, \ + VizRankMixin +from Orange.widgets.visualize.utils.customizableplot import Updater from Orange.widgets.visualize.utils.widget import OWDataProjectionWidget from Orange.widgets.widget import AttributeList, Msg, Input, Output class ScatterPlotVizRank(VizRankDialogAttrPair): - captionTitle = "Score Plots" minK = 10 - attr_color = None - - def __init__(self, master): - super().__init__(master) - self.attr_color = self.master.attr_color - - def initialize(self): - self.attr_color = self.master.attr_color - super().initialize() - - def check_preconditions(self): - self.Information.add_message( - "color_required", "Color variable is not selected") - self.Information.color_required.clear() - if not super().check_preconditions(): - return False - if not self.attr_color: - self.Information.color_required() - return False - return True - - def iterate_states(self, initial_state): - # If we put initialization of `self.attrs` to `initialize`, - # `score_heuristic` would be run on every call to `set_data`. - if initial_state is None: # on the first call, compute order - self.attrs = self.score_heuristic() - yield from super().iterate_states(initial_state) def compute_score(self, state): # pylint: disable=invalid-unary-operand-type - attrs = [self.attrs[i] for i in state] - data = self.master.data + attrs = [self.attr_order[i] for i in state] + data = self.data data = data.transform(Domain(attrs, self.attr_color)) data = data[~np.isnan(data.X).any(axis=1) & ~np.isnan(data.Y).T] if len(data) < self.minK: @@ -79,69 +57,129 @@ def compute_score(self, state): n_neighbors / len(data.Y) else: return -r2_score(data.Y, np.mean(data.Y[ind], axis=1)) * \ - (len(data.Y) / len(self.master.data)) - - def bar_length(self, score): - return max(0, -score) + (len(data.Y) / len(self.data)) - def score_heuristic(self): + def score_attributes(self): assert self.attr_color is not None - master_domain = self.master.data.domain - vars = [v for v in chain(master_domain.variables, master_domain.metas) - if v is not self.attr_color and v.is_primitive()] - domain = Domain(attributes=vars, class_vars=self.attr_color) - data = self.master.data.transform(domain) + attrs = [ + v + for v in self.attrs # same attributes that are in xy combos + if v is not self.attr_color and v.is_primitive() + ] + domain = Domain(attributes=attrs, class_vars=self.attr_color) + data = self.data.transform(domain) relief = ReliefF if isinstance(domain.class_var, DiscreteVariable) \ else RReliefF - weights = relief(n_iterations=100, k_nearest=self.minK)(data) + weights = relief( + n_iterations=100, k_nearest=self.minK, random_state=0)(data) attrs = sorted(zip(weights, domain.attributes), key=lambda x: (-x[0], x[1].name)) return [a for _, a in attrs] class ParameterSetter(ScatterBaseParameterSetter): + DEFAULT_LINE_WIDTH = 3 + DEFAULT_LINE_ALPHA = 255 def __init__(self, master): super().__init__(master) + self.reg_line_label_font = QFont() + self.reg_line_settings = { + Updater.WIDTH_LABEL: self.DEFAULT_LINE_WIDTH, + Updater.ALPHA_LABEL: self.DEFAULT_LINE_ALPHA, + Updater.STYLE_LABEL: Updater.DEFAULT_LINE_STYLE, + } def update_setters(self): super().update_setters() self.initial_settings[self.LABELS_BOX].update({ self.AXIS_TITLE_LABEL: self.FONT_SETTING, - self.AXIS_TICKS_LABEL: self.FONT_SETTING + self.AXIS_TICKS_LABEL: self.FONT_SETTING, + self.LINE_LAB_LABEL: self.FONT_SETTING }) + self.initial_settings[self.PLOT_BOX] = {} + self.initial_settings[self.PLOT_BOX][self.LINE_LABEL] = { + Updater.WIDTH_LABEL: (range(1, 10), self.DEFAULT_LINE_WIDTH), + Updater.ALPHA_LABEL: (range(0, 255, 5), self.DEFAULT_LINE_ALPHA), + Updater.STYLE_LABEL: (list(Updater.LINE_STYLES), + Updater.DEFAULT_LINE_STYLE), + } + + def update_lines(**settings): + self.reg_line_settings.update(**settings) + Updater.update_inf_lines(self.reg_line_items, + **self.reg_line_settings) + Updater.update_lines(self.ellipse_items, + **self.reg_line_settings) + self.master.update_reg_line_label_colors() + + def update_line_label(**settings): + self.reg_line_label_font = \ + Updater.change_font(self.reg_line_label_font, settings) + Updater.update_label_font(self.reg_line_label_items, + self.reg_line_label_font) + + self._setters[self.LABELS_BOX][self.LINE_LAB_LABEL] = update_line_label + self._setters[self.PLOT_BOX] = {self.LINE_LABEL: update_lines} @property def axis_items(self): return [value["item"] for value in self.master.plot_widget.plotItem.axes.values()] + @property + def reg_line_items(self): + return self.master.reg_line_items + + @property + def reg_line_label_items(self): + return [line.label for line in self.master.reg_line_items + if hasattr(line, "label")] + + @property + def ellipse_items(self): + return self.master.ellipse_items + class OWScatterPlotGraph(OWScatterPlotBase): show_reg_line = Setting(False) orthonormal_regression = Setting(False) + show_ellipse = Setting(False) jitter_continuous = Setting(False) + aggregate_dense_regions = Setting(True) def __init__(self, scatter_widget, parent): super().__init__(scatter_widget, parent) self.parameter_setter = ParameterSetter(self) self.reg_line_items = [] + self.ellipse_items: List[pg.PlotCurveItem] = [] + self.error_bars_items: List[pg.ErrorBarItem] = [] + self.view_box.sigResized.connect(self.update_error_bars) + self.view_box.sigRangeChanged.connect(self.update_error_bars) def clear(self): super().clear() self.reg_line_items.clear() + self.ellipse_items.clear() + self.error_bars_items.clear() def update_coordinates(self): super().update_coordinates() + self.set_aggregation() self.update_axes() + self.update_error_bars() # Don't update_regression line here: update_coordinates is always # followed by update_point_props, which calls update_colors def update_colors(self): super().update_colors() self.update_regression_line() + self.update_ellipse() def jitter_coordinates(self, x, y): + if self.jitter_size == 0: + return x, y + def get_span(attr): if attr.is_discrete: # Assuming the maximal jitter size is 10, a span of 4 will @@ -153,7 +191,7 @@ def get_span(attr): return 0 # No jittering span_x = get_span(self.master.attr_x) span_y = get_span(self.master.attr_y) - if self.jitter_size == 0 or (span_x == 0 and span_y == 0): + if span_x == 0 and span_y == 0: return x, y return self._jitter_data(x, y, span_x, span_y) @@ -172,9 +210,9 @@ def update_axes(self): self.plot_widget.hideAxis(axis) @staticmethod - def _orthonormal_line(x, y, color, width): + def _orthonormal_line(x, y, color, width, style=Qt.SolidLine): # https://en.wikipedia.org/wiki/Deming_regression, with δ=0. - pen = pg.mkPen(color=color, width=width) + pen = pg.mkPen(color=color, width=width, style=style) xm = np.mean(x) ym = np.mean(y) sxx, sxy, _, syy = np.cov(x, y, ddof=1).flatten() @@ -196,47 +234,74 @@ def _orthonormal_line(x, y, color, width): return pg.InfiniteLine(QPointF(xm, y.min()), 90, pen) @staticmethod - def _regression_line(x, y, color, width): + def _regression_line(x, y, color, width, style=Qt.SolidLine): min_x, max_x = np.min(x), np.max(x) if min_x == max_x: return None slope, intercept, rvalue, _, _ = linregress(x, y) angle = np.degrees(np.arctan(slope)) start_y = min_x * slope + intercept - rotate = 135 < angle % 360 < 315 - l_opts = dict(color=color, position=abs(rotate - 0.85), + l_opts = dict(color=color, position=0.85, rotateAxis=(1, 0), movable=True) - reg_line_item = pg.InfiniteLine( + return pg.InfiniteLine( pos=QPointF(min_x, start_y), angle=angle, - pen=pg.mkPen(color=color, width=width), + pen=pg.mkPen(color=color, width=width, style=style), label=f"r = {rvalue:.2f}", labelOpts=l_opts) - if rotate: - reg_line_item.label.angle = 180 - reg_line_item.label.updateTransform() - return reg_line_item - def _add_line(self, x, y, color, width): + def _add_line(self, x, y, color): + width = self.parameter_setter.reg_line_settings[Updater.WIDTH_LABEL] + alpha = self.parameter_setter.reg_line_settings[Updater.ALPHA_LABEL] + style = self.parameter_setter.reg_line_settings[Updater.STYLE_LABEL] + style = Updater.LINE_STYLES[style] + color.setAlpha(alpha) if self.orthonormal_regression: - line = self._orthonormal_line(x, y, color, width) + line = self._orthonormal_line(x, y, color, width, style) else: - line = self._regression_line(x, y, color, width) + line = self._regression_line(x, y, color, width, style) if line is None: return self.plot_widget.addItem(line) self.reg_line_items.append(line) - def update_regression_line(self): + if hasattr(line, "label"): + Updater.update_label_font( + [line.label], self.parameter_setter.reg_line_label_font + ) + + def update_reg_line_label_colors(self): for line in self.reg_line_items: - self.plot_widget.removeItem(line) - self.reg_line_items.clear() - if not (self.show_reg_line - and self.master.can_draw_regresssion_line()): + if hasattr(line, "label"): + color = 0.0 if self.class_density \ + else line.pen.color().darker(175) + line.label.setColor(color) + + def update_density(self): + super().update_density() + self.update_reg_line_label_colors() + + def update_regression_line(self): + self._update_curve(self.reg_line_items, + self.show_reg_line, + self._add_line) + self.update_reg_line_label_colors() + + def update_ellipse(self): + self._update_curve(self.ellipse_items, + self.show_ellipse, + self._add_ellipse) + + def _update_curve(self, items: List, show: bool, add: Callable): + for item in items: + self.plot_widget.removeItem(item) + items.clear() + if not (show and self.master.can_draw_regression_line()): return x, y = self.master.get_coordinates_data() - if x is None: + if x is None or len(x) < 2: return - self._add_line(x, y, QColor("#505050"), width=2) - if self.master.is_continuous_color() or self.palette is None: + add(x, y, QColor("#505050")) + if self.master.is_continuous_color() or self.palette is None \ + or len(self.palette) == 0: return c_data = self.master.get_color_data() if c_data is None: @@ -245,19 +310,104 @@ def update_regression_line(self): for val in range(c_data.max() + 1): mask = c_data == val if mask.sum() > 1: - self._add_line(x[mask], y[mask], self.palette[val], width=2) + add(x[mask], y[mask], self.palette[val].darker(135)) + + def _add_ellipse(self, x: np.ndarray, y: np.ndarray, color: QColor) -> np.ndarray: + # https://github.com/ChristianGoueguel/HotellingEllipse/blob/master/R/ellipseCoord.R + points = np.vstack([x, y]).T + mu = np.mean(points, axis=0) + cov = np.cov(*(points - mu).T) + vals, vects = np.linalg.eig(cov) + angle = math.atan2(vects[1, 0], vects[0, 0]) + matrix = np.array([[np.cos(angle), -np.sin(angle)], + [np.sin(angle), np.cos(angle)]]) + + n = len(x) + f = ss.f.ppf(0.95, 2, n - 2) + f = f * 2 * (n - 1) / (n - 2) + m = [np.pi * i / 100 for i in range(201)] + cx = np.cos(m) * np.sqrt(vals[0] * f) + cy = np.sin(m) * np.sqrt(vals[1] * f) + + pts = np.vstack([cx, cy]) + pts = matrix.dot(pts) + cx = pts[0] + mu[0] + cy = pts[1] + mu[1] + + width = self.parameter_setter.reg_line_settings[Updater.WIDTH_LABEL] + alpha = self.parameter_setter.reg_line_settings[Updater.ALPHA_LABEL] + style = self.parameter_setter.reg_line_settings[Updater.STYLE_LABEL] + style = Updater.LINE_STYLES[style] + color.setAlpha(alpha) + + pen = pg.mkPen(color=color, width=width, style=style) + ellipse = pg.PlotCurveItem(cx, cy, pen=pen) + self.plot_widget.addItem(ellipse) + self.ellipse_items.append(ellipse) + + def update_jittering(self): + super().update_jittering() + self.update_error_bars() + self.set_aggregation() + + def allow_aggregation(self): + # Reimplemented to allow aggregation when jittering is zero, or when + # both axes are continuous and continuous jittering is disabled + return ( + self.aggregate_dense_regions + and (self.selection is None or len(self.selection) == 0) + and not self.subset_is_shown + and (self.labels is None or len(self.labels) == 0) + and (self.jitter_size == 0 + or (self.master.attr_x.is_continuous + and self.master.attr_y.is_continuous + and not self.jitter_continuous) + ) + ) + + def update_error_bars(self): + for item in self.error_bars_items: + self.plot_widget.removeItem(item) + self.error_bars_items.clear() + if not self.master.can_draw_regression_line(): + return + x, y = self.get_coordinates() + if x is None: + return + + top, bottom, left, right = self.master.get_errors_data() + if top is None and bottom is None and left is None and right is None: + return + + px, py = self.view_box.viewPixelSize() + pen = pg.mkPen(color=QColor("#505050")) + + # x axis + error_bars = pg.ErrorBarItem(x=x, y=y, left=left, right=right, + beam=py * 10, pen=pen) + error_bars.setZValue(-1) + self.plot_widget.addItem(error_bars) + self.error_bars_items.append(error_bars) -class OWScatterPlot(OWDataProjectionWidget): + # y axis + error_bars = pg.ErrorBarItem(x=x, y=y, top=top, bottom=bottom, + beam=px * 10, pen=pen) + error_bars.setZValue(-1) + self.plot_widget.addItem(error_bars) + self.error_bars_items.append(error_bars) + + +class OWScatterPlot(OWDataProjectionWidget, VizRankMixin(ScatterPlotVizRank)): """Scatterplot visualization with explorative analysis and intelligent data visualization enhancements.""" - name = 'Scatter Plot' + name = "Scatter Plot" description = "Interactive scatter plot visualization with " \ "intelligent data visualization enhancements." icon = "icons/ScatterPlot.svg" priority = 140 - keywords = [] + keywords = "scatter plot" class Inputs(OWDataProjectionWidget.Inputs): features = Input("Features", AttributeList) @@ -269,14 +419,18 @@ class Outputs(OWDataProjectionWidget.Outputs): auto_sample = Setting(True) attr_x = ContextSetting(None) attr_y = ContextSetting(None) + attr_x_upper = ContextSetting(None) + attr_x_lower = ContextSetting(None) + attr_x_is_abs = Setting(False) + attr_y_upper = ContextSetting(None) + attr_y_lower = ContextSetting(None) + attr_y_is_abs = Setting(False) tooltip_shows_all = Setting(True) GRAPH_CLASS = OWScatterPlotGraph graph = SettingProvider(OWScatterPlotGraph) embedding_variables_names = None - xy_changed_manually = Signal(Variable, Variable) - class Warning(OWDataProjectionWidget.Warning): missing_coords = Msg( "Plot cannot be displayed because '{}' or '{}' " @@ -292,9 +446,12 @@ def __init__(self): self.xy_model: DomainModel = None self.cb_attr_x: ComboBoxSearch = None self.cb_attr_y: ComboBoxSearch = None - self.vizrank: ScatterPlotVizRank = None - self.vizrank_button: QPushButton = None + self.button_attr_x: QPushButton = None + self.button_attr_y: QPushButton = None + self.__x_axis_dlg: ErrorBarsDialog = None + self.__y_axis_dlg: ErrorBarsDialog = None self.sampling: QGroupBox = None + self._xy_invalidated: bool = True self.sql_data = None # Orange.data.sql.table.SqlTable self.attribute_selection_list = None # list of Orange.data.Variable @@ -326,6 +483,12 @@ def _add_controls(self): "If checked, fit line to group (minimize distance from points);\n" "otherwise fit y as a function of x (minimize vertical distances)", disabledBy=self.cb_reg_line) + gui.checkBox( + self._plot_box, self, + value="graph.show_ellipse", + label="Show confidence ellipse", + tooltip="Hotelling's T² confidence ellipse (α=95%)", + callback=self.graph.update_ellipse) def _add_controls_axis(self): common_options = dict( @@ -336,19 +499,70 @@ def _add_controls_axis(self): spacing=2 if gui.is_macstyle() else 8) dmod = DomainModel self.xy_model = DomainModel(dmod.MIXED, valid_types=dmod.PRIMITIVE) + + hor_icon, ver_icon = self.__get_bar_icons() + width = 3 * QFontMetrics(self.font()).horizontalAdvance("m") + hbox = gui.hBox(self.attr_box, spacing=0) self.cb_attr_x = gui.comboBox( - self.attr_box, self, "attr_x", label="Axis x:", + hbox, self, "attr_x", label="Axis x:", callback=self.set_attr_from_combo, model=self.xy_model, **common_options, ) + self.button_attr_x = gui.button( + hbox, self, "", callback=self.__on_x_button_clicked, + autoDefault=False, width=width, enabled=False, + sizePolicy=(QSizePolicy.Fixed, QSizePolicy.Fixed) + ) + self.button_attr_x.setIcon(hor_icon) + + hbox = gui.hBox(self.attr_box, spacing=0) self.cb_attr_y = gui.comboBox( - self.attr_box, self, "attr_y", label="Axis y:", + hbox, self, "attr_y", label="Axis y:", callback=self.set_attr_from_combo, model=self.xy_model, **common_options, ) + self.button_attr_y = gui.button( + hbox, self, "", callback=self.__on_y_button_clicked, + autoDefault=False, width=width, enabled=False, + sizePolicy=(QSizePolicy.Fixed, QSizePolicy.Fixed) + ) + self.button_attr_y.setIcon(ver_icon) + vizrank_box = gui.hBox(self.attr_box) - self.vizrank, self.vizrank_button = ScatterPlotVizRank.add_vizrank( - vizrank_box, self, "Find Informative Projections", self.set_attr) + button = self.vizrank_button("Find Informative Projections") + vizrank_box.layout().addWidget(button) + self.vizrankSelectionChanged.connect(self.set_attr) + + self.__x_axis_dlg = ErrorBarsDialog(self) + self.__x_axis_dlg.changed.connect(self.__on_x_dlg_changed) + self.__y_axis_dlg = ErrorBarsDialog(self) + self.__y_axis_dlg.changed.connect(self.__on_y_dlg_changed) + + def __on_x_button_clicked(self): + self.__show_bars_dlg( + self.__x_axis_dlg, self.button_attr_x, + self.attr_x_upper, self.attr_x_lower, self.attr_x_is_abs) + + def __on_y_button_clicked(self): + self.__show_bars_dlg( + self.__y_axis_dlg, self.button_attr_y, + self.attr_y_upper, self.attr_y_lower, self.attr_y_is_abs) + + def __show_bars_dlg(self, dlg, button, upper, lower, is_abs): + pos = button.mapToGlobal(button.rect().bottomLeft()) + dlg.show_dlg(self.data.domain, + pos.x(), pos.y(), + upper, lower, is_abs) + + def __on_x_dlg_changed(self): + self.attr_x_upper, self.attr_x_lower, self.attr_x_is_abs = \ + self.__x_axis_dlg.get_data() + self.graph.update_error_bars() + + def __on_y_dlg_changed(self): + self.attr_y_upper, self.attr_y_lower, self.attr_y_is_abs = \ + self.__y_axis_dlg.get_data() + self.graph.update_error_bars() def _add_controls_sampling(self): self.sampling = gui.auto_commit( @@ -357,18 +571,24 @@ def _add_controls_sampling(self): self.sampling.setVisible(False) @property - def effective_variables(self): - return [self.attr_x, self.attr_y] if self.attr_x and self.attr_y else [] + def effective_variables(self) -> list[Variable]: + variables = [] + if self.attr_x and self.attr_y: + variables.append(self.attr_x) + if self.attr_x.name != self.attr_y.name: + variables.append(self.attr_y) + for var in (self.attr_x_upper, self.attr_x_lower, + self.attr_y_upper, self.attr_y_lower): + # set is not used to preserve order + if var and var not in variables: + variables.append(var) + return variables @property def effective_data(self): - eff_var = self.effective_variables - if eff_var and self.attr_x.name == self.attr_y.name: - eff_var = [self.attr_x] - return self.data.transform(Domain(eff_var)) + return self.data.transform(Domain(self.effective_variables)) - def _vizrank_color_change(self): - self.vizrank.initialize() + def init_vizrank(self): err_msg = "" if self.data is None: err_msg = "No data on input" @@ -378,15 +598,17 @@ def _vizrank_color_change(self): err_msg = "Not enough features for ranking" elif self.attr_color is None: err_msg = "Color variable is not selected" - elif np.isnan(self.data.get_column_view( - self.attr_color)[0].astype(float)).all(): + elif np.isnan(self.data.get_column(self.attr_color)).all(): err_msg = "Color variable has no values" - self.vizrank_button.setEnabled(not err_msg) - self.vizrank_button.setToolTip(err_msg) + if not err_msg: + super().init_vizrank(self.data, list(self.xy_model), self.attr_color) + else: + self.disable_vizrank(err_msg) + @OWDataProjectionWidget.Inputs.data def set_data(self, data): super().set_data(data) - self._vizrank_color_change() + self.init_vizrank() def findvar(name, iterable): """Find a Orange.data.Variable in `iterable` by name""" @@ -416,7 +638,7 @@ def check_data(self): self.sampling.setVisible(False) self.sql_data = None if isinstance(self.data, SqlTable): - if self.data.approx_len() < 4000: + if len(self.data) < 4000: self.data = Table(self.data) else: self.Information.sampled_sql() @@ -432,6 +654,14 @@ def check_data(self): len(self.data.domain.variables) == 0): self.data = None + def enable_controls(self): + super().enable_controls() + enabled = bool(self.data) and \ + self.data.domain.has_continuous_attributes(include_class=True, + include_metas=True) + self.button_attr_x.setEnabled(enabled) + self.button_attr_y.setEnabled(enabled) + def get_embedding(self): self.valid_data = None if self.data is None: @@ -450,6 +680,31 @@ def get_embedding(self): msg.missing_coords(self.attr_x.name, self.attr_y.name) return np.vstack((x_data, y_data)).T + def get_errors_data(self) -> tuple[ + Optional[np.ndarray], Optional[np.ndarray], + Optional[np.ndarray], Optional[np.ndarray] + ]: + x_data = self.get_column(self.attr_x) + y_data = self.get_column(self.attr_y) + top, bottom, left, right = [None] * 4 + if self.attr_x_upper: + right = self.get_column(self.attr_x_upper) + if self.attr_x_is_abs: + right = right - x_data + if self.attr_x_lower: + left = self.get_column(self.attr_x_lower) + if self.attr_x_is_abs: + left = x_data - left + if self.attr_y_upper: + top = self.get_column(self.attr_y_upper) + if self.attr_y_is_abs: + top = top - y_data + if self.attr_y_lower: + bottom = self.get_column(self.attr_y_lower) + if self.attr_y_is_abs: + bottom = y_data - bottom + return top, bottom, left, right + # Tooltip def _point_tooltip(self, point_id, skip_attrs=()): point_data = self.data[point_id] @@ -463,9 +718,10 @@ def _point_tooltip(self, point_id, skip_attrs=()): text = "{}

      {}".format(text, others) return text - def can_draw_regresssion_line(self): + def can_draw_regression_line(self): return self.data is not None and \ self.data.domain is not None and \ + self.attr_x is not None and self.attr_y is not None and \ self.attr_x.is_continuous and \ self.attr_y.is_continuous @@ -488,6 +744,8 @@ def init_attr_values(self): self.attr_x = self.xy_model[0] if self.xy_model else None self.attr_y = self.xy_model[1] if len(self.xy_model) >= 2 \ else self.attr_x + self.attr_x_upper, self.attr_x_lower = None, None + self.attr_y_upper, self.attr_y_lower = None, None def switch_sampling(self): self.__timer.stop() @@ -495,64 +753,80 @@ def switch_sampling(self): self.add_data() self.__timer.start() - def set_subset_data(self, subset_data): + @OWDataProjectionWidget.Inputs.data_subset + def set_subset_data(self, subset: Optional[Table]): self.warning() - if isinstance(subset_data, SqlTable): - if subset_data.approx_len() < AUTO_DL_LIMIT: - subset_data = Table(subset_data) + if isinstance(subset, SqlTable): + if len(subset) < AUTO_DL_LIMIT: + subset = Table(subset) else: self.warning("Data subset does not support large Sql tables") - subset_data = None - super().set_subset_data(subset_data) + subset = None + super().set_subset_data(subset) # called when all signals are received, so the graph is updated only once def handleNewSignals(self): self.attr_box.setEnabled(True) - self.vizrank.setEnabled(True) if self.attribute_selection_list and self.data is not None and \ - self.data.domain is not None and \ - all(attr in self.data.domain for attr - in self.attribute_selection_list): - self.attr_x, self.attr_y = self.attribute_selection_list[:2] + self.data.domain is not None: self.attr_box.setEnabled(False) - self.vizrank.setEnabled(False) + if all(attr in self.xy_model for attr in self.attribute_selection_list): + self.attr_x, self.attr_y = self.attribute_selection_list + else: + self.attr_x, self.attr_y = None, None + self.attr_x_upper, self.attr_x_lower = None, None + self.attr_y_upper, self.attr_y_lower = None, None + self._invalidated = self._invalidated or self._xy_invalidated + self._xy_invalidated = False super().handleNewSignals() if self._domain_invalidated: self.graph.update_axes() + self.graph.update_error_bars() self._domain_invalidated = False - self.cb_reg_line.setEnabled(self.can_draw_regresssion_line()) + if self.attribute_selection_list: + self.graph.update_error_bars() + can_plot = self.can_draw_regression_line() + self.cb_reg_line.setEnabled(can_plot) + self.graph.controls.show_ellipse.setEnabled(can_plot) @Inputs.features def set_shown_attributes(self, attributes): if attributes and len(attributes) >= 2: self.attribute_selection_list = attributes[:2] - self._invalidated = self._invalidated \ + self._xy_invalidated = self._xy_invalidated \ or self.attr_x != attributes[0] \ or self.attr_y != attributes[1] else: + if self.attr_x is None or self.attr_y is None: + # scenario happens when features input removed and features + # were invalid or hidden and those attr_x and attr_h were None + self.init_attr_values() self.attribute_selection_list = None - def set_attr(self, attr_x, attr_y): - if attr_x != self.attr_x or attr_y != self.attr_y: - self.attr_x, self.attr_y = attr_x, attr_y + def set_attr(self, attrs): + if attrs != [self.attr_x,self.attr_y]: + self.attr_x, self.attr_y = attrs self.attr_changed() def set_attr_from_combo(self): self.attr_changed() - self.xy_changed_manually.emit(self.attr_x, self.attr_y) + self.vizrankAutoSelect.emit([self.attr_x, self.attr_y]) def attr_changed(self): - self.cb_reg_line.setEnabled(self.can_draw_regresssion_line()) + can_plot = self.can_draw_regression_line() + self.cb_reg_line.setEnabled(can_plot) + self.graph.controls.show_ellipse.setEnabled(can_plot) self.setup_plot() - self.commit() + self.commit.deferred() def get_axes(self): return {"bottom": self.attr_x, "left": self.attr_y} def colors_changed(self): super().colors_changed() - self._vizrank_color_change() + self.init_vizrank() + @gui.deferred def commit(self): super().commit() self.send_features() @@ -603,6 +877,18 @@ def migrate_context(cls, context, version): if values["attr_x"][1] % 100 == 1 or values["attr_y"][1] % 100 == 1: raise IncompatibleContext() + __HorizontalBarIcon = None + __VerticalBarIcon = None + + @classmethod + def __get_bar_icons(cls): + if cls.__HorizontalBarIcon is None: + cls.__HorizontalBarIcon = load_styled_icon( + "Orange.widgets.visualize", "icons/interval-horizontal.svg") + cls.__VerticalBarIcon = load_styled_icon( + "Orange.widgets.visualize", "icons/interval-vertical.svg") + return cls.__HorizontalBarIcon, cls.__VerticalBarIcon + if __name__ == "__main__": # pragma: no cover table = Table("iris") diff --git a/Orange/widgets/visualize/owscatterplotgraph.py b/Orange/widgets/visualize/owscatterplotgraph.py index 368e0594771..6cdba261f14 100644 --- a/Orange/widgets/visualize/owscatterplotgraph.py +++ b/Orange/widgets/visualize/owscatterplotgraph.py @@ -1,19 +1,21 @@ import sys import itertools import warnings +from collections import Counter +from typing import Callable from xml.sax.saxutils import escape -from math import log10, floor, ceil from datetime import datetime, timezone import numpy as np from AnyQt.QtCore import Qt, QRectF, QSize, QTimer, pyqtSignal as Signal, \ - QObject + QObject, QEvent from AnyQt.QtGui import QColor, QPen, QBrush, QPainterPath, QTransform, \ - QPainter + QPainter, QPalette, QTextOption from AnyQt.QtWidgets import QApplication, QToolTip, QGraphicsTextItem, \ QGraphicsRectItem, QGraphicsItemGroup import pyqtgraph as pg +from pyqtgraph import functions as fn from pyqtgraph.graphicsItems.ScatterPlotItem import Symbols from pyqtgraph.graphicsItems.LegendItem import LegendItem as PgLegendItem from pyqtgraph.graphicsItems.TextItem import TextItem @@ -23,12 +25,11 @@ from Orange.widgets import gui from Orange.widgets.settings import Setting from Orange.widgets.utils import classdensity, colorpalettes -from Orange.widgets.utils.plot import OWPalette from Orange.widgets.visualize.utils.customizableplot import Updater, \ CommonParameterSetter from Orange.widgets.visualize.utils.plotutils import ( HelpEventDelegate as EventDelegate, InteractiveViewBox as ViewBox, - PaletteItemSample, SymbolItemSample, AxisItem + PaletteItemSample, SymbolItemSample, AxisItem, PlotWidget, DiscretizedScale ) SELECTION_WIDTH = 5 @@ -39,20 +40,21 @@ class LegendItem(PgLegendItem): - def __init__(self, size=None, offset=None, pen=None, brush=None): - super().__init__(size, offset) + items = [] # Accessed in changeEvent after delete in tests?? + def __init__( + self, size=None, offset=None, pen=None, brush=None, + ): + super().__init__(size, offset) + self.items = [] self.layout.setContentsMargins(5, 5, 5, 5) self.layout.setHorizontalSpacing(15) self.layout.setColumnAlignment(1, Qt.AlignLeft | Qt.AlignVCenter) - - if pen is None: - pen = QPen(QColor(196, 197, 193, 200), 1) - pen.setCosmetic(True) + if pen is not None: + pen = QPen(pen) + if brush is not None: + brush = QBrush(brush) self.__pen = pen - - if brush is None: - brush = QBrush(QColor(232, 232, 232, 100)) self.__brush = brush def restoreAnchor(self, anchors): @@ -66,16 +68,17 @@ def restoreAnchor(self, anchors): # pylint: disable=arguments-differ def paint(self, painter, _option, _widget=None): - painter.setPen(self.__pen) - painter.setBrush(self.__brush) + painter.setPen(self.pen()) + painter.setBrush(self.brush()) rect = self.contentsRect() painter.drawRoundedRect(rect, 2, 2) def addItem(self, item, name): super().addItem(item, name) - # Fix-up the label alignment + # Fix-up the label alignment, and color + color = self.palette().color(QPalette.Text) _, label = self.items[-1] - label.setText(name, justify="left") + label.setText(name, justify="left", color=color) def clear(self): """ @@ -91,6 +94,31 @@ def clear(self): self.updateSize() + def pen(self): + if self.__pen is not None: + return QPen(self.__pen) + else: + color = self.palette().color(QPalette.Disabled, QPalette.Text) + color.setAlpha(100) + pen = QPen(color, 1) + pen.setCosmetic(True) + return pen + + def brush(self): + if self.__brush is not None: + return QBrush(self.__brush) + else: + color = self.palette().color(QPalette.Window) + color.setAlpha(150) + return QBrush(color) + + def changeEvent(self, event: QEvent): + if event.type() == QEvent.PaletteChange: + color = self.palette().color(QPalette.Text) + for _, label in self.items: + label.setText(label.text, color=color) + super().changeEvent(event) + def bound_anchor_pos(corner, parentpos): corner = np.clip(corner, 0, 1) @@ -110,60 +138,6 @@ def bound_anchor_pos(corner, parentpos): return (irx, iry), (prx, pry) -class DiscretizedScale: - """ - Compute suitable bins for continuous value from its minimal and - maximal value. - - The width of the bin is a power of 10 (including negative powers). - The minimal value is rounded up and the maximal is rounded down. If this - gives less than 3 bins, the width is divided by four; if it gives - less than 6, it is halved. - - .. attribute:: offset - The start of the first bin. - - .. attribute:: width - The width of the bins - - .. attribute:: bins - The number of bins - - .. attribute:: decimals - The number of decimals used for printing out the boundaries - """ - def __init__(self, min_v, max_v): - """ - :param min_v: Minimal value - :type min_v: float - :param max_v: Maximal value - :type max_v: float - """ - super().__init__() - dif = max_v - min_v if max_v != min_v else 1 - if np.isnan(dif): - min_v = 0 - dif = decimals = 1 - else: - decimals = -floor(log10(dif)) - resolution = 10 ** -decimals - bins = ceil(dif / resolution) - if bins < 6: - decimals += 1 - if bins < 3: - resolution /= 4 - else: - resolution /= 2 - bins = ceil(dif / resolution) - self.offset = resolution * floor(min_v // resolution) - self.bins = bins - self.decimals = max(decimals, 0) - self.width = resolution - - def get_bins(self): - return self.offset + self.width * np.arange(self.bins + 1) - - class ScatterPlotItem(pg.ScatterPlotItem): """ Modifies the behaviour of ScatterPlotItem as follows: @@ -184,6 +158,15 @@ def __init__(self, *args, **kwargs): self._update_spots_in_paint = False self._z_mapping = None self._inv_mapping = None + self._aggregation_size = None + self._aggregation_threshold = None + self._agg_size_default = 50 + self._agg_threshold_default = 10 + + self._nonaggregated = True + self._agg_indices = None + self._agg_coords = None + self._agg_r2s = None def setZ(self, z): """ @@ -226,9 +209,33 @@ def updateSpots(self, dataSet=None): # pylint: disable=unused-argument self._update_spots_in_paint = True self.update() + def setAggregationOptions(self, size, threshold): + self._agg_size_default = size + self._agg_threshold_default = threshold + + def setAggregation(self, enabled): + if enabled != (self._aggregation_size is None): + return + if enabled: + self._aggregation_size = self._agg_size_default + self._aggregation_threshold = self._agg_threshold_default + else: + self._aggregation_size = None + self._aggregation_threshold = None + self.update() + # pylint: disable=arguments-differ def paint(self, painter, option, widget=None): + # super().paint will reset painter.transform, so we must compute + # all transformations in advance + self._nonaggregated = True + self._agg_indices = None + self._agg_coords = None + self._agg_r2s = None + agg_colors = self._get_aggregated_points(painter) + try: + self.data["visible"] = self._nonaggregated if self._z_mapping is not None: assert len(self._z_mapping) == len(self.data) self.data = self.data[self._z_mapping] @@ -238,9 +245,131 @@ def paint(self, painter, option, widget=None): painter.setRenderHint(QPainter.SmoothPixmapTransform, True) super().paint(painter, option, widget) finally: + self.data["visible"] = True if self._inv_mapping is not None: self.data = self.data[self._inv_mapping] + self._paint_aggregated_points(painter, agg_colors) + + def _get_aggregated_points(self, painter): + if self._aggregation_size is None: + return None + + viewmask = self._maskAt(self.viewRect()) + data = self.data[viewmask] + if len(data) == 0: + return None + + x, y = data["x"], data["y"] + xmi, xma = np.min(x), np.max(x) + ymi, yma = np.min(y), np.max(y) + + tr = fn.transformCoordinates( + painter.transform(), + np.array([[xmi, ymi], [xma, yma]]).T).T + width = tr[1, 0] - tr[0, 0] or 1 + height = tr[0, 1] - tr[1, 1] or 1 + + NX = int(width / self._aggregation_size) or 1 + NY = int(height / self._aggregation_size) or 1 + cellx = (xma - xmi) / NX or 1 + celly = (yma - ymi) / NY or 1 + xg = np.clip((x - xmi) // cellx, 0, NX - 1) + yg = np.clip((y - ymi) // celly, 0, NY - 1) + idx = (xg + NX * yg).astype(int) + counts = np.bincount(idx, minlength=NX * NY) + + if np.max(counts) < self._aggregation_threshold: + return None + + self._nonaggregated = np.ones(len(self.data), dtype=bool) + self._nonaggregated[viewmask] = counts[idx] < self._aggregation_threshold + + agg_pts = [] + self._agg_indices = [] + agg_colors = [] + brushes = data["brush"] + for i, count in enumerate(counts): + if count < self._aggregation_threshold: + continue + mask = idx == i + agg_pts.append([np.mean(x[mask]), np.mean(y[mask])]) + self._agg_indices.append(np.flatnonzero(mask)) + agg_colors.append( + Counter( + brush.color().getRgb() for brush in brushes[mask] + ) + ) + self._agg_coords = fn.transformCoordinates(painter.transform(), np.array(agg_pts).T).T + return agg_colors + + def _paint_aggregated_points(self, painter, agg_colors): + if self._agg_coords is None: + return + + countf = 8 / max(sum(c.values()) for c in agg_colors) + text_opt = QTextOption() + text_opt.setAlignment(Qt.AlignCenter) + rs = [] + for (pt, spot_colors) in zip(self._agg_coords, agg_colors): + painter.resetTransform() + painter.translate(*pt) + total = sum(spot_colors.values()) + r = int(6 + countf * total) + rs.append(r + 12) + if len(spot_colors) == 1: + color = QColor(*next(iter(spot_colors))) + painter.setBrush(fn.mkBrush(color)) + painter.setPen(fn.mkPen(color)) + painter.drawEllipse(-r, -r, 2 * r, 2 * r) + painter.setBrush(fn.mkBrush(None)) + for r in (r + 3, r + 6): + painter.setPen(fn.mkPen(color, width=5)) + painter.drawEllipse(-r, -r, 2 * r, 2 * r) + else: + start_angle = 0 + for color, count in spot_colors.items(): + angle_span = 360 * count / total + painter.setBrush(fn.mkBrush(color)) + painter.setPen(fn.mkPen(color)) + start = int(16 * start_angle) + span = int(16 * angle_span) + painter.drawPie(-r, -r, 2 * r, 2 * r, start, span) + for nr in (r + 3, r + 6): + painter.setPen(fn.mkPen(color, width=5)) + painter.drawArc(-nr, -nr, 2 * nr, 2 * nr, start, span) + start_angle += angle_span + + painter.setBrush(fn.mkBrush(QColor(0, 0, 0))) + painter.setPen(fn.mkPen(QColor(0, 0, 0))) + painter.drawText(QRectF(-15, -15, 30, 30), str(total), text_opt) + self._agg_r2s = np.array(rs) ** 2 + + def aggregatedPointsAt(self, pos): + """ + Returns indices of aggregated points for the given **scene** coordinate. + + Note that unlike pointsAt, this function's argument is scene coordinate + and not data coordinate. This is because the aggregation is done in scene + coordinates. + """ + if self._agg_indices is None: + indices = [] + else: + x, y = pos.x(), pos.y() + aggs = np.flatnonzero( + np.sum((self._agg_coords - np.array([x, y])) ** 2, axis=1) + < self._agg_r2s) + indices = list(itertools.chain(*(self._agg_indices[i] for i in aggs))) + return self.points()[indices][::-1] + + def pointsAt(self, pos): + # Override to ignore aggregated points. + try: + self.data["visible"] = self._nonaggregated + return super().pointsAt(pos) + finally: + self.data["visible"] = True def _define_symbols(): """ @@ -301,6 +430,9 @@ def use_time(self, enable): self._use_time = enable self.enableAutoSIPrefix(not enable) + def is_time(self): + return self._use_time + def tickValues(self, minVal, maxVal, size): """Find appropriate tick locations.""" if not self._use_time: @@ -320,6 +452,11 @@ def tickValues(self, minVal, maxVal, size): return super().tickValues(minVal, maxVal, size) ticks = bins.thresholds + # Remove ticks that will later be removed in AxisItem.generateDrawSpecs + # because they are out of range. Removing them here is needed so that + # they do not affect spaces and label format + ticks = ticks[int((ticks[0] < minVal)) + :len(ticks) - int((ticks[-1] > maxVal))] max_steps = max(int(size / self._label_width), 1) if len(ticks) > max_steps: @@ -327,7 +464,10 @@ def tickValues(self, minVal, maxVal, size): step = int(np.ceil(float(len(ticks)) / max_steps)) ticks = ticks[::step] - spacing = min(b - a for a, b in zip(ticks[:-1], ticks[1:])) + # In case of a single tick, `default` will inform tickStrings + # about the appropriate scale. + spacing = min((b - a for a, b in zip(ticks[:-1], ticks[1:])), + default=maxVal - minVal) return [(spacing, ticks)] def tickStrings(self, values, scale, spacing): @@ -527,6 +667,11 @@ def get_size_data(self): class_density = Setting(False) jitter_size = Setting(0) + # Subclasses that want to "opt in" aggregation of dense regions should + # override this with `aggregate_dense_regions = Setting(True)` + # (or `Setting(False)` if they want to have it disabled by default). + aggregate_dense_regions = False + resolution = 256 CurveSymbols = np.array("o x t + d star ?".split()) @@ -534,9 +679,7 @@ def get_size_data(self): DarkerValue = 120 UnknownColor = (168, 50, 168) - COLOR_NOT_SUBSET = (128, 128, 128, 0) - COLOR_SUBSET = (128, 128, 128, 255) - COLOR_DEFAULT = (128, 128, 128, 255) + COLOR_DEFAULT = (128, 128, 128) MAX_VISIBLE_LABELS = 500 @@ -549,8 +692,10 @@ def __init__(self, scatter_widget, parent=None, view_box=ViewBox): self.view_box = view_box(self) _axis = {"left": AxisItem("left"), "bottom": AxisItem("bottom")} - self.plot_widget = pg.PlotWidget(viewBox=self.view_box, parent=parent, - background="w", axisItems=_axis) + self.plot_widget = PlotWidget( + viewBox=self.view_box, parent=parent, background=None, + axisItems=_axis + ) self.plot_widget.hideAxis("left") self.plot_widget.hideAxis("bottom") self.plot_widget.getPlotItem().buttonsHidden = True @@ -597,6 +742,19 @@ def __init__(self, scatter_widget, parent=None, view_box=ViewBox): self.parameter_setter = ScatterBaseParameterSetter(self) + def allow_aggregation(self): + return ( + (self.selection is None or len(self.selection) == 0) + and not self.subset_is_shown + and (self.labels is None or len(self.labels) == 0) + and self.jitter_size == 0 + ) + + def set_aggregation(self): + if self.scatterplot_item is not None: + self.scatterplot_item.setAggregation( + self.aggregate_dense_regions and self.allow_aggregation()) + def _create_legend(self, anchor): legend = LegendItem() legend.setParentItem(self.plot_widget.getViewBox()) @@ -627,7 +785,9 @@ def _create_drag_tooltip(self): r = text.boundingRect() text.setTextWidth(r.width()) rect = QGraphicsRectItem(0, 0, r.width() + 8, r.height() + 4) - rect.setBrush(QColor(224, 224, 224, 212)) + color = self.plot_widget.palette().color(QPalette.Disabled, QPalette.Window) + color.setAlpha(212) + rect.setBrush(color) rect.setPen(QPen(Qt.NoPen)) self.update_tooltip() @@ -667,6 +827,7 @@ def update_jittering(self): self.scatterplot_item.setCoordinates(x, y) self.scatterplot_item_sel.setCoordinates(x, y) self.update_labels() + self.set_aggregation() # TODO: Rename to remove_plot_items def clear(self): @@ -933,8 +1094,9 @@ def update_sizes(self): widget = self class Timeout: - # 0.5 - np.cos(np.arange(0.17, 1, 0.17) * np.pi) / 2 - factors = [0.07, 0.26, 0.52, 0.77, 0.95, 1] + # 0.5 - np.cos(np.arange(0.17, 1, 0.09) * np.pi) / 2 + factors = [0.07, 0.16, 0.27, 0.41, 0.55, + 0.68, 0.81, 0.9, 0.97, 1] def __init__(self): self._counter = 0 @@ -959,7 +1121,8 @@ def __call__(self): # If encountered any strange behaviour when updating sizes, # implement it with threads self.begin_resizing.emit() - self.timer = QTimer(self.scatterplot_item, interval=50) + interval = int(500 / len(Timeout.factors)) + self.timer = QTimer(self.scatterplot_item, interval=interval) self.timer.timeout.connect(Timeout()) self.timer.start() else: @@ -1023,7 +1186,7 @@ def get_colors(self): else: return self._get_discrete_colors(c_data, subset) - def _get_same_colors(self, subset): + def _get_same_colors(self, subset, color=COLOR_DEFAULT): """ Return the same pen for all points while the brush color depends upon whether the point is in the subset or not @@ -1036,21 +1199,17 @@ def _get_same_colors(self, subset): Returns: (tuple): a list of pens and list of brushes """ - color = self.plot_widget.palette().color(OWPalette.Data) - pen = [_make_pen(color, 1.5)] * self.n_shown # use a single QPen instance - - # Prepare all brushes; we use the first two or the last - brushes = [] - for c in (self.COLOR_SUBSET, self.COLOR_NOT_SUBSET, self.COLOR_DEFAULT): - color = QColor(*c) - if color.alpha(): - color.setAlpha(self.alpha_value) - brushes.append(QBrush(color)) - + alpha_subset, alpha_unset = self._alpha_for_subsets() if subset is not None: - brush = np.where(subset, *brushes[:2]) + qcolor = QColor(*color, alpha_subset) + brush = np.where(subset, QBrush(qcolor), QBrush(QColor(0, 0, 0, 0))) + pen = np.where(subset, + _make_pen(qcolor, 1.5), + _make_pen(QColor(*color, alpha_unset), 1.5)) else: - brush = brushes[-1:] * self.n_shown # use a single QBrush instance + qcolor = QColor(*color, self.alpha_value) + brush = np.full(self.n_shown, QBrush(qcolor)) + pen = [_make_pen(qcolor, 1.5)] * self.n_shown return pen, brush def _get_continuous_colors(self, c_data, subset): @@ -1064,18 +1223,18 @@ def _get_continuous_colors(self, c_data, subset): if np.isnan(c_data).all(): self.palette = palette - return self._get_continuous_nan_colors(len(c_data)) + return self._get_same_colors(subset, self.palette.nan_color) self.scale = DiscretizedScale(np.nanmin(c_data), np.nanmax(c_data)) bins = self.scale.get_bins() self.palette = \ colorpalettes.BinnedContinuousPalette.from_palette(palette, bins) colors = self.palette.values_to_colors(c_data) - brush = np.hstack( - (colors, - np.full((len(c_data), 1), self.alpha_value, dtype=np.ubyte))) - pen = (colors.astype(dtype=float) * 100 / self.DarkerValue - ).astype(np.ubyte) + alphas = np.full((len(c_data), 1), self.alpha_value, dtype=np.ubyte) + brush = np.hstack((colors, alphas)) + pen = np.hstack( + ((colors.astype(dtype=float) * 100 / self.DarkerValue).astype(np.ubyte), + alphas)) # Reuse pens and brushes with the same colors because PyQtGraph then # builds smaller pixmap atlas, which makes the drawing faster @@ -1091,27 +1250,21 @@ def create_pen(col): def create_brush(col): return QBrush(QColor(*col)) - cached_pens = {} - pen = [reuse(cached_pens, create_pen, *col) for col in pen.tolist()] - if subset is not None: + alpha_subset, alpha_unset = self._alpha_for_subsets() brush[:, 3] = 0 - brush[subset, 3] = self.alpha_value + brush[subset, 3] = alpha_subset + pen[:, 3] = alpha_unset + brush[subset, 3] = alpha_subset + cached_pens = {} + pen = [reuse(cached_pens, create_pen, *col) for col in pen.tolist()] cached_brushes = {} brush = np.array([reuse(cached_brushes, create_brush, *col) for col in brush.tolist()]) return pen, brush - def _get_continuous_nan_colors(self, n): - nan_color = QColor(*self.palette.nan_color) - nan_pen = _make_pen(nan_color.darker(1.2), 1.5) - pen = np.full(n, nan_pen) - nan_brush = QBrush(nan_color) - brush = np.full(n, nan_brush) - return pen, brush - def _get_discrete_colors(self, c_data, subset): """ Return the pens and colors whose color represent an index into @@ -1124,20 +1277,44 @@ def _get_discrete_colors(self, c_data, subset): c_data[np.isnan(c_data)] = len(self.palette) c_data = c_data.astype(int) colors = self.palette.qcolors_w_nan - pens = np.array( - [_make_pen(col.darker(self.DarkerValue), 1.5) for col in colors]) - pen = pens[c_data] - if self.alpha_value < 255: + if subset is None: for col in colors: col.setAlpha(self.alpha_value) - brushes = np.array([QBrush(col) for col in colors]) - brush = brushes[c_data] + pens = np.array( + [_make_pen(col.darker(self.DarkerValue), 1.5) + for col in colors]) + pen = pens[c_data] + brushes = np.array([QBrush(col) for col in colors]) + brush = brushes[c_data] + else: + subset_colors = [QColor(col) for col in colors] + alpha_subset, alpha_unset = self._alpha_for_subsets() + for col in subset_colors: + col.setAlpha(alpha_subset) + for col in colors: + col.setAlpha(alpha_unset) - if subset is not None: + pens, subset_pens = ( + np.array( + [_make_pen(col.darker(self.DarkerValue), 1.5) + for col in cols]) + for cols in (colors, subset_colors)) + pen = np.where(subset, subset_pens[c_data], pens[c_data]) + + brushes = np.array([QBrush(col) for col in subset_colors]) + brush = brushes[c_data] black = np.full(len(brush), QBrush(QColor(0, 0, 0, 0))) brush = np.where(subset, brush, black) return pen, brush + def _alpha_for_subsets(self): + a, b, c = 1.2, -3.2, 3 + x = self.alpha_value / 255 + alpha_subset = 31 + int(224 * (a * x ** 3 + b * x ** 2 + c * x)) + x = 1 - x + alpha_unset = int(255 - 224 * (a * x ** 3 + b * x ** 2 + c * x)) + return alpha_subset, alpha_unset + def update_colors(self): """ Trigger an update of point colors @@ -1174,8 +1351,9 @@ def update_density(self): if c_data is None: return visible_c_data = self._filter_visible(c_data) - mask = np.bitwise_and(np.isfinite(visible_c_data), - visible_c_data < MAX_COLORS - 1) + mask = np.isfinite(visible_c_data) + if not self.master.is_continuous_color(): + mask = np.bitwise_and(mask, visible_c_data < MAX_COLORS - 1) pens = self.scatterplot_item.data['pen'] rgb_data = [ pen.color().getRgb()[:3] if pen is not None else (255, 255, 255) @@ -1272,21 +1450,24 @@ def update_labels(self): mask = None self._signal_too_many_labels( - mask is not None and mask.sum() > self.MAX_VISIBLE_LABELS) + bool(mask is not None and mask.sum() > self.MAX_VISIBLE_LABELS)) if self._too_many_labels or mask is None or not np.any(mask): + self.set_aggregation() return - black = pg.mkColor(0, 0, 0) + foreground = self.plot_widget.palette().color(QPalette.Text) labels = labels[mask] x = x[mask] y = y[mask] for label, xp, yp in zip(labels, x, y): - ti = TextItem(label, black) + ti = TextItem(label, foreground) ti.setPos(xp, yp) self.plot_widget.addItem(ti) self.labels.append(ti) ti.setFont(self.parameter_setter.label_font) + self.set_aggregation() + def _signal_too_many_labels(self, too_many): if self._too_many_labels != too_many: self._too_many_labels = too_many @@ -1468,7 +1649,7 @@ def _update_shape_legend(self, labels): SymbolItemSample(pen=color, brush=color, size=10, symbol=symbol), escape(label)) - def _update_continuous_color_legend(self, label_formatter): + def _update_continuous_color_legend(self, label_formatter: Callable[[float], str]): self.color_legend.clear() if self.scale is None or self.scatterplot_item is None: return @@ -1542,6 +1723,7 @@ def unselect_all(self): if self.label_only_selected: self.update_labels() self.master.selection_changed() + self.set_aggregation() def select(self, points): # noinspection PyArgumentList @@ -1586,6 +1768,7 @@ def _update_after_selection(self): if self.label_only_selected: self.update_labels() self.master.selection_changed() + self.set_aggregation() def _compress_indices(self): indices = sorted(set(self.selection) | {0}) @@ -1602,17 +1785,65 @@ def get_selection(self): else: return np.flatnonzero(self.selection) + def __adjust_pos(self, pos): + # This code is "inspired" by the code in pyqtgraph's ScatterPlotItem._maskAt + x = pos.x() + y = pos.y() + w = h = 3 + if self.scatterplot_item.opts['pxMode']: + px, py = self.scatterplot_item.pixelVectors() + try: + px = 0 if px is None else px.length() + except OverflowError: + px = 0 + try: + py = 0 if py is None else py.length() + except OverflowError: + py = 0 + w *= px + h *= py + return QRectF(x - w, y - w, 2 * w, 2 * h) + + def get_dragged_points(self, pos): + if not hasattr(self.master, "set_coordinates") \ + or self.scatterplot_item is None: + return None + + # Expand the position to a rectangle of 3 pixels in each direction + pos = self.__adjust_pos(pos) + + pts = self.scatterplot_item.pointsAt(pos) + if pts is None or len(pts) == 0: + return None + idx = np.array([p.data() for p in pts], dtype=int) + + if self.selection is not None and np.any(self.selection[idx]): + idx = np.flatnonzero(self.selection) + + x, y = self.scatterplot_item.getData() + return idx, x[idx], y[idx] + + def move_dragged_points(self, points, dist): + idx, x, y = points + self.master.set_coordinates(idx, np.vstack((x + dist.x(), y + dist.y())).T) + + def finish_dragging(self): + if hasattr(self.master, "finish_dragging"): + self.master.finish_dragging() + def help_event(self, event): """ Create a `QToolTip` for the point hovered by the mouse """ if self.scatterplot_item is None: return False - act_pos = self.scatterplot_item.mapFromScene(event.scenePos()) - point_data = [p.data() for p in self.scatterplot_item.pointsAt(act_pos)] - text = self.master.get_tooltip(point_data) - if text: - QToolTip.showText(event.screenPos(), text, widget=self.plot_widget) - return True + pos = event.scenePos() + act_pos = self.scatterplot_item.mapFromScene(pos) + if len(points := self.scatterplot_item.aggregatedPointsAt(pos)) != 0: + text = self.master.get_aggregated_tooltip([p.data() for p in points]) + elif len(points := self.scatterplot_item.pointsAt(act_pos)) != 0: + text = self.master.get_tooltip([p.data() for p in points]) else: return False + QToolTip.showText(event.screenPos(), text, widget=self.plot_widget) + return True diff --git a/Orange/widgets/visualize/owscoringsheetviewer.py b/Orange/widgets/visualize/owscoringsheetviewer.py new file mode 100644 index 00000000000..102d57f21c3 --- /dev/null +++ b/Orange/widgets/visualize/owscoringsheetviewer.py @@ -0,0 +1,669 @@ +import numpy as np + +from AnyQt import QtGui +from AnyQt.QtWidgets import ( + QTableWidget, + QTableWidgetItem, + QSlider, + QLabel, + QVBoxLayout, + QHBoxLayout, + QWidget, + QStyle, + QProxyStyle, + QToolTip, + QStyleOptionSlider, +) +from AnyQt.QtCore import Qt, QRect, pyqtSignal as Signal +from AnyQt.QtGui import QPainter, QFontMetrics, QPalette + +from Orange.widgets import gui +from Orange.widgets.settings import ContextSetting +from Orange.widgets.widget import Input, Output, OWWidget, AttributeList, Msg +from Orange.data import Table +from Orange.classification import Model + +from Orange.classification.scoringsheet import ScoringSheetModel +from Orange.classification.utils.fasterrisk.utils import ( + get_support_indices, + get_all_product_booleans, +) + + +class ScoringSheetTable(QTableWidget): + state_changed = Signal(int) + + def __init__(self, main_widget, parent=None): + """ + Initialize the ScoringSheetTable. + + It sets the column headers and connects the itemChanged + signal to the handle_item_changed method. + """ + super().__init__(parent) + self.main_widget = main_widget + self.setColumnCount(3) + self.setHorizontalHeaderLabels(["Attribute Name", "Points", "Selected"]) + self.itemChanged.connect(self.handle_item_changed) + + def populate_table(self, attributes, coefficients): + """ + Populates the table with the given attributes and coefficients. + + It creates a row for each attribute and populates the first two columns with + the attribute name and coefficient respectively. The third column contains a + checkbox that allows the user to select the attribute. + """ + self.setRowCount(len(attributes)) + for i, (attr, coef) in enumerate(zip(attributes, coefficients)): + # First column + self.setItem(i, 0, QTableWidgetItem(attr)) + + # Second column (align text to the right) + coef_item = QTableWidgetItem(str(coef)) + coef_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.setItem(i, 1, coef_item) + + # Third column (checkbox) + checkbox = QTableWidgetItem() + checkbox.setCheckState(Qt.Unchecked) + self.setItem(i, 2, checkbox) + + for col in range(self.columnCount()): + item = self.item(i, col) + item.setFlags(item.flags() & ~Qt.ItemIsEditable & ~Qt.ItemIsSelectable) + + # Resize columns to fit the contents + self.resize_columns_to_contents() + + def resize_columns_to_contents(self): + """ + Resize each column to fit the content. + """ + for column in range(self.columnCount()): + self.resizeColumnToContents(column) + + def handle_item_changed(self, item): + """ + Handles the change in the state of the checkbox. + + It updates the slider value depending on the collected points. + """ + if item.column() == 2: + self.state_changed.emit(item.row()) + + +class CustomSliderStyle(QProxyStyle): + """ + A custom slider handle style. + + It draws a 2px wide black rectangle to replace the default handle. + This is done to suggest to the user that the slider is not interactive. + """ + + def drawComplexControl(self, cc, opt, painter, widget=None): + if cc != QStyle.CC_Slider: + super().drawComplexControl(cc, opt, painter, widget) + return + + # Make a copy of the style option and remove the handle subcontrol. + slider_opt = QStyleOptionSlider(opt) + slider_opt.subControls &= ~QStyle.SC_SliderHandle + super().drawComplexControl(cc, slider_opt, painter, widget) + + # Get the rectangle for the slider handle. + handle_rect = self.subControlRect(cc, opt, QStyle.SC_SliderHandle, widget) + + # Draw a simple 2px wide black rectangle as the custom handle. + painter.save() + painter.setPen(Qt.NoPen) + painter.setBrush(QPalette().color(QPalette.WindowText)) + h = handle_rect.height() + painter.drawRoundedRect( + QRect( + handle_rect.center().x() - 2, handle_rect.y() + int(0.2 * h), + 4, int(0.6 * h) + ), + 3, + 3, + ) + painter.restore() + + +class RiskSlider(QWidget): + def __init__(self, points, probabilities, parent=None): + super().__init__(parent) + self.layout = QHBoxLayout(self) + + # Set the margins for the layout + self.leftMargin = 20 + self.topMargin = 20 + self.rightMargin = 20 + self.bottomMargin = 20 + self.layout.setContentsMargins( + self.leftMargin, self.topMargin, self.rightMargin, self.bottomMargin + ) + self.setMouseTracking(True) + + # Setup the labels + self.setup_labels() + + self.slider = QSlider(Qt.Horizontal, self) + self.slider.setStyle(CustomSliderStyle()) + self.slider.setEnabled(False) + self.layout.addWidget(self.slider) + + self.points = points + self.probabilities = probabilities + self.setup_slider() + + # Set the margin for drawing text + self.textMargin = 1 + + # This is needed to show the tooltip when the mouse is over the slider thumb + self.slider.installEventFilter(self) + self.setMouseTracking(True) + self.target_class = None + + self.label_frequency = 1 + + def setup_labels(self): + """ + Set up the labels for the slider. + + It creates a vertical layout for the labels and adds it to the main layout. + It is only called once when the widget is initialized. + """ + # Create the labels for the slider + self.label_layout = QVBoxLayout() + # Add the label for the points "Points:" + self.points_label = QLabel("Total:") + self.label_layout.addWidget(self.points_label) + # Add stretch to the label layout + self.label_layout.addSpacing(23) + # Add the label for the probability "Probability:" + self.probability_label = QLabel("Probabilities (%):") + self.label_layout.addWidget(self.probability_label) + self.layout.addLayout(self.label_layout) + # Add a spacer + self.layout.addSpacing(28) + + def setup_slider(self): + """ + Set up the slider with the given points and probabilities. + + It sets the minimum and maximum values (of the indexes for the ticks) of the slider. + It is called when the points and probabilities are updated. + """ + self.slider.setMinimum(0) + self.slider.setMaximum(len(self.points) - 1 if self.points else 0) + self.slider.setTickPosition(QSlider.TicksBothSides) + self.slider.setTickInterval(1) # Set tick interval + + def move_to_value(self, value): + """ + Move the slider to the closest tick mark to the given value. + """ + if not self.points: + return + closest_point_index = min( + range(len(self.points)), key=lambda i: abs(self.points[i] - value) + ) + self.slider.setValue(closest_point_index) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.update_label_frequency() + self.update() + + def update_label_frequency(self): + """ + Update the label frequency based on the width of the slider and the number of points. + + Label frequency determines how many labels are shown on the slider. + """ + total_width = self.slider.width() + label_width = QFontMetrics(self.font()).boundingRect("100.0%").width() + max_labels = total_width // label_width + + frequencies = [1, 2, 5, 10, 20, 50, 100] + for frequency in frequencies: + if max_labels >= len(self.points) / frequency: + self.label_frequency = frequency + break + + def paintEvent(self, event): + """ + Paint the point and probabilitie labels above and below the tick marks respectively. + """ + super().paintEvent(event) + + if not self.points: + return + + painter = QPainter(self) + fm = QFontMetrics(painter.font()) + + for i, point in enumerate(self.points): + if i % self.label_frequency == 0: + # Calculate the x position of the tick mark + x_pos = ( + QStyle.sliderPositionFromValue( + self.slider.minimum(), + self.slider.maximum(), + i, + self.slider.width(), + ) + + self.slider.x() + ) + + # Draw the point label above the tick mark + point_str = str(point) + point_rect = fm.boundingRect(point_str) + point_x = int(x_pos - point_rect.width() / 2) + point_y = int(self.slider.y() - self.textMargin - point_rect.height()) + painter.drawText( + QRect(point_x, point_y, point_rect.width(), point_rect.height()), + Qt.AlignCenter, + point_str, + ) + + # Draw the probability label below the tick mark + prob_str = str(round(self.probabilities[i], 1)) + "%" + prob_rect = fm.boundingRect(prob_str) + prob_x = int(x_pos - prob_rect.width() / 2) + prob_y = int(self.slider.y() + self.slider.height() + self.textMargin) + painter.drawText( + QRect(prob_x, prob_y, prob_rect.width(), prob_rect.height()), + Qt.AlignCenter, + prob_str, + ) + + painter.end() + + def eventFilter(self, watched, event): + """ + Event filter to intercept help events on the slider. + + This is needed to show the tooltip when the mouse is over the slider thumb. + """ + if watched == self.slider and isinstance(event, QtGui.QHelpEvent): + # Handle the hover event when it's over the slider + self.handle_hover_event(event.pos()) + return True + else: + # Call the base class method to continue default event processing + return super().eventFilter(watched, event) + + def handle_hover_event(self, pos): + """ + Handle hover events for the slider. + + Display the tooltip when the mouse is over the slider thumb. + """ + thumbRect = self.get_thumb_rect() + if thumbRect.contains(pos) and self.points: + value = self.slider.value() + points = self.points[value] + probability = self.probabilities[value] + tooltip = str( + f"{self.target_class}\n " + "
      " + f"Points: {int(points)}
      " + f"Probability: {probability:.1f}%" + ) + QToolTip.showText(self.slider.mapToGlobal(pos), tooltip) + else: + QToolTip.hideText() + + def get_thumb_rect(self): + """ + Get the rectangle of the slider thumb. + """ + opt = QStyleOptionSlider() + self.slider.initStyleOption(opt) + + style = self.slider.style() + + # Get the area of the slider that contains the handle + handle_rect = style.subControlRect( + QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self.slider + ) + + # Calculate the position and size of the thumb + thumb_x = handle_rect.x() + thumb_y = handle_rect.y() + thumb_width = handle_rect.width() + thumb_height = handle_rect.height() + + return QRect(thumb_x, thumb_y, thumb_width, thumb_height) + + +class OWScoringSheetViewer(OWWidget): + """ + Allows visualization of the scoring sheet model. + """ + + name = "Scoring Sheet Viewer" + description = "Visualize the scoring sheet model." + want_control_area = False + icon = "icons/ScoringSheetViewer-symbolic.svg" + replaces = [ + "orangecontrib.prototypes.widgets.owscoringsheetviewer.OWScoringSheetViewer" + ] + priority = 2010 + keywords = "scoring sheet viewer" + + class Inputs: + classifier = Input("Classifier", Model) + data = Input("Data", Table) + + class Outputs: + features = Output("Features", AttributeList) + + target_class_index = ContextSetting(0) + + class Error(OWWidget.Error): + invalid_classifier = Msg( + "Scoring Sheet Viewer only accepts a Scoring Sheet model." + ) + + class Information(OWWidget.Information): + multiple_instances = Msg( + "The input data contains multiple instances. Only the first instance will be used." + ) + + def __init__(self): + super().__init__() + self.data = None + self.instance = None + self.instance_points = [] + self.classifier = None + self._base_coefficients = None + self._base_all_scores = None + self._base_all_risks = None + self.attributes = None + self.coefficients = None + self.all_scores = None + self.all_risks = None + self.domain = None + self.old_target_class_index = self.target_class_index + + self._setup_gui() + self.resize(700, 400) + + # GUI Methods ---------------------------------------------------------------------------------- + + def _setup_gui(self): + # Create a new widget box for the combo box in the main area + combo_box_layout = gui.widgetBox(self.mainArea, orientation="horizontal") + self.class_combo = gui.comboBox( + combo_box_layout, + self, + "target_class_index", + callback=self._class_combo_changed, + ) + self.class_combo.setFixedWidth(100) + combo_box_layout.layout().addWidget(QLabel("Target class:")) + combo_box_layout.layout().addWidget(self.class_combo) + combo_box_layout.layout().addStretch() + + self.coefficient_table = ScoringSheetTable(main_widget=self, parent=self) + gui.widgetBox(self.mainArea).layout().addWidget(self.coefficient_table) + self.coefficient_table.state_changed.connect(self._update_slider_value) + + self.risk_slider = RiskSlider([], [], self) + gui.widgetBox(self.mainArea).layout().addWidget(self.risk_slider) + + def _reset_ui_to_original_state(self): + """ + Reset all UI components to their original state. + """ + # Reset the coefficient table + self.coefficient_table.clearContents() + self.coefficient_table.setRowCount(0) + + # Reset the risk slider + self.risk_slider.slider.setValue(0) + self.risk_slider.points = [] + self.risk_slider.probabilities = [] + self.risk_slider.setup_slider() + self.risk_slider.update() + + # Reset class combo box + self.class_combo.clear() + + def _populate_interface(self): + """Populate the scoring sheet based on extracted data.""" + if self.attributes and self.coefficients: + self.coefficient_table.populate_table(self.attributes, self.coefficients) + + # Update points and probabilities in the custom slider + class_var_name = self.domain.class_vars[0].name + class_var_value = self.domain.class_vars[0].values[self.target_class_index] + + self.risk_slider.points = self.all_scores + self.risk_slider.probabilities = self.all_risks + self.risk_slider.target_class = f"{class_var_name} = {class_var_value}" + self.risk_slider.setup_slider() + self.risk_slider.update() + + def _update_slider_value(self): + """ + Updates the slider value to reflect the total points collected. + + This method is called when user changes the state of the checkbox in the coefficient table. + """ + if not self.coefficient_table: + return + total_coefficient = sum( + float(self.coefficient_table.item(row, 1).text()) + for row in range(self.coefficient_table.rowCount()) + if self.coefficient_table.item(row, 2) + and self.coefficient_table.item(row, 2).checkState() == Qt.Checked + ) + self.risk_slider.move_to_value(total_coefficient) + + def _update_controls(self): + """ + It updates the interface components based on the extracted data. + + This method is called when the user inputs data, changes the classifier or the target class. + """ + self._populate_interface() + self._update_slider_value() + self._setup_class_combo() + self._set_instance_points() + + # Class Combo Methods -------------------------------------------------------------------------- + + def _setup_class_combo(self): + """ + This method is used to populate the class combo box with the target classes. + """ + self.class_combo.clear() + if self.domain is not None: + values = self.domain.class_vars[0].values + if values: + self.class_combo.addItems(values) + self.class_combo.setCurrentIndex(self.target_class_index) + + def _class_combo_changed(self): + """ + This method is called when the user changes the target class. + It updates the interface components based on the selected class. + """ + self.target_class_index = self.class_combo.currentIndex() + if self.target_class_index == self.old_target_class_index: + return + self.old_target_class_index = self.target_class_index + + self._adjust_for_target_class() + self._update_controls() + + def _adjust_for_target_class(self): + """ + Adjusts the coefficients, scores, and risks for the negative/positive class. + + This allows user to select the target class and see the + corresponding coefficients, scores, and risks. + """ + if self.target_class_index == 1: + self.coefficients = self._base_coefficients[:] + self.all_scores = self._base_all_scores[:] + self.all_risks = self._base_all_risks[:] + else: + self.coefficients = [-coef for coef in self._base_coefficients] + self.all_scores = sorted( + [-score if score != 0 else score for score in self._base_all_scores] + ) + self.all_risks = sorted([100 - risk for risk in self._base_all_risks]) + + # Classifier Input Methods --------------------------------------------------------------------- + + def _extract_data_from_model(self, classifier): + """ + Extracts the attributes, non-zero coefficients, all possible + scores, and corresponding probabilities from the model. + """ + model = classifier.model + + # 1. Extracting attributes and non-zero coefficients + nonzero_indices = get_support_indices(model.coefficients) + attributes = [model.featureNames[i] for i in nonzero_indices] + coefficients = [int(model.coefficients[i]) for i in nonzero_indices] + + # 2. Extracting possible points and corresponding probabilities + len_nonzero_indices = len(nonzero_indices) + # If we have less than 10 attributes, we can calculate all possible combinations of scores. + if len_nonzero_indices <= 10: + all_product_booleans = get_all_product_booleans(len_nonzero_indices) + all_scores = all_product_booleans.dot(model.coefficients[nonzero_indices]) + all_scores = np.unique(all_scores) + # If there are more than 10 non-zero coefficients, calculating all possible combinations + # of scores might be computationally intensive. Instead, the method calculates all possible + # scores from the training dataset (X_train) and then picks some quantile points + # (in this case, a maximum of 20) to represent the possible scores. + else: + all_scores = model.X_train.dot(model.coefficients) + all_scores = np.unique(all_scores) + quantile_len = min(20, len(all_scores)) + quantile_points = np.asarray(range(1, 1 + quantile_len)) / quantile_len + all_scores = np.quantile( + all_scores, quantile_points, method="closest_observation" + ) + + all_scaled_scores = (model.intercept + all_scores) / model.multiplier + all_risks = 1 / (1 + np.exp(-all_scaled_scores)) + + self._base_all_scores = all_scores.tolist() + self._base_all_risks = (all_risks * 100).tolist() + combined_sorted = sorted(zip(coefficients, attributes), reverse=True) + self._base_coefficients, self.attributes = zip(*combined_sorted) + self.domain = classifier.domain + self._adjust_for_target_class() + + def _is_valid_classifier(self, classifier): + """Check if the classifier is a valid ScoringSheetModel.""" + if not isinstance(classifier, ScoringSheetModel): + self.Error.invalid_classifier() + return False + return True + + def _clear_classifier_data(self): + """Clear classifier data and associated interface components.""" + self.coefficients = None + self.attributes = None + self.all_scores = None + self.all_risks = None + self.classifier = None + self._base_coefficients = None + self._base_all_scores = None + self._base_all_risks = None + self.Outputs.features.send(None) + + # Data Input Methods --------------------------------------------------------------------------- + + def _clear_table_data(self): + """Clear data and associated interface components.""" + self.data = None + self.instance = None + self.instance_points = [] + self._set_table_checkboxes() + + def _set_instance_points(self): + """ + Initializes the instance and its points and sets the checkboxes in the coefficient table. + """ + if self.data and self.domain is not None: + self._init_instance_points() + + self._set_table_checkboxes() + + def _set_table_checkboxes(self): + """ + Sets the checkboxes in the coefficient table based on the instance points. + Or clears the checkboxes if the instance points are not initialized. + """ + for row in range(self.coefficient_table.rowCount()): + if self.instance_points and self.instance_points[row] != 0: + self.coefficient_table.item(row, 2).setCheckState(Qt.Checked) + else: + self.coefficient_table.item(row, 2).setCheckState(Qt.Unchecked) + + def _init_instance_points(self): + """ + Initialize the instance which is used to show the points collected for each attribute. + Get the values of the features for the instance and store them in a list. + """ + instances = self.data.transform(self.domain) + self.instance = instances[0] + self.instance_points = [ + self.instance.list[i] + for i in get_support_indices(self.classifier.model.coefficients) + ] + + # Input Methods -------------------------------------------------------------------------------- + + @Inputs.classifier + def set_classifier(self, classifier): + self.Error.invalid_classifier.clear() + if not classifier or not self._is_valid_classifier(classifier): + self._clear_classifier_data() + self._reset_ui_to_original_state() + return + + self.classifier = classifier + self._extract_data_from_model(classifier) + self._update_controls() + # Output the features + self.Outputs.features.send( + AttributeList( + [feature for feature in self.domain if feature.name in self.attributes] + ) + ) + + @Inputs.data + def set_data(self, data): + self.Information.multiple_instances.clear() + if not data or len(data) < 1: + self._clear_table_data() + return + + self.data = data + if len(data) > 1: + self.Information.multiple_instances() + self._update_controls() + + +if __name__ == "__main__": + from Orange.widgets.utils.widgetpreview import WidgetPreview + from Orange.classification.scoringsheet import ScoringSheetLearner + + mock_data = Table("heart_disease") + mock_learner = ScoringSheetLearner(15, 5, 5, None) + mock_model = mock_learner(mock_data) + WidgetPreview(OWScoringSheetViewer).run( + set_classifier=mock_model, set_data=mock_data + ) diff --git a/Orange/widgets/visualize/owsieve.py b/Orange/widgets/visualize/owsieve.py index 57b88198a7d..d5429898667 100644 --- a/Orange/widgets/visualize/owsieve.py +++ b/Orange/widgets/visualize/owsieve.py @@ -4,11 +4,11 @@ import numpy as np from scipy.stats.distributions import chi2 -from AnyQt.QtCore import Qt, QSize, Signal +from AnyQt.QtCore import Qt, QSize from AnyQt.QtGui import QColor, QPen, QBrush from AnyQt.QtWidgets import QGraphicsScene, QGraphicsLineItem, QSizePolicy -from Orange.data import Table, filter, Variable +from Orange.data import Table, filter as data_filter, Variable from Orange.data.sql.table import SqlTable, LARGE_TABLE, DEFAULT_SAMPLE_TIME from Orange.preprocess import Discretize from Orange.preprocess.discretize import EqualFreq @@ -20,9 +20,12 @@ ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.utils.vizrank import VizRankDialogAttrPair, \ + VizRankMixin from Orange.widgets.visualize.utils import ( - CanvasText, CanvasRectangle, ViewWithPress, VizRankDialogAttrPair) -from Orange.widgets.widget import OWWidget, AttributeList, Input, Output + CanvasText, CanvasRectangle, ViewWithPress) +from Orange.widgets.widget import OWWidget, AttributeList, Input, Output, \ + Msg class ChiSqStats: @@ -52,30 +55,24 @@ def __init__(self, data, attr1, attr2): self.p = chi2.sf( self.chisq, (len(self.probs_x) - 1) * (len(self.probs_y) - 1)) - class SieveRank(VizRankDialogAttrPair): - captionTitle = "Sieve Rank" - - def initialize(self): - super().initialize() - self.attrs = self.master.attrs + sort_names_in_row = True def compute_score(self, state): - p = ChiSqStats(self.master.discrete_data, - *(self.attrs[i].name for i in state)).p + p = ChiSqStats(self.data, *(self.attr_order[i].name for i in state)).p return 2 if np.isnan(p) else p def bar_length(self, score): return min(1, -math.log(score, 10) / 50) if 0 < score <= 1 else 0 -class OWSieveDiagram(OWWidget): +class OWSieveDiagram(OWWidget, VizRankMixin(SieveRank)): name = "Sieve Diagram" description = "Visualize the observed and expected frequencies " \ "for a combination of values." - icon = "icons/SieveDiagram.svg" + icon = "icons/SieveDiagram-symbolic.svg" priority = 200 - keywords = [] + keywords = "sieve diagram" class Inputs: data = Input("Data", Table, default=True) @@ -85,7 +82,10 @@ class Outputs: selected_data = Output("Selected Data", Table, default=True) annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table) - graph_name = "canvas" + graph_name = "canvas" # QGraphicsScene + + class Warning(OWWidget.Warning): + cochran = Msg("Data does not meet the Cochran's rule\n{}") want_control_area = False @@ -95,8 +95,6 @@ class Outputs: attr_y = ContextSetting(None) selection = ContextSetting(set()) - xy_changed_manually = Signal(Variable, Variable) - def __init__(self): # pylint: disable=missing-docstring super().__init__() @@ -118,9 +116,10 @@ def __init__(self): gui.comboBox(value="attr_x", **combo_args) gui.widgetLabel(self.attr_box, "\u2717", sizePolicy=fixed_size) gui.comboBox(value="attr_y", **combo_args) - self.vizrank, self.vizrank_button = SieveRank.add_vizrank( - self.attr_box, self, "Score Combinations", self.set_attr) - self.vizrank_button.setSizePolicy(*fixed_size) + button = self.vizrank_button("Score Combinations") + self.attr_box.layout().addWidget(button) + self.vizrankSelectionChanged.connect(self.set_attr) + button.setSizePolicy(*fixed_size) self.canvas = QGraphicsScene(self) self.canvasView = ViewWithPress( @@ -163,7 +162,7 @@ def set_data(self, data): Args: data (Table): input data """ - if isinstance(data, SqlTable) and data.approx_len() > LARGE_TABLE: + if isinstance(data, SqlTable) and len(data) > LARGE_TABLE: data = data.sample_time(DEFAULT_SAMPLE_TIME) self.closeContext() @@ -190,19 +189,30 @@ def set_data(self, data): self.resolve_shown_attributes() self.update_graph() self.update_selection() + self.init_vizrank() - self.vizrank.initialize() - self.vizrank_button.setEnabled( - self.data is not None and len(self.data) > 1 and - len(self.data.domain.attributes) > 1 and not self.data.is_sparse()) + def init_vizrank(self): + errmsg = "" + if self.data is None: + errmsg = "No data" + elif len(self.data) <= 1 or len(self.data.domain.attributes) <= 1: + errmsg = "Not enough data" + elif self.data.is_sparse(): + errmsg = "Data is sparse" + + if not errmsg: + super().init_vizrank(self.discrete_data, + list(self.data.domain.variables)) + else: + self.disable_vizrank(errmsg) - def set_attr(self, attr_x, attr_y): - self.attr_x, self.attr_y = attr_x, attr_y + def set_attr(self, attrs): + self.attr_x, self.attr_y = (self.data.domain[attr.name] for attr in attrs) self.update_attr() def attr_changed(self): self.update_attr() - self.xy_changed_manually.emit(self.attr_x, self.attr_y) + self.vizrankAutoSelect.emit([self.attr_x, self.attr_y]) def update_attr(self): """Update the graph and selection.""" @@ -255,7 +265,6 @@ def resolve_shown_attributes(self): """ self.warning() self.attr_box.setEnabled(True) - self.vizrank.setEnabled(True) if not self.input_features: # None or empty return features = [f for f in self.input_features if f in self.domain_model] @@ -264,9 +273,8 @@ def resolve_shown_attributes(self): "Features from the input signal are not present in the data") return old_attrs = self.attr_x, self.attr_y - self.attr_x, self.attr_y = [f for f in (features * 2)[:2]] + self.attr_x, self.attr_y = (features * 2)[:2] self.attr_box.setEnabled(False) - self.vizrank.setEnabled(False) if (self.attr_x, self.attr_y) != old_attrs: self.selection = set() self.update_graph() @@ -308,9 +316,9 @@ def update_selection(self): width = 4 val_x, val_y = area.value_pair filts.append( - filter.Values([ - filter.FilterDiscrete(self.attr_x.name, [val_x]), - filter.FilterDiscrete(self.attr_y.name, [val_y]) + data_filter.Values([ + data_filter.FilterDiscrete(self.attr_x.name, [val_x]), + data_filter.FilterDiscrete(self.attr_y.name, [val_y]) ])) else: width = 1 @@ -320,7 +328,7 @@ def update_selection(self): if len(filts) == 1: filts = filts[0] else: - filts = filter.Values(filts, conjunction=False) + filts = data_filter.Values(filts, conjunction=False) selection = filts(self.discrete_data) idset = set(selection.ids) sel_idx = [i for i, id in enumerate(self.data.ids) if id in idset] @@ -371,9 +379,9 @@ def show_pearson(rect, pearson, pen_width): r = b = 255 if pearson > 0: - r = g = max(255 - 20 * pearson, 55) + r = g = max(int(255 - 20 * pearson), 55) elif pearson < 0: - b = g = max(255 + 20 * pearson, 55) + b = g = max(int(255 + 20 * pearson), 55) else: r = g = b = 224 rect.setBrush(QBrush(QColor(r, g, b))) @@ -434,7 +442,7 @@ def _oper(attr, txt): return f"{xt}
      {yt}
      {ct}" - + self.Warning.cochran.clear() for item in self.canvas.items(): self.canvas.removeItem(item) if self.data is None or len(self.data) == 0 or \ @@ -511,6 +519,28 @@ def _oper(attr, txt): 0, bottom) # Assume similar height for both lines text("N = " + fmt(chi.n), 0, bottom - xl.boundingRect().height()) + msg = self._check_cochran(chi) + if msg is not None: + self.Warning.cochran(msg) + + @staticmethod + def _check_cochran(chi): + """ + Check Cochran's rule. + Return None if it is met, otherwise a string describing the problem. + """ + expected = np.asarray(chi.expected, dtype=float) + cells = expected.size + if cells == 0: + return "no cells in contingency table" + eps = 1e-12 + num_lt1 = (expected < 1.0 - eps).sum() + num_lt5 = (expected < 5.0 - eps).sum() + if num_lt1 > 0: + return "some expected frequencies are below 1" + if num_lt5 > 0.2 * cells: + return "more than 20% of expected frequencies are below 5" + return None def get_widget_name_extension(self): if self.data is not None: diff --git a/Orange/widgets/visualize/owsilhouetteplot.py b/Orange/widgets/visualize/owsilhouetteplot.py index b999e12aa76..724b2796142 100644 --- a/Orange/widgets/visualize/owsilhouetteplot.py +++ b/Orange/widgets/visualize/owsilhouetteplot.py @@ -3,22 +3,25 @@ from xml.sax.saxutils import escape from types import SimpleNamespace as namespace -from typing import Optional, Union +from typing import Optional, Union, Tuple, cast import numpy as np import sklearn.metrics from AnyQt.QtWidgets import ( - QGraphicsScene, QGraphicsWidget, QGraphicsGridLayout, + QGraphicsWidget, QGraphicsGridLayout, QGraphicsRectItem, QStyleOptionGraphicsItem, QSizePolicy, QWidget, QVBoxLayout, QGraphicsSimpleTextItem, QWIDGETSIZE_MAX, + QGraphicsSceneHelpEvent, QToolTip, QApplication, +) +from AnyQt.QtGui import ( + QColor, QPen, QBrush, QPainter, QFontMetrics, QPalette, +) +from AnyQt.QtCore import ( + Qt, QEvent, QRectF, QSizeF, QSize, QPointF, QPoint, QRect ) -from AnyQt.QtGui import QColor, QPen, QBrush, QPainter, QFontMetrics, QPalette -from AnyQt.QtCore import Qt, QEvent, QRectF, QSizeF, QSize, QPointF from AnyQt.QtCore import pyqtSignal as Signal -import pyqtgraph as pg - import Orange.data from Orange.data.util import get_unique_names import Orange.distance @@ -27,20 +30,19 @@ from Orange.misc import DistMatrix from Orange.widgets import widget, gui, settings +from Orange.widgets.utils.graphicsscene import GraphicsScene from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView -from Orange.widgets.utils import itemmodels +from Orange.widgets.utils import itemmodels, apply_all from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.utils.graphicstextlist import TextListWidget from Orange.widgets.utils.graphicslayoutitem import SimpleLayoutItem from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.widgetpreview import WidgetPreview +from Orange.widgets.visualize.utils.plotutils import AxisItem from Orange.widgets.widget import Msg, Input, Output -ROW_NAMES_WIDTH = 200 - - class InputValidationError(ValueError): message: str @@ -60,9 +62,9 @@ class OWSilhouettePlot(widget.OWWidget): description = "Visually assess cluster quality and " \ "the degree of cluster membership." - icon = "icons/SilhouettePlot.svg" + icon = "icons/SilhouettePlot-symbolic.svg" priority = 300 - keywords = [] + keywords = "silhouette plot" class Inputs: data = Input("Data", (Orange.data.Table, Orange.misc.DistMatrix)) @@ -76,14 +78,15 @@ class Outputs: "Orange.widgets.unsupervised.owsilhouetteplot.OWSilhouettePlot" ] - settingsHandler = settings.PerfectDomainContextHandler() + settingsHandler = settings.DomainContextHandler() + settings_version = 2 #: Distance metric index distance_idx = settings.Setting(0) - #: Group/cluster variable index - cluster_var_idx = settings.ContextSetting(0) - #: Annotation variable index - annotation_var_idx = settings.ContextSetting(0) + #: Group/cluster variable + cluster_var = settings.ContextSetting(None) + #: Annotation variable + annotation_var = settings.ContextSetting(None) #: Group the (displayed) silhouettes by cluster group_by_cluster = settings.Setting(True) #: A fixed size for an instance bar @@ -97,7 +100,7 @@ class Outputs: ("Manhattan", Orange.distance.Manhattan), ("Cosine", Orange.distance.Cosine)] - graph_name = "scene" + graph_name = "scene" # QGraphicsScene class Error(widget.OWWidget.Error): need_two_clusters = Msg("Need at least two non-empty clusters") @@ -105,6 +108,7 @@ class Error(widget.OWWidget.Error): memory_error = Msg("Not enough memory") value_error = Msg("Distances could not be computed: '{}'") input_validation_error = Msg("{}") + not_symmetric = widget.Msg("Distance matrix is not symmetric.") class Warning(widget.OWWidget.Warning): missing_cluster_assignment = Msg( @@ -143,16 +147,17 @@ def __init__(self): orientation=Qt.Horizontal, callback=self._invalidate_distances) controllayout.addWidget(distbox) - box = gui.vBox(self.controlArea, "Cluster Label") + box = gui.vBox(self.controlArea, "Grouping") + self.cluster_var_model = itemmodels.VariableListModel( + parent=self, placeholder="(None)") self.cluster_var_cb = gui.comboBox( - box, self, "cluster_var_idx", contentsLength=14, - searchable=True, callback=self._invalidate_scores + box, self, "cluster_var", contentsLength=14, + searchable=True, callback=self._invalidate_scores, + model=self.cluster_var_model ) gui.checkBox( - box, self, "group_by_cluster", "Group by cluster", + box, self, "group_by_cluster", "Show in groups", callback=self._replot) - self.cluster_var_model = itemmodels.VariableListModel(parent=self) - self.cluster_var_cb.setModel(self.cluster_var_model) box = gui.vBox(self.controlArea, "Bars") gui.widgetLabel(box, "Bar width:") @@ -160,12 +165,12 @@ def __init__(self): box, self, "bar_size", minValue=1, maxValue=10, step=1, callback=self._update_bar_size) gui.widgetLabel(box, "Annotations:") - self.annotation_cb = gui.comboBox( - box, self, "annotation_var_idx", contentsLength=14, - callback=self._update_annotations) self.annotation_var_model = itemmodels.VariableListModel(parent=self) - self.annotation_var_model[:] = ["None"] - self.annotation_cb.setModel(self.annotation_var_model) + self.annotation_var_model[:] = [None] + self.annotation_cb = gui.comboBox( + box, self, "annotation_var", contentsLength=14, + callback=self._update_annotations, + model=self.annotation_var_model) ibox = gui.indentedBox(box, 5) self.ann_hidden_warning = warning = gui.widgetLabel( ibox, "(increase the width to show)") @@ -176,8 +181,8 @@ def __init__(self): gui.auto_send(self.buttonsArea, self, "auto_commit") - self.scene = QGraphicsScene(self) - self.view = StickyGraphicsView(self.scene) + self.scene = GraphicsScene(self) + self.view = StyledGraphicsView(self.scene) self.view.setRenderHint(QPainter.Antialiasing, True) self.view.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.mainArea.layout().addWidget(self.view) @@ -221,6 +226,8 @@ def _set_table(self, data: Table): self.distances = None def _set_distances(self, distances: DistMatrix): + if not distances.is_symmetric(): + raise ValidationError("Distance matrix is not symmetric.") if isinstance(distances.row_items, Orange.data.Table) and \ distances.axis == 1: data = distances.row_items @@ -246,7 +253,7 @@ def handleNewSignals(self): # Disable/enable the Distances GUI controls if applicable self._distances_gui_box.setEnabled(self.distances is None) - self.unconditional_commit() + self.commit.now() def _setup_control_models(self, domain: Domain): groupvars = [ @@ -256,13 +263,14 @@ def _setup_control_models(self, domain: Domain): raise NoGroupVariable() self.cluster_var_model[:] = groupvars if domain.class_var in groupvars: - self.cluster_var_idx = groupvars.index(domain.class_var) + self.cluster_var = domain.class_var else: - self.cluster_var_idx = 0 - annotvars = [var for var in domain.metas if var.is_string] - self.annotation_var_model[:] = ["None"] + annotvars - self.annotation_var_idx = 1 if annotvars else 0 - self.openContext(Orange.data.Domain(groupvars)) + self.cluster_var = groupvars[0] + annotvars = [var for var in domain.variables + domain.metas + if var.is_string or var.is_discrete] + self.annotation_var_model[:] = [None] + annotvars + self.annotation_var = annotvars[0] if annotvars else None + self.openContext(domain) def _is_empty(self) -> bool: # Is empty (does not have any input). @@ -280,7 +288,7 @@ def clear(self): self._silhouette = None self._labels = None self.cluster_var_model[:] = [] - self.annotation_var_model[:] = ["None"] + self.annotation_var_model[:] = [None] self._clear_scene() self.Error.clear() self.Warning.clear() @@ -305,7 +313,7 @@ def _invalidate_scores(self): self._update() self._replot() if self.data is not None: - self.commit() + self.commit.deferred() def _ensure_matrix(self): # ensure self._matrix is computed if necessary @@ -343,8 +351,7 @@ def _update(self): if self._matrix is None: return - labelvar = self.cluster_var_model[self.cluster_var_idx] - labels, _ = self.data.get_column_view(labelvar) + labels = self.data.get_column(self.cluster_var) labels = np.asarray(labels, dtype=float) cluster_mask = np.isnan(labels) dist_mask = np.isnan(self._matrix).all(axis=0) @@ -393,19 +400,19 @@ def _set_bar_height(self): self._silplot.setBarHeight(self.bar_size) self._silplot.setRowNamesVisible(visible) self.ann_hidden_warning.setVisible( - not visible and self.annotation_var_idx > 0) + not visible and self.annotation_var is not None) def _replot(self): # Clear and replot/initialize the scene self._clear_scene() if self._silhouette is not None and self._labels is not None: - var = self.cluster_var_model[self.cluster_var_idx] self._silplot = silplot = SilhouettePlot() self._set_bar_height() if self.group_by_cluster: - silplot.setScores(self._silhouette, self._labels, var.values, - var.colors) + silplot.setScores( + self._silhouette, self._labels, + self.cluster_var.values, self.cluster_var.colors) else: silplot.setScores( self._silhouette, @@ -415,7 +422,7 @@ def _replot(self): self.scene.addItem(silplot) self._update_annotations() - silplot.selectionChanged.connect(self.commit) + silplot.selectionChanged.connect(self.commit.deferred) silplot.layout().activate() self._update_scene_rect() silplot.geometryChanged.connect(self._update_scene_rect) @@ -425,16 +432,13 @@ def _update_bar_size(self): self._set_bar_height() def _update_annotations(self): - if 0 < self.annotation_var_idx < len(self.annotation_var_model): - annot_var = self.annotation_var_model[self.annotation_var_idx] - else: - annot_var = None + annot_var = self.annotation_var self.ann_hidden_warning.setVisible( self.bar_size < 5 and annot_var is not None) if self._silplot is not None: if annot_var is not None: - column, _ = self.data.get_column_view(annot_var) + column = self.data.get_column(annot_var) if self._mask is not None: assert column.shape == self._mask.shape # pylint: disable=invalid-unary-operand-type @@ -467,6 +471,7 @@ def extend_horizontal(rect): self.view.setFooterSceneRect( extend_horizontal(footer.geometry().adjusted(0, -margin, 0, 0))) + @gui.deferred def commit(self): """ Commit/send the current selection to the output. @@ -490,10 +495,8 @@ def commit(self): else: scores = self._silhouette - var = self.cluster_var_model[self.cluster_var_idx] - domain = self.data.domain - proposed = "Silhouette ({})".format(escape(var.name)) + proposed = "Silhouette ({})".format(escape(self.cluster_var.name)) names = [var.name for var in itertools.chain(domain.attributes, domain.class_vars, domain.metas)] @@ -503,15 +506,18 @@ def commit(self): domain.attributes, domain.class_vars, domain.metas + (silhouette_var, )) - data = self.data.transform(domain) if np.count_nonzero(selectedmask): selected = self.data.from_table( domain, self.data, np.flatnonzero(selectedmask)) if selected is not None: - selected[:, silhouette_var] = np.c_[scores[selectedmask]] - data[:, silhouette_var] = np.c_[scores] + with selected.unlocked(selected.metas): + selected[:, silhouette_var] = np.c_[scores[selectedmask]] + + data = self.data.transform(domain) + with data.unlocked(data.metas): + data[:, silhouette_var] = np.c_[scores] self.Outputs.selected_data.send(selected) self.Outputs.annotated_data.send(create_annotated_table(data, indices)) @@ -521,23 +527,89 @@ def send_report(self): return self.report_plot() - caption = "Silhouette plot ({} distance), clustered by '{}'".format( - self.Distances[self.distance_idx][0], - self.cluster_var_model[self.cluster_var_idx]) - if self.annotation_var_idx and self._silplot.rowNamesVisible(): - caption += ", annotated with '{}'".format( - self.annotation_var_model[self.annotation_var_idx]) + caption = "Silhouette plot " \ + f"({self.Distances[self.distance_idx][0]} distance), " \ + f"clustered by '{self.cluster_var.name}'" + if self.annotation_var and self._silplot.rowNamesVisible(): + caption += f", annotated with '{self.annotation_var.name}'" self.report_caption(caption) def onDeleteWidget(self): self.clear() super().onDeleteWidget() + @classmethod + def migrate_context(cls, context, version): + values = context.values + if version < 2: + # contexts were constructed from Domain containing vars shown in + # the list view, context.class_vars and context.metas were always + # empty, and context.attributes contained discrete attributes + index, _ = values.pop("cluster_var_idx") + values["cluster_var"] = (context.attributes[index][0], 101) + + index = values.pop("annotation_var_idx")[0] - 1 + if index == -1: + values["annotation_var"] = None + elif index < len(context.attributes): + name, _ = context.attributes[index] + values["annotation_var"] = (name, 101) + # else we cannot migrate + # Even this migration can be erroneous if metas contained a mixture + # of discrete and string attributes; the latter were not stored in + # context, so indices in context could have been wrong + class SelectAction(enum.IntEnum): NoUpdate, Clear, Select, Deselect, Toogle, Current = 1, 2, 4, 8, 16, 32 +def show_tool_tip(pos: QPoint, text: str, widget: Optional[QWidget] = None, + rect=QRect(), elide=Qt.ElideRight): + """ + Show a plain text tool tip with limited length, eliding if necessary. + """ + if widget is not None: + screen = widget.screen() + else: + screen = QApplication.screenAt(pos) + font = QApplication.font("QTipLabel") + fm = QFontMetrics(font) + geom = screen.availableSize() + etext = fm.elidedText(text, elide, geom.width()) + if etext != text: + text = f"{etext}" + QToolTip.showText(pos, text, widget, rect) + + +class _SilhouettePlotTextListWidget(TextListWidget): + # disable default tooltips, SilhouettePlot handles them + def helpEvent(self, event: QGraphicsSceneHelpEvent): + return + + +class StyledGraphicsView(StickyGraphicsView): + """ + Propagate style and palette changes to the visualized scene. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.ensurePolished() + if self.scene() is not None: + self.scene().setPalette(self.palette()) + + def setScene(self, scene): + super().setScene(scene) + if self.scene() is not None: + self.scene().setPalette(self.palette()) + + def changeEvent(self, event): + if event.type() == QEvent.PaletteChange and \ + self.scene() is not None and self.scene().parent() is self: + self.scene().setPalette(self.palette()) + super().changeEvent(event) + + class SilhouettePlot(QGraphicsWidget): """ A silhouette plot widget. @@ -557,8 +629,8 @@ def __init__(self, parent=None, **kwargs): self.__pen = QPen(Qt.NoPen) self.__layout = QGraphicsGridLayout() self.__hoveredItem = None - self.__topScale = None # type: Optional[pg.AxisItem] - self.__bottomScale = None # type: Optional[pg.AxisItem] + self.__topScale = None # type: Optional[AxisItem] + self.__bottomScale = None # type: Optional[AxisItem] self.__layout.setColumnSpacing(0, 1.) self.setLayout(self.__layout) self.setFocusPolicy(Qt.StrongFocus) @@ -626,26 +698,11 @@ def setRowNames(self, names): item = layout.itemAt(i + 1, 3) assert isinstance(item, TextListWidget) if grp.rownames is not None: - metrics = QFontMetrics(self.font()) - rownames = [metrics.elidedText(rowname, Qt.ElideRight, ROW_NAMES_WIDTH) - for rowname in grp.rownames] - item.setItems(rownames) + item.setItems(grp.rownames) item.setVisible(self.__rowNamesVisible) else: item.setItems([]) item.setVisible(False) - - barplot = list(self.__plotItems())[i] - baritems = barplot.items() - - if grp.rownames is None: - tooltips = itertools.repeat("") - else: - tooltips = grp.rownames - - for baritem, tooltip in zip(baritems, tooltips): - baritem.setToolTip(tooltip) - layout.activate() def setRowNamesVisible(self, visible): @@ -704,10 +761,8 @@ def __setup(self): font = self.font() font.setPixelSize(self.__barHeight) - axispen = QPen(Qt.black) - - ax = pg.AxisItem(parent=self, orientation="top", maxTickLength=7, - pen=axispen) + foreground = self.palette().brush(QPalette.WindowText) + ax = AxisItem(parent=self, orientation="top", maxTickLength=7) ax.setRange(smin, smax) self.__topScale = ax layout = self.__layout @@ -726,9 +781,12 @@ def __setup(self): if group.label: layout.addItem(Line(orientation=Qt.Vertical), i + 1, 1) - label = QGraphicsSimpleTextItem( - "{} ({})".format(group.label, len(group.scores)), self - ) + text = group.label + if group.scores.size: + text += f" ({np.mean(group.scores):.3f})" + label = QGraphicsSimpleTextItem(text, self) + label.setBrush(foreground) + label.setPen(QPen(Qt.NoPen)) label.setRotation(-90) item = SimpleLayoutItem( label, @@ -738,11 +796,15 @@ def __setup(self): item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) layout.addItem(item, i + 1, 0, Qt.AlignCenter) - textlist = TextListWidget(self, font=font) + textlist = _SilhouettePlotTextListWidget( + self, font=font, elideMode=Qt.ElideRight, + alignment=Qt.AlignLeft | Qt.AlignVCenter + ) + textlist.setMaximumWidth(750) + textlist.setFlag(TextListWidget.ItemClipsChildrenToShape, False) sp = textlist.sizePolicy() sp.setVerticalPolicy(QSizePolicy.Ignored) textlist.setSizePolicy(sp) - textlist.setParent(self) if group.rownames is not None: textlist.setItems(group.items) textlist.setVisible(self.__rowNamesVisible) @@ -751,8 +813,7 @@ def __setup(self): layout.addItem(textlist, i + 1, 3) - ax = pg.AxisItem(parent=self, orientation="bottom", maxTickLength=7, - pen=axispen) + ax = AxisItem(parent=self, orientation="bottom", maxTickLength=7) ax.setRange(smin, smax) self.__bottomScale = ax layout.addItem(ax, len(self.__groups) + 1, 2) @@ -765,22 +826,55 @@ def bottomScaleItem(self): # type: () -> Optional[QGraphicsWidget] return self.__bottomScale - def __updateTextSizeConstraint(self): + def __updateSizeConstraints(self): # set/update fixed height constraint on the text annotation items so # it matches the silhouette's height for silitem, textitem in zip(self.__plotItems(), self.__textItems()): height = silitem.effectiveSizeHint(Qt.PreferredSize).height() textitem.setMaximumHeight(height) textitem.setMinimumHeight(height) - - def event(self, event): + mwidth = max((silitem.effectiveSizeHint(Qt.PreferredSize).width() + for silitem in self.__plotItems()), default=300) + # match the AxisItem's width to the bars + for axis in self.__axisItems(): + axis.setMaximumWidth(mwidth) + axis.setMinimumWidth(mwidth) + + def changeEvent(self, event: QEvent) -> None: + if event.type() == QEvent.PaletteChange: + brush = self.palette().brush(QPalette.Text) + labels = [it for it in self.childItems() + if isinstance(it, QGraphicsSimpleTextItem)] + apply_all(labels, lambda it: it.setBrush(brush)) + super().changeEvent(event) + + def event(self, event: QEvent) -> bool: # Reimplemented if event.type() == QEvent.LayoutRequest and \ self.parentLayoutItem() is None: - self.__updateTextSizeConstraint() + self.__updateSizeConstraints() self.resize(self.effectiveSizeHint(Qt.PreferredSize)) + elif event.type() == QEvent.GraphicsSceneHelp: + self.helpEvent(cast(QGraphicsSceneHelpEvent, event)) + if event.isAccepted(): + return True return super().event(event) + def helpEvent(self, event: QGraphicsSceneHelpEvent): + pos = self.mapFromScene(event.scenePos()) + item = self.__itemDataAtPos(pos) + if item is None: + return + data, index, rect = item + if data.rownames is None: + return + ttip = data.rownames[index] + if ttip: + view = event.widget().parentWidget() + rect = view.mapFromScene(self.mapToScene(rect)).boundingRect() + show_tool_tip(event.screenPos(), ttip, event.widget(), rect) + event.setAccepted(True) + def __setHoveredItem(self, item): # Set the current hovered `item` (:class:`QGraphicsRectItem`) if self.__hoveredItem is not item: @@ -951,35 +1045,36 @@ def itemAtPos(self, pos): else: return None - def indexAtPos(self, pos): - items = [item for item in self.__plotItems() - if item.geometry().contains(pos)] + def __itemDataAtPos(self, pos) -> Optional[Tuple[namespace, int, QRectF]]: + items = [(sitem, tlist, grp) for sitem, tlist, grp + in zip(self.__plotItems(), self.__textItems(), self.__groups) + if sitem.geometry().contains(pos) or tlist.isVisible() + and tlist.geometry().contains(pos)] if not items: - return -1 + return None else: - item = items[0] - indices = item.data(0) + sitem, _, grp = items[0] + indices = grp.indices assert (isinstance(indices, np.ndarray) and - indices.shape == (item.count(),)) - crect = item.contentsRect() - pos = item.mapFromParent(pos) - if not crect.contains(pos): - return -1 - - assert pos.x() >= 0 - rowh = crect.height() / item.count() - index = np.floor(pos.y() / rowh) + indices.shape == (sitem.count(),)) + crect = sitem.contentsRect() + pos = sitem.mapFromParent(pos) + if not crect.top() <= pos.y() <= crect.bottom(): + return None + rowh = crect.height() / sitem.count() + index = int(np.floor(pos.y() / rowh)) index = min(index, indices.size - 1) - - if index >= 0: - return indices[index] - else: - return -1 + baritem = sitem.items()[index] + rect = self.mapRectFromItem(baritem, baritem.rect()) + crect = self.contentsRect() + rect.setLeft(crect.left()) + rect.setRight(crect.right()) + return grp, index, rect def __selectionChanged(self, selected, deselected): for item, grp in zip(self.__plotItems(), self.__groups): select = np.flatnonzero( - np.in1d(grp.indices, selected, assume_unique=True)) + np.isin(grp.indices, selected, assume_unique=True)) items = item.items() if select.size: for i in select: @@ -987,7 +1082,7 @@ def __selectionChanged(self, selected, deselected): items[i].setBrush(QBrush(QColor(*color))) deselect = np.flatnonzero( - np.in1d(grp.indices, deselected, assume_unique=True)) + np.isin(grp.indices, deselected, assume_unique=True)) if deselect.size: for i in deselect: items[i].setBrush(QBrush(QColor(*grp.color))) @@ -1006,6 +1101,9 @@ def __textItems(self): assert isinstance(item, TextListWidget) yield item + def __axisItems(self): + return self.__topScale, self.__bottomScale + def setSelection(self, indices): indices = np.unique(np.asarray(indices, dtype=int)) select = np.setdiff1d(indices, self.__selection) @@ -1072,11 +1170,8 @@ def sizeHint(self, which, constraint=QRectF()): def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None - palette = option.palette # type: QPalette - role = QPalette.WindowText - if widget is not None: - role = widget.foregroundRole() - color = palette.color(role) + palette = self.palette() # type: QPalette + color = palette.color(QPalette.WindowText) painter.setPen(QPen(color, 1)) rect = self.contentsRect() center = rect.center() @@ -1118,7 +1213,8 @@ def event(self, event): return super().event(event) def sizeHint(self, which, constraint=QSizeF()): - return QSizeF(300, (self.__barsize + self.__spacing) * self.count()) + spacing = max(self.__spacing * (self.count() - 1), 0) + return QSizeF(300, self.__barsize * self.count() + spacing) def setPreferredBarSize(self, size): if self.__barsize != size: @@ -1128,6 +1224,11 @@ def setPreferredBarSize(self, size): def spacing(self): return self.__spacing + def setSpacing(self, spacing): + if self.__spacing != spacing: + self.__spacing = spacing + self.updateGeometry() + def setPen(self, pen): pen = QPen(pen) if self.__pen != pen: @@ -1208,4 +1309,4 @@ def __layout(self): if __name__ == "__main__": # pragma: no cover - WidgetPreview(OWSilhouettePlot).run(Orange.data.Table("iris")) + WidgetPreview(OWSilhouettePlot).run(Orange.data.Table("brown-selected")) diff --git a/Orange/widgets/visualize/owtreeviewer.py b/Orange/widgets/visualize/owtreeviewer.py index 6bef257b4e6..af9c9a3a20b 100644 --- a/Orange/widgets/visualize/owtreeviewer.py +++ b/Orange/widgets/visualize/owtreeviewer.py @@ -1,4 +1,8 @@ """Widget for visualization of tree models""" +import re +from html import escape +from typing import Optional + import numpy as np from AnyQt.QtWidgets import ( @@ -7,22 +11,28 @@ ) from AnyQt.QtGui import QColor, QBrush, QPen, QFontMetrics from AnyQt.QtCore import Qt, QPointF, QSizeF, QRectF + from orangewidget.utils.combobox import ComboBoxSearch +from orangewidget.utils.itemmodels import PyListModel from Orange.base import TreeModel, SklModel +from Orange.widgets import gui from Orange.widgets.utils.signals import Input, Output +from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.visualize.owtreeviewer2d import \ GraphicsNode, GraphicsEdge, OWTreeViewer2D from Orange.widgets.utils import to_html +from Orange.widgets.utils.localization import pl from Orange.data import Table +from Orange.util import color_to_hex from Orange.widgets.settings import ContextSetting, ClassValuesContextHandler, \ Setting from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) -from Orange.widgets.visualize.utils.tree.skltreeadapter import SklTreeAdapter -from Orange.widgets.visualize.utils.tree.treeadapter import TreeAdapter +from Orange.utils.tree.skltreeadapter import SklTreeAdapter +from Orange.utils.tree.treeadapter import TreeAdapter class PieChart(QGraphicsRectItem): @@ -123,7 +133,7 @@ def boundingRect(self): QSizeF(self.attr_text_w, self.attr_text_h)) else: attr_rect = QRectF(0, 0, 1, 1) - rect = self.rect().adjusted(-5, -5, 5, 5) + rect = self.rect().adjusted(-6, -6, 6, 6) return rect | attr_rect def paint(self, painter, option, widget=None): @@ -142,7 +152,11 @@ def paint(self, painter, option, widget=None): painter.drawText(QPointF(x, -self.line_descent - 1), draw_text) painter.save() painter.setBrush(self.backgroundBrush) - painter.setPen(QPen(Qt.black, 3 if self.isSelected() else 0)) + if self.isSelected(): + outline = QPen(option.palette.highlight(), 3) + else: + outline = QPen(option.palette.dark(), 1) + painter.setPen(outline) adjrect = rect.adjusted(-3, 0, 0, 0) if not self.tree_adapter.has_children(self.node_inst): painter.drawRoundedRect(adjrect, 4, 4) @@ -159,7 +173,7 @@ class OWTreeGraph(OWTreeViewer2D): name = "Tree Viewer" icon = "icons/TreeViewer.svg" priority = 35 - keywords = [] + keywords = "tree viewer" class Inputs: # Had different input names before merging from @@ -173,6 +187,9 @@ class Outputs: settingsHandler = ClassValuesContextHandler() target_class_index = ContextSetting(0) regression_colors = Setting(0) + # None is a hint, "" means 'no hint' + node_labels_hint: Optional[str] = ContextSetting("") + show_intermediate = Setting(False) replaces = [ "Orange.widgets.classify.owclassificationtreegraph.OWClassificationTreeGraph", @@ -186,8 +203,8 @@ def __init__(self): super().__init__() self.domain = None self.dataset = None - self.clf_dataset = None self.tree_adapter = None + self.node_labels = None self.color_label = QLabel("Target class: ") combo = self.color_combo = ComboBoxSearch() @@ -198,6 +215,31 @@ def __init__(self): combo.activated[int].connect(self.color_changed) self.display_box.layout().addRow(self.color_label, combo) + self.label_model = DomainModel( + placeholder="None", + order=(DomainModel.METAS, + PyListModel.Separator, + DomainModel.ATTRIBUTES) + ) + combo = gui.comboBox( + None, self, "node_labels", + model=self.label_model, + orientation=Qt.Horizontal, + callback=self.label_changed, + sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, + sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed), + minimumContentsLength=8, + tooltip="Variable that identifies the instances in nodes." + ) + self.display_box.layout().addRow("Node labels:", combo) + + box = gui.hBox(None) + gui.rubber(box) + gui.checkBox(box, self, "show_intermediate", + "Show details in non-leaves", + callback=self.set_node_info) + self.display_box.layout().addRow(box) + def set_node_info(self): """Set the content of the node""" for node in self.scene.nodes(): @@ -214,7 +256,9 @@ def set_node_info(self): def _update_node_info_attr_name(self, node, text): attr = self.tree_adapter.attribute(node.node_inst) if attr is not None: - text += "
      {}".format(attr.name) + if text: + text += "
      " + text += attr.name return text def activate_loaded_settings(self): @@ -238,6 +282,10 @@ def color_changed(self, i): self.regression_colors = i self.toggle_node_color_reg() + def label_changed(self): + self.node_labels_hint = self.node_labels and self.node_labels.name + self.set_node_info() + def toggle_node_size(self): self.set_node_info() self.scene.update() @@ -262,39 +310,72 @@ def ctree(self, model=None): self.model = model self.target_class_index = 0 if model is None: - self.infolabel.setText('No tree.') - self.root_node = None - self.dataset = None - self.tree_adapter = None + self._ctree_clean() else: - self.tree_adapter = self._get_tree_adapter(model) - self.domain = model.domain - self.dataset = model.instances - if self.dataset is not None and self.dataset.domain != self.domain: - self.clf_dataset = self.dataset.transform(model.domain) - else: - self.clf_dataset = self.dataset - class_var = self.domain.class_var - self.scene.colors = class_var.palette - if class_var.is_discrete: - self.color_label.setText("Target class: ") - self.color_combo.addItem("None") - self.color_combo.addItems(self.domain.class_vars[0].values) - self.color_combo.setCurrentIndex(self.target_class_index) - else: - self.color_label.setText("Color by: ") - self.color_combo.addItems(self.COL_OPTIONS) - self.color_combo.setCurrentIndex(self.regression_colors) - self.openContext(self.domain.class_var) - # self.root_node = self.walkcreate(model.root, None) - self.root_node = self.walkcreate(self.tree_adapter.root) - self.infolabel.setText('{} nodes, {} leaves'.format( - self.tree_adapter.num_nodes, - len(self.tree_adapter.leaves(self.tree_adapter.root)))) + self._ctree_setup(model) + self.setup_scene() self.Outputs.selected_data.send(None) self.Outputs.annotated_data.send(create_annotated_table(self.dataset, [])) + def _ctree_clean(self): + self.infolabel.setText('No tree.') + self.label_model.set_domain(None) + self.root_node = None + self.dataset = None + self.tree_adapter = None + self.node_labels = None + + def _ctree_setup(self, model): + self.tree_adapter = self._get_tree_adapter(model) + self.domain = model.domain + self.dataset = model.instances + class_var = self.domain.class_var + self.scene.colors = class_var.palette + if class_var.is_discrete: + self.color_label.setText("Target class: ") + self.color_combo.addItem("None") + self.color_combo.addItems(self.domain.class_vars[0].values) + self.color_combo.setCurrentIndex(self.target_class_index) + else: + self.color_label.setText("Color by: ") + self.color_combo.addItems(self.COL_OPTIONS) + self.color_combo.setCurrentIndex(self.regression_colors) + + self.openContext(self.domain) + + self.set_node_labels(model) + self.root_node = self.walkcreate(self.tree_adapter.root) + nodes = self.tree_adapter.num_nodes + leaves = len(self.tree_adapter.leaves(self.tree_adapter.root)) + self.infolabel.setText(f'{nodes} {pl(nodes, "node")}, {leaves} {pl(leaves, "leaf|leaves")}') + + def set_node_labels(self, model): + # Note: This function set the instance label but not the hint + # Hints are only set by users. If the label is set heuristically + # it will be set to the same (heuristic) value next time anyway. + + # Set node_labels to None before changing the model, + # for the sake of hygiene + self.node_labels = None + self.label_model.set_domain(model.instances and model.instances.domain) + + # If we have no data or the hint say to not use labels, leave it None + if model.instances is None or self.node_labels_hint is None: + return + + if self.node_labels_hint in self.domain: + # Use the hint if you can + self.node_labels = self.domain[self.node_labels_hint] + else: + nunique, var = max( + ((len(set(self.dataset.get_column(v))), v) + for v in self.domain.metas if v.is_string), + key=lambda x: x[0], + default=(0, None)) + if nunique > 0.8 * len(self.dataset): + self.node_labels = var + def walkcreate(self, node, parent=None): """Create a structure of tree nodes from the given model""" node_obj = TreeNode(self.tree_adapter, node, parent) @@ -309,8 +390,44 @@ def walkcreate(self, node, parent=None): return node_obj def node_tooltip(self, node): - return "
      ".join(to_html(str(rule)) - for rule in self.tree_adapter.rules(node.node_inst)) + # We use
      and  : styling of
    • in Qt doesn't work well + indent = "   " + nbp = "

      " + + rule = "
      ".join(f"{indent}– {to_html(str(rule))}" + for rule in self.tree_adapter.rules(node.node_inst)) + if rule: + rule = f"

      Selection

      {rule}

      " + + distr = self.tree_adapter.get_distribution(node.node_inst)[0] + class_var = self.domain.class_var + name = escape(class_var.name) + if self.domain.class_var.is_discrete: + total = float(sum(distr)) or 1 + show_all = len(distr) <= 2 + content = f"{nbp}Distribution of '{name}'

      " \ + + "" + "".join( + "" + f"" + f"" + f"" + f"" + f"" + "" + for value, color, prop + in zip(class_var.values, class_var.colors, distr) + if show_all or prop > 0) \ + + "
      " + f"{escape(value)}{indent}{prop:g}{indent}{prop / total * 100:.1f} %
      " + else: + mean, var = distr + content = f"{nbp}{class_var.name} = {mean:.3g} ± {var:.3g}
      " + \ + f"({self.tree_adapter.num_samples(node.node_inst)} instances)

      " + + split = self._update_node_info_attr_name(node, "") + if split: + split = f"{nbp}Next split: {split}

      " + return "
      ".join(filter(None, (rule, content, split))) def update_selection(self): if self.model is None: @@ -336,15 +453,31 @@ def send_report(self): elif self.regression_colors != self.COL_DEFAULT: items.append(("Color by", self.COL_OPTIONS[self.regression_colors])) self.report_items(items) - self.report_plot(self.scene) + self.report_plot() def update_node_info(self, node): - if self.domain.class_var.is_discrete: - self.update_node_info_cls(node) + if self.tree_adapter.has_children(node.node_inst) and not self.show_intermediate: + text = "" + elif self.domain.class_var.is_discrete: + text = self.node_content_cls(node) else: - self.update_node_info_reg(node) + text = self.node_content_reg(node) + + text = self._update_node_info_attr_name(node, text) + if self.node_labels is not None and not self.tree_adapter.has_children(node.node_inst): + text += "
      " + data = self.tree_adapter.get_instances_in_nodes([node.node_inst]) + var = self.node_labels + labels = [escape(var.str_val(label)) + for label in data.get_column(var)[:4]] + text += ", ".join(labels) + if len(data) > 4: + text += ", …" - def update_node_info_cls(self, node): + node.setHtml( + f'

      {text}

      ') + + def node_content_cls(self, node): """Update the printed contents of the node for classification trees""" node_inst = node.node_inst distr = self.tree_adapter.get_distribution(node_inst)[0] @@ -356,26 +489,21 @@ def update_node_info_cls(self, node): else: modus = np.argmax(distr) tabs = distr[modus] - text = f"{self.domain.class_vars[0].values[int(modus)]}
      " + text = f"{escape(self.domain.class_vars[0].values[int(modus)])}
      " if tabs > 0.999: text += f"100%, {total}/{total}" else: text += f"{100 * tabs:2.1f}%, {int(total * tabs)}/{total}" + return text - text = self._update_node_info_attr_name(node, text) - node.setHtml( - f'

      {text}

      ') - - def update_node_info_reg(self, node): + def node_content_reg(self, node): """Update the printed contents of the node for regression trees""" node_inst = node.node_inst mean, var = self.tree_adapter.get_distribution(node_inst)[0] insts = self.tree_adapter.num_samples(node_inst) text = f"{mean:.1f} ± {var:.1f}
      " text += f"{insts} instances" - text = self._update_node_info_attr_name(node, text) - node.setHtml( - f'

      {text}

      ') + return text def toggle_node_color_cls(self): """Update the node color for classification trees""" @@ -386,12 +514,12 @@ def toggle_node_color_cls(self): if self.target_class_index: p = distr[self.target_class_index - 1] / total color = colors[self.target_class_index - 1].lighter( - 200 - 100 * p) + int(200 - 100 * p)) else: modus = np.argmax(distr) p = distr[modus] / (total or 1) color = colors.value_to_qcolor(int(modus)) - color = color.lighter(300 - 200 * p) + color = color.lighter(int(300 - 200 * p)) node.backgroundBrush = QBrush(color) self.scene.update() @@ -409,7 +537,7 @@ def toggle_node_color_reg(self): node_insts = len(self.tree_adapter.get_instances_in_nodes( [node.node_inst])) node.backgroundBrush = QBrush(def_color.lighter( - 120 - 20 * node_insts / max_insts)) + int(120 - 20 * node_insts / max_insts))) elif self.regression_colors == self.COL_MEAN: minv = np.nanmin(self.dataset.Y) maxv = np.nanmax(self.dataset.Y) @@ -425,7 +553,7 @@ def toggle_node_color_reg(self): max_var = max(variances) for node, var in zip(nodes, variances): node.backgroundBrush = QBrush(def_color.lighter( - 120 - 20 * var / max_var)) + int(120 - 20 * var / max_var))) self.scene.update() def _get_tree_adapter(self, model): diff --git a/Orange/widgets/visualize/owtreeviewer2d.py b/Orange/widgets/visualize/owtreeviewer2d.py index bace9ec1aea..2becbc9018f 100644 --- a/Orange/widgets/visualize/owtreeviewer2d.py +++ b/Orange/widgets/visualize/owtreeviewer2d.py @@ -244,7 +244,6 @@ def __init__(self, scene, *args): self.setFocusPolicy(Qt.WheelFocus) self.setRenderHint(QPainter.Antialiasing) self.setRenderHint(QPainter.TextAntialiasing) - self.setRenderHint(QPainter.HighQualityAntialiasing) def resizeEvent(self, event): super().resizeEvent(event) @@ -370,7 +369,7 @@ class OWTreeViewer2D(OWWidget, openclass=True): _DEF_NODE_WIDTH = 24 _DEF_NODE_HEIGHT = 20 - graph_name = "scene" + graph_name = "scene" # QGraphicsScene (TreeGraphicsScene) def __init__(self): super().__init__() @@ -404,7 +403,7 @@ def __init__(self): "Depth: ", gui.comboBox(box, self, 'max_tree_depth', items=["Unlimited"] + [ - "{} levels".format(x) for x in range(2, 10)], + f"{x} levels" for x in range(2, 10)], addToLayout=False, sendSelectedValue=False, callback=self.toggle_tree_depth, sizePolicy=policy)) layout.addRow( @@ -418,6 +417,10 @@ def __init__(self): self.scene = TreeGraphicsScene(self) self.scene_view = TreeGraphicsView(self.scene) + self.scene_view.setStyleSheet("""QToolTip { padding: 3px; + border: 1px solid #C0C0C0; + }""") + self.scene_view.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) self.mainArea.layout().addWidget(self.scene_view) self.toggle_zoom_slider() diff --git a/Orange/widgets/visualize/owvenndiagram.py b/Orange/widgets/visualize/owvenndiagram.py index 4533cfa189a..355e822d4c2 100644 --- a/Orange/widgets/visualize/owvenndiagram.py +++ b/Orange/widgets/visualize/owvenndiagram.py @@ -11,6 +11,7 @@ from functools import reduce from operator import attrgetter from xml.sax.saxutils import escape +from typing import Dict, Any, List, Mapping, Optional import numpy as np @@ -32,9 +33,9 @@ from Orange.widgets.utils import itemmodels, colorpalettes from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) -from Orange.widgets.utils.sql import check_sql_input +from Orange.widgets.utils.sql import check_sql_input_sequence from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import Input, Output, Msg +from Orange.widgets.widget import MultiInput, Output, Msg _InputData = namedtuple("_InputData", ["key", "name", "table"]) @@ -65,11 +66,11 @@ class OWVennDiagram(widget.OWWidget): "from a collection of input datasets." icon = "icons/VennDiagram.svg" priority = 280 - keywords = [] + keywords = "venn diagram" settings_version = 2 class Inputs: - data = Input("Data", Table, multiple=True) + data = MultiInput("Data", Table) class Outputs: selected_data = Output("Selected Data", Table, default=True) @@ -96,7 +97,7 @@ class Warning(widget.OWWidget.Warning): selected_feature = ContextSetting(IDENTITY_STR) want_main_area = False - graph_name = "scene" + graph_name = "scene" # QGraphicsScene atr_types = ['attributes', 'metas', 'class_vars'] atr_vals = {'metas': 'metas', 'attributes': 'X', 'class_vars': 'Y'} row_vals = {'attributes': 'x', 'class_vars': 'y', 'metas': 'metas'} @@ -106,10 +107,11 @@ def __init__(self): # Diagram update is in progress self._updating = False - # Input update is in progress - self._inputUpdate = False - # Input datasets in the order they were 'connected'. - self.data = {} + self.__id_gen = count() # 'key' generator for _InputData + #: Connected input dataset signals. + self._data_inputs: List[_InputData] = [] + # Input non-none datasets in the order they were 'connected'. + self.__data: Optional[Dict[Any, _InputData]] = None # Extracted input item sets in the order they were 'connected' self.itemsets = {} # A list with 2 ** len(self.data) elements that store item sets @@ -165,9 +167,8 @@ def __init__(self): callback=lambda: self.commit(), # pylint: disable=unnecessary-lambda stateWhenDisabled=False, attribute=Qt.WA_LayoutUsesWidgetRect) - auto = gui.auto_send(box, self, "autocommit", - box=False, - contentsMargins=(0, 0, 0, 0)) + gui.auto_send( + box, self, "autocommit", box=False, contentsMargins=(0, 0, 0, 0)) gui.rubber(box) self._update_duplicates_cb() self._queue = [] @@ -187,30 +188,48 @@ def _resize(self): self.vennwidget.resize(size, size) self.scene.setSceneRect(self.scene.itemsBoundingRect()) + @property + def data(self) -> Mapping[Any, _InputData]: + if self.__data is None: + self.__data = { + item.key: item for item in self._data_inputs[:5] + if item.table is not None + } + return self.__data + @Inputs.data - @check_sql_input - def setData(self, data, key=None): - self.Error.too_many_inputs.clear() - if not self._inputUpdate: - self._inputUpdate = True - if key in self.data: - if data is None: - # Remove the input - # Clear possible warnings. - self.Warning.clear() - del self.data[key] - else: - # Update existing item - self.data[key] = self.data[key]._replace(name=data.name, table=data) - - elif data is not None: - # TODO: Allow setting more them 5 inputs and let the user - # select the 5 to display. - if len(self.data) == 5: - self.Error.too_many_inputs() - return - # Add a new input - self.data[key] = _InputData(key, data.name, data) + @check_sql_input_sequence + def setData(self, index: int, data: Optional[Table]): + item = self._data_inputs[index] + item = item._replace( + name=data.name if data else "", + table=data or None + ) + self._data_inputs[index] = item + self.__data = None # invalidate self.data + self._setInterAttributes() + + @Inputs.data.insert + @check_sql_input_sequence + def insertData(self, index: int, data: Optional[Table]): + key = next(self.__id_gen) + item = _InputData( + key, name=data.name if data is not None else "", table=data + ) + self._data_inputs.insert(index, item) + self.__data = None # invalidate self.data + if len(self._data_inputs) > 5: + self.Error.too_many_inputs() + self._setInterAttributes() + + @Inputs.data.remove + def removeData(self, index: int): + self.__data = None # invalidate self.data + self._data_inputs.pop(index) + if len(self._data_inputs) <= 5: + self.Error.too_many_inputs.clear() + # Clear possible warnings. + self.Warning.clear() self._setInterAttributes() def data_equality(self): @@ -234,7 +253,6 @@ def settings_compatible(self): return True def handleNewSignals(self): - self._inputUpdate = False self.vennwidget.clear() if not self.settings_compatible(): self.invalidateOutput() @@ -243,9 +261,9 @@ def handleNewSignals(self): self._createItemsets() self._createDiagram() # If autocommit is enabled, _createDiagram already outputs data - # If not, call unconditional_commit from here + # If not, call commit from here if not self.autocommit: - self.unconditional_commit() + self.commit.now() super().handleNewSignals() @@ -400,7 +418,7 @@ def _on_itemTextEdited(self, index, text): self.itemsets[key] = self.itemsets[key]._replace(title=text) def invalidateOutput(self): - self.commit() + self.commit.deferred() def merge_data(self, domain, values, ids=None): X, metas, class_vars = None, None, None @@ -691,6 +709,7 @@ def extract_rowwise_duplicates(self, var_dict, ids): 'class_vars': [np.vstack(all_y)]} return self.merge_data(domain, values, np.vstack(new_table_ids)) + @gui.deferred def commit(self): if not self.vennwidget.vennareas() or not self.data: self.Outputs.selected_data.send(None) @@ -964,11 +983,11 @@ def setItems(self, items): font = self.font() font.setPixelSize(14) - + palette = self.palette() for item in items: text = GraphicsTextEdit(self) text.setFont(font) - text.setDefaultTextColor(QColor("#333")) + text.setDefaultTextColor(palette.color(QPalette.Text)) text.setHtml(fmt(escape(item.text), item.informativeText)) text.adjustSize() text.editingStarted.connect(self._on_editingStarted) @@ -1450,18 +1469,18 @@ def main(): # pragma: no cover res = ShuffleSplit(n_resamples=5, test_size=0.7, stratified=False) indices = iter(res.get_indices(data)) datasets = [] - for i in range(1, 6): + for i in range(5): sample, _ = next(indices) data1 = data[sample] data1.name = chr(ord("A") + i) - datasets.append((data1, i)) + datasets.append((i, data1)) else: domain = data.domain data1 = data.transform(Domain(domain.attributes[:15], domain.class_var)) data2 = data.transform(Domain(domain.attributes[10:], domain.class_var)) - datasets = [(data1, 1), (data2, 2)] + datasets = [(0, data1), (1, data2)] - WidgetPreview(OWVennDiagram).run(setData=datasets) + WidgetPreview(OWVennDiagram).run(insertData=datasets) if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/visualize/owviolinplot.py b/Orange/widgets/visualize/owviolinplot.py index 42270578e19..ebe89aa6d28 100644 --- a/Orange/widgets/visualize/owviolinplot.py +++ b/Orange/widgets/visualize/owviolinplot.py @@ -29,7 +29,8 @@ from Orange.widgets.visualize.owboxplot import SortProxyModel from Orange.widgets.visualize.utils.customizableplot import \ CommonParameterSetter, Updater -from Orange.widgets.visualize.utils.plotutils import AxisItem +from Orange.widgets.visualize.utils.plotutils import PlotWidget +from Orange.widgets.visualize.owscatterplotgraph import AxisItem from Orange.widgets.widget import OWWidget, Input, Output, Msg # scaling types @@ -369,14 +370,15 @@ def paint(self, painter: QPainter, *_): painter.restore() -class ViolinPlot(pg.PlotWidget): +class ViolinPlot(PlotWidget): VIOLIN_PADDING_FACTOR = 1.25 SELECTION_PADDING_FACTOR = 1.20 selection_changed = Signal(list, list) def __init__(self, parent: OWWidget, kernel: str, scale: int, orientation: Qt.Orientations, show_box_plot: bool, - show_strip_plot: bool, show_rug_plot: bool, sort_items: bool): + show_strip_plot: bool, show_rug_plot: bool, show_grid: bool, + sort_items: bool): # data self.__values: Optional[np.ndarray] = None @@ -391,6 +393,7 @@ def __init__(self, parent: OWWidget, kernel: str, scale: int, self.__show_box_plot = show_box_plot self.__show_strip_plot = show_strip_plot self.__show_rug_plot = show_rug_plot + self.__show_grid = show_grid self.__sort_items = sort_items # items @@ -405,7 +408,7 @@ def __init__(self, parent: OWWidget, kernel: str, scale: int, view_box = ViolinPlotViewBox(self) super().__init__(parent, viewBox=view_box, - background="w", enableMenu=False, + enableMenu=False, axisItems={"bottom": AxisItem("bottom"), "left": AxisItem("left")}) self.setAntialiasing(True) @@ -486,6 +489,11 @@ def set_show_rug_plot(self, show: bool): for item in self.__violin_items: item.set_show_rug_plot(show) + def set_show_grid(self, show: bool): + if self.__show_grid != show: + self.__show_grid = show + self._update_grid() + def set_sort_items(self, sort_items: bool): if self.__sort_items != sort_items: self.__sort_items = sort_items @@ -548,14 +556,19 @@ def set_selection(self, ranges: List[Optional[Tuple[float, float]]]): def _set_axes(self): if self.__value_var is None: return - value_title = self.__value_var.name - group_title = self.__group_var.name if self.__group_var else "" vertical = self.__orientation == Qt.Vertical - self.getAxis("left" if vertical else "bottom").setLabel(value_title) - self.getAxis("bottom" if vertical else "left").setLabel(group_title) - if self.__group_var is None: - self.getAxis("bottom" if vertical else "left").setTicks([]) + value_axis = self.getAxis("left" if vertical else "bottom") + value_axis.setLabel(self.__value_var.name) + value_axis.use_time(self.__value_var.is_time) + + group_axis = self.getAxis("bottom" if vertical else "left") + group_axis.use_time(False) + if self.__group_var: + group_axis.setLabel(self.__group_var.name) + else: + group_axis.setLabel("") + group_axis.setTicks([]) def _plot_data(self): # save selection ranges @@ -578,6 +591,11 @@ def _plot_data(self): # apply selection ranges self._selection_ranges = ranges + self._update_grid() + + def _update_grid(self): + self.showGrid(x=self.__show_grid and self.__orientation == Qt.Horizontal, + y=self.__show_grid and self.__orientation == Qt.Vertical) def _set_violin_item(self, values: np.ndarray, color: QColor): values = values[~np.isnan(values)] @@ -639,8 +657,8 @@ def _clear_data_items(self): self.__selection_rects.clear() def _clear_axes(self): - self.setAxisItems({"bottom": AxisItem(orientation="bottom"), - "left": AxisItem(orientation="left")}) + self.getAxis("left").setTicks(None) + self.getAxis("bottom").setTicks(None) Updater.update_axes_titles_font( self.parameter_setter.axis_items, **self.parameter_setter.titles_settings @@ -718,7 +736,7 @@ class OWViolinPlot(OWWidget): " values in a violin plot." icon = "icons/ViolinPlot.svg" priority = 110 - keywords = ["kernel", "density"] + keywords = "violin plot, kernel, density" class Inputs: data = Input("Data", Table) @@ -743,6 +761,7 @@ class Error(OWWidget.Error): show_box_plot = Setting(True) show_strip_plot = Setting(False) show_rug_plot = Setting(False) + show_grid = Setting(False) order_violins = Setting(False) orientation_index = Setting(1) # Vertical kernel_index = Setting(0) # Normal kernel @@ -750,7 +769,7 @@ class Error(OWWidget.Error): selection_ranges = Setting([], schema_only=True) visual_settings = Setting({}, schema_only=True) - graph_name = "graph.plotItem" + graph_name = "graph.plotItem" # QGraphicsView (pg.PlotWidget -> ViolinPlot) buttons_area_orientation = None def __init__(self): @@ -781,7 +800,8 @@ def _add_graph(self): self.graph = ViolinPlot(self, self.kernel, self.scale_index, self.orientation, self.show_box_plot, self.show_strip_plot, - self.show_rug_plot, self.order_violins) + self.show_rug_plot, self.show_grid, + self.order_violins) self.graph.selection_changed.connect(self.__selection_changed) box.layout().addWidget(self.graph) @@ -836,14 +856,18 @@ def _add_controls(self): sizePolicy=(QSizePolicy.Minimum, QSizePolicy.Maximum)) gui.checkBox(box, self, "show_box_plot", "Box plot", callback=self.__show_box_plot_changed) - gui.checkBox(box, self, "show_strip_plot", "Strip plot", + gui.checkBox(box, self, "show_strip_plot", "Density dots", callback=self.__show_strip_plot_changed) - gui.checkBox(box, self, "show_rug_plot", "Rug plot", + gui.checkBox(box, self, "show_rug_plot", "Density lines", callback=self.__show_rug_plot_changed) self._order_violins_cb = gui.checkBox( box, self, "order_violins", "Order subgroups", callback=self.__order_violins_changed, ) + gui.checkBox( + box, self, "show_grid", "Show grid", + callback=self.__show_grid_changed, + ) gui.radioButtons(box, self, "orientation_index", ["Horizontal", "Vertical"], label="Orientation: ", orientation=Qt.Horizontal, @@ -889,6 +913,9 @@ def __show_rug_plot_changed(self): def __order_violins_changed(self): self.graph.set_sort_items(self.order_violins) + def __show_grid_changed(self): + self.graph.set_show_grid(self.show_grid) + def __orientation_changed(self): self.graph.set_orientation(self.orientation) @@ -975,7 +1002,7 @@ def apply_value_var_sorting(self): def compute_score(attr): if attr is group_var: return 3 - col = self.data.get_column_view(attr)[0].astype(float) + col = self.data.get_column(attr) groups = (col[group_col == i] for i in range(n_groups)) groups = (col[~np.isnan(col)] for col in groups) groups = [group for group in groups if len(group)] @@ -989,7 +1016,7 @@ def compute_score(attr): group_var = self.group_var if self.order_by_importance and group_var is not None: n_groups = len(group_var.values) - group_col = self.data.get_column_view(group_var)[0].astype(float) + group_col = self.data.get_column(group_var) self._sort_list(self._value_var_model, self._value_var_view, compute_score) else: @@ -1001,7 +1028,7 @@ def compute_stat(group): return 3 if group is None: return -1 - col = self.data.get_column_view(group)[0].astype(float) + col = self.data.get_column(group) groups = (value_col[col == i] for i in range(len(group.values))) groups = (col[~np.isnan(col)] for col in groups) groups = [group for group in groups if len(group)] @@ -1014,7 +1041,7 @@ def compute_stat(group): return value_var = self.value_var if self.order_grouping_by_importance: - value_col = self.data.get_column_view(value_var)[0].astype(float) + value_col = self.data.get_column(value_var) self._sort_list(self._group_var_model, self._group_var_view, compute_stat) else: @@ -1047,10 +1074,10 @@ def setup_plot(self): if not self.data: return - y = self.data.get_column_view(self.value_var)[0].astype(float) + y = self.data.get_column(self.value_var) x = None if self.group_var: - x = self.data.get_column_view(self.group_var)[0].astype(float) + x = self.data.get_column(self.group_var) self.graph.set_data(y, self.value_var, x, self.group_var) def apply_selection(self): diff --git a/Orange/widgets/visualize/pythagorastreeviewer.py b/Orange/widgets/visualize/pythagorastreeviewer.py index 63a2b3783e3..cab878dc811 100644 --- a/Orange/widgets/visualize/pythagorastreeviewer.py +++ b/Orange/widgets/visualize/pythagorastreeviewer.py @@ -27,8 +27,8 @@ ) from Orange.widgets.utils import to_html -from Orange.widgets.visualize.utils.tree.rules import Rule -from Orange.widgets.visualize.utils.tree.treeadapter import TreeAdapter +from Orange.utils.tree.rules import Rule +from Orange.utils.tree.treeadapter import TreeAdapter # z index range, increase if needed Z_STEP = 5000000 @@ -42,9 +42,7 @@ class PythagorasTreeViewer(QGraphicsWidget): Examples -------- - >>> from Orange.widgets.visualize.utils.tree.treeadapter import ( - ... TreeAdapter - ... ) + >>> from Orange.utils.tree.treeadapter import TreeAdapter Pass tree through constructor. >>> tree_view = PythagorasTreeViewer(parent=scene, adapter=tree_adapter) @@ -629,12 +627,12 @@ def color(self): if self.target_class_index: p = distribution[self.target_class_index - 1] / total color = self.color_palette[self.target_class_index - 1] - color = color.lighter(200 - 100 * p) + color = color.lighter(int(200 - 100 * p)) else: modus = np.argmax(distribution) p = distribution[modus] / (total or 1) color = self.color_palette[int(modus)] - color = color.lighter(400 - 300 * p) + color = color.lighter(int(400 - 300 * p)) return color @property diff --git a/Orange/widgets/visualize/tests/test_owbarplot.py b/Orange/widgets/visualize/tests/test_owbarplot.py index 69b437016a4..848c6cfac33 100644 --- a/Orange/widgets/visualize/tests/test_owbarplot.py +++ b/Orange/widgets/visualize/tests/test_owbarplot.py @@ -21,7 +21,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWBarPlot.Inputs.data cls.signal_data = cls.data cls.titanic = Table("titanic") cls.housing = Table("housing") @@ -137,6 +137,21 @@ def test_group_axis(self): self.assertFalse(group_axis.isVisible()) self.assertFalse(annot_axis.isVisible()) + def test_annotate_by_enumeration(self): + widget = self.widget + + self.send_signal(widget.Inputs.data, self.data) + combo = widget.controls.annot_var + for i in range(combo.count()): + try: + simulate.combobox_activate_index(combo, i) + except AssertionError: # skip disabled items + pass + else: + labels = widget.get_labels() + self.assertTrue(not labels + or all(isinstance(x, str) for x in labels)) + def test_datasets(self): controls = self.widget.controls for ds in datasets.datasets(): @@ -293,13 +308,14 @@ def test_send_report(self): self.send_signal(self.widget.Inputs.data, None) self.widget.report_button.click() + @WidgetTest.skipNonEnglish def test_visual_settings(self): graph = self.widget.graph font = QFont() font.setItalic(True) font.setFamily("Helvetica") - self.send_signal(self.widget.Inputs.data, self.data) + self.send_signal(self.widget.Inputs.data, self.data[50:]) key, value = ("Fonts", "Font family", "Font family"), "Helvetica" self.widget.set_visual_settings(key, value) @@ -358,6 +374,18 @@ def test_visual_settings(self): self.widget.set_visual_settings(key, value) self.assertTrue(graph.group_axis.style["rotateTicks"]) + key = "Figure", "Legend", "Hide empty categories in the legend" + value = True + self.assertEqual( + [i[1].text for i in graph.parameter_setter.legend_items], + ["Iris-setosa", "Iris-versicolor", "Iris-virginica"] + ) + self.widget.set_visual_settings(key, value) + self.assertEqual( + [i[1].text for i in graph.parameter_setter.legend_items], + ["Iris-versicolor", "Iris-virginica"] + ) + def assertFontEqual(self, font1, font2): self.assertEqual(font1.family(), font2.family()) self.assertEqual(font1.pointSize(), font2.pointSize()) @@ -374,11 +402,36 @@ def assertSelectedIndices(self, indices, data=None, widget=None): indices = self.widget.grouped_indices_inverted self.assertSetEqual(set(widget.graph.selection), set(indices)) pens = widget.graph.bar_item.opts["pens"] - self.assertTrue(all([pen.style() == 2 for i, pen + self.assertTrue(all([pen.style() == Qt.DashLine for i, pen in enumerate(pens) if i in indices])) - self.assertTrue(all([pen.style() == 1 for i, pen + self.assertTrue(all([pen.style() == Qt.SolidLine for i, pen in enumerate(pens) if i not in indices])) + @staticmethod + def _set_check(checkbox, value): + state = Qt.Checked if value else Qt.Unchecked + checkbox.setCheckState(state) + checkbox.toggled[bool].emit(value) + + def test_show_hide_legend(self): + widget = self.widget + legend = widget.graph.legend + + self._set_check(widget.controls.show_legend, False) + self._set_check(widget.controls.show_legend, True) + + self.send_signal(widget.Inputs.data, self.heart) + simulate.combobox_activate_index(widget.controls.color_var, 2) + self.assertTrue(legend.isVisible()) + self._set_check(widget.controls.show_legend, False) + self.assertFalse(legend.isVisible()) + self._set_check(widget.controls.show_legend, True) + self.assertTrue(legend.isVisible()) + + self.send_signal(widget.Inputs.data, None) + self._set_check(widget.controls.show_legend, False) + self._set_check(widget.controls.show_legend, True) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owboxplot.py b/Orange/widgets/visualize/tests/test_owboxplot.py index 4896051acef..504dab1a2f4 100644 --- a/Orange/widgets/visualize/tests/test_owboxplot.py +++ b/Orange/widgets/visualize/tests/test_owboxplot.py @@ -8,9 +8,8 @@ from Orange.data import Table, ContinuousVariable, StringVariable, Domain, \ DiscreteVariable -from Orange.widgets.visualize.owboxplot import ( - OWBoxPlot, FilterGraphicsRectItem, _quantiles -) +from Orange.widgets.visualize.owboxplot import OWBoxPlot, FilterGraphicsRectItem + from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin from Orange.tests import test_filename @@ -27,7 +26,7 @@ def setUpClass(cls): cls.titanic = Table("titanic") cls.heart = Table("heart_disease") cls.data = cls.iris - cls.signal_name = "Data" + cls.signal_name = OWBoxPlot.Inputs.data cls.signal_data = cls.data def setUp(self): @@ -66,25 +65,28 @@ def test_primitive_metas(self): def test_input_data_missings_cont_group_var(self): """Check widget with continuous data with missing values and group variable""" data = self.iris.copy() - data.X[:, 0] = np.nan + with data.unlocked(): + data.X[:, 0] = np.nan self.send_signal(self.widget.Inputs.data, data) # used to crash, see #1568 def test_input_data_missings_cont_no_group_var(self): """Check widget with continuous data with missing values and no group variable""" data = self.housing - data.X[:, 0] = np.nan + with data.unlocked(): + data.X[:, 0] = np.nan self.send_signal(self.widget.Inputs.data, data) # used to crash, see #1568 def test_input_data_missings_disc_group_var(self): """Check widget with discrete data with missing values and group variable""" data = self.zoo - data.X[:, 1] = np.nan + with data.unlocked(): + data.X[:, 1] = np.nan # This is a test and does it at its own risk: # pylint: disable=protected-access data.domain.attributes[1]._values = [] - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) self.widget.controls.order_by_importance.setChecked(True) self._select_list_items(self.widget.attr_list) self._select_list_items(self.widget.group_list) @@ -93,14 +95,28 @@ def test_input_data_missings_disc_no_group_var(self): """Check widget discrete data with missing values and no group variable""" data = self.zoo data.domain.class_var = ContinuousVariable("cls") - data.X[:, 1] = np.nan + with data.unlocked(): + data.X[:, 1] = np.nan # This is a test and does it at its own risk: # pylint: disable=protected-access data.domain.attributes[1]._values = [] - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) self._select_list_items(self.widget.attr_list) self._select_list_items(self.widget.group_list) + def test_no_attribute_or_group_values(self): + attrs = [DiscreteVariable(n, values=tuple("xyz"[:i])) + for i, n in enumerate("abc")] + n = np.nan + data = Table.from_numpy(Domain(attrs), + [[n, n, n], + [n, n, 0], + [n, 0, 1]]) + self.send_signal(data) + for self.widget.attribute in attrs: + for self.widget.group_var in attrs: + self.widget.update_graph() + def test_attribute_combinations(self): self.send_signal(self.widget.Inputs.data, self.heart) group_list = self.widget.group_list @@ -128,7 +144,7 @@ def select_group(i): data = self.titanic - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) select_group(2) # First attribute @@ -146,7 +162,7 @@ def select_group(i): ['sex', 'status', 'age', 'survived']) data = self.heart - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) select_group(1) # Class order_check.setChecked(True) self.assertEqual(self.model_order(model), @@ -179,7 +195,7 @@ def select_attr(i): attr_selection.ClearAndSelect) data = self.titanic - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) select_attr(1) # First attribute @@ -199,7 +215,7 @@ def select_attr(i): ['None', 'sex', 'status', 'age', 'survived']) data = self.heart - self.send_signal("Data", data) + self.send_signal(self.widget.Inputs.data, data) select_attr(0) # Class self.assertIsNone(groups[0]) self.assertEqual(self.model_order(model), @@ -253,7 +269,8 @@ def test_empty_groups(self): # select rows with US State equal to TX or MO use_indexes = np.array([0, 1, 25, 26, 27]) - table.X = table.X[use_indexes] + with table.unlocked(): + table.X = table.X[use_indexes] self.send_signal(self.widget.Inputs.data, table) self.assertEqual(2, len(self.widget.boxes)) @@ -276,8 +293,8 @@ def _select_data(self): if isinstance(item, FilterGraphicsRectItem)] items[0].setSelected(True) return [100, 103, 104, 108, 110, 111, 112, 115, 116, - 120, 123, 124, 126, 128, 132, 133, 136, 137, - 139, 140, 141, 143, 144, 145, 146, 147, 148] + 120, 123, 124, 128, 132, 133, 136, 137, 139, + 140, 141, 143, 144, 145, 146, 147] def _select_list_items(self, _list): for name in _list.model().sourceModel(): @@ -355,42 +372,5 @@ def test_valid_data_range(self): box.setSelected(True) -class TestUtils(unittest.TestCase): - def test(self): - np.testing.assert_array_equal( - _quantiles(range(1, 8 + 1), [1.] * 8, [0.0, 0.25, 0.5, 0.75, 1.0]), - [1., 2.5, 4.5, 6.5, 8.] - ) - np.testing.assert_array_equal( - _quantiles(range(1, 8 + 1), [1.] * 8, [0.0, 0.25, 0.5, 0.75, 1.0]), - [1., 2.5, 4.5, 6.5, 8.] - ) - np.testing.assert_array_equal( - _quantiles(range(1, 4 + 1), [1., 2., 1., 2], - [0.0, 0.25, 0.5, 0.75, 1.0]), - [1.0, 2.0, 2.5, 4.0, 4.0] - ) - np.testing.assert_array_equal( - _quantiles(range(1, 4 + 1), [2., 1., 1., 2.], - [0.0, 0.25, 0.5, 0.75, 1.0]), - [1.0, 1.0, 2.5, 4.0, 4.0] - ) - np.testing.assert_array_equal( - _quantiles(range(1, 4 + 1), [1., 1., 1., 1.], - [0.0, 0.25, 0.5, 0.75, 1.0]), - [1.0, 1.5, 2.5, 3.5, 4.0] - ) - np.testing.assert_array_equal( - _quantiles(range(1, 4 + 1), [1., 1., 1., 1.], - [0.0, 0.25, 0.5, 0.75, 1.0], interpolation="higher"), - [1, 2, 3, 4, 4] - ) - np.testing.assert_array_equal( - _quantiles(range(1, 4 + 1), [1., 1., 1., 1.], - [0.0, 0.25, 0.5, 0.75, 1.0], interpolation="lower"), - [1, 1, 2, 3, 4] - ) - - if __name__ == '__main__': unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owdistributions.py b/Orange/widgets/visualize/tests/test_owdistributions.py index 22eddc30cc2..4ee26863705 100644 --- a/Orange/widgets/visualize/tests/test_owdistributions.py +++ b/Orange/widgets/visualize/tests/test_owdistributions.py @@ -1,13 +1,20 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring,protected-access +from functools import partial + import os import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch import numpy as np -from AnyQt.QtCore import QItemSelection, Qt +from AnyQt.QtCore import QItemSelection, Qt, QEvent +from AnyQt.QtGui import QKeyEvent, QFont +from AnyQt.QtWidgets import QCheckBox + +from orangewidget.utils.combobox import qcombobox_emit_activated -from Orange.data import Table, Domain, DiscreteVariable +from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable +from Orange.preprocess import BinDefinition from Orange.widgets.tests.base import WidgetTest from Orange.widgets.utils.annotated_data import ANNOTATED_DATA_FEATURE_NAME from Orange.widgets.utils.itemmodels import DomainModel @@ -18,18 +25,17 @@ class TestOWDistributions(WidgetTest): def setUp(self): self.widget = self.create_widget(OWDistributions) #: OWDistributions self.iris = Table("iris") + self.heart = Table("heart_disease") def _set_cvar(self, cvar): combo = self.widget.controls.cvar self.widget.cvar = cvar - combo.activated[int].emit(combo.currentIndex()) - combo.activated[str].emit(combo.currentText()) + qcombobox_emit_activated(combo, combo.currentIndex()) def _set_fitter(self, i): combo = self.widget.controls.fitted_distribution combo.setCurrentIndex(i) - combo.activated[int].emit(combo.currentIndex()) - combo.activated[str].emit(combo.currentText()) + qcombobox_emit_activated(combo, i) def _set_var(self, var): listview = self.widget.controls.var @@ -43,8 +49,9 @@ def _set_var(self, var): selectionmodel.selectionChanged.emit(newselection, oldselection) @staticmethod - def _set_check(checkbox, value): - checkbox.setCheckState(value) + def _set_check(checkbox: QCheckBox, value: bool): + state = Qt.Checked if value else Qt.Unchecked + checkbox.setCheckState(state) checkbox.toggled[bool].emit(value) def _set_slider(self, i): @@ -74,7 +81,7 @@ def test_set_data(self): self.assertIsNone(self.get_output(widget.Outputs.selected_data)) # Data gone: clean up - widget.selection.add(0) + widget.selected_bars.add(widget.ordered_values[0]) widget._clear_plot = Mock() self.send_signal(widget.Inputs.data, None) self.assertEqual(len(var_model), 0) @@ -82,7 +89,7 @@ def test_set_data(self): self.assertIsNone(widget.var) self.assertIsNone(widget.cvar) - self.assertEqual(widget.selection, set()) + self.assertEqual(widget.selected_bars, set()) self.assertIsNone(widget.valid_data) self.assertIsNone(widget.valid_group_data) self.assertIsNone(self.get_output(widget.Outputs.histogram_data)) @@ -182,7 +189,7 @@ def test_histogram_data(self): self._set_var(self.iris.domain["sepal length"]) self._set_cvar(self.iris.domain["iris"]) hist = self.get_output(widget.Outputs.histogram_data) - self.assertTrue(len(hist)>0 and len(hist)%3==0) + self.assertTrue(len(hist) > 0 and len(hist) % 3 == 0) def test_switch_var(self): """Widget reset and recomputes when changing var""" @@ -191,9 +198,9 @@ def test_switch_var(self): self.send_signal(widget.Inputs.data, self.iris) binnings = widget.binnings.copy() valid_data = widget.valid_data.copy() - widget.selection.add(1) + widget.selected_bars.add(widget.ordered_values[1]) widget._clear_plot = Mock() - widget.apply = Mock() + widget.apply.now = widget.apply.deferred = Mock() self._set_var(2) self.assertFalse( @@ -202,9 +209,9 @@ def test_switch_var(self): ) self.assertFalse(valid_data.shape == widget.valid_data.shape and np.allclose(valid_data, widget.valid_data)) - self.assertEqual(widget.selection, set()) + self.assertEqual(widget.selected_bars, set()) widget._clear_plot.assert_called() - widget.apply.assert_called() + widget.apply.now.assert_called() def test_switch_cvar(self): """Widget reset and recomputes when changing splitting variable""" @@ -213,18 +220,19 @@ def test_switch_cvar(self): y = self.iris.domain.class_var extra = DiscreteVariable("foo", values=("a", "b")) domain = Domain(self.iris.domain.attributes + (extra, ), y) - data = self.iris.transform(domain) - data.X[:75, -1] = 0 - data.X[75:120, -1] = 1 + data = self.iris.transform(domain).copy() + with data.unlocked(): + data.X[:75, -1] = 0 + data.X[75:120, -1] = 1 self.send_signal(widget.Inputs.data, data) self._set_var(2) self._set_cvar(y) binnings = widget.binnings valid_data = widget.valid_data.copy() - widget.selection.add(1) + widget.selected_bars.add(widget.ordered_values[1]) widget._clear_plot = Mock() - widget.apply = Mock() + widget.apply.now = widget.apply.deferred = Mock() self.assertEqual(len(widget.valid_group_data), 150) @@ -232,19 +240,19 @@ def test_switch_cvar(self): self.assertIs(binnings, widget.binnings) np.testing.assert_equal(valid_data[:120], widget.valid_data) self.assertEqual(len(widget.valid_group_data), 120) - self.assertEqual(widget.selection, {1}) + self.assertEqual(widget.selected_bars, {widget.ordered_values[1]}) widget._clear_plot.assert_called() - widget.apply.assert_called() + widget.apply.now.assert_called() widget._clear_plot.reset_mock() - widget.apply.reset_mock() + widget.apply.now.reset_mock() self._set_cvar(None) self.assertIs(binnings, widget.binnings) np.testing.assert_equal(valid_data, widget.valid_data) self.assertIsNone(widget.valid_group_data) - self.assertEqual(widget.selection, {1}) + self.assertEqual(widget.selected_bars, {widget.ordered_values[1]}) widget._clear_plot.assert_called() - widget.apply.assert_called() + widget.apply.now.assert_called() def test_on_bins_changed(self): """Widget replots and outputs data when the number of bins is changed""" @@ -252,14 +260,14 @@ def test_on_bins_changed(self): self.send_signal(widget.Inputs.data, self.iris) self._set_slider(0) - widget.selection.add(1) + widget.selected_bars.add(widget.ordered_values[1]) n_bars = len(widget.bar_items) - widget.apply = Mock() + widget.apply.now = widget.apply.deferred = Mock() self._set_slider(1) - self.assertEqual(widget.selection, set()) + self.assertEqual(widget.selected_bars, set()) self.assertGreater(n_bars, len(widget.bar_items)) - widget.apply.assert_called_once() + widget.apply.now.assert_called_once() def test_set_valid_data(self): """Widget handles nans in data""" @@ -288,11 +296,12 @@ def test_set_valid_data(self): self.assertIsNotNone(widget.valid_group_data) self.assertTrue(widget.is_valid) - X, Y = self.iris.X, self.iris.Y - X[:, 0] = np.nan - X[:50, 1] = np.nan - X[:100, 2] = np.nan - Y[75:] = np.nan + with self.iris.unlocked(): + X, Y = self.iris.X, self.iris.Y + X[:, 0] = np.nan + X[:50, 1] = np.nan + X[:100, 2] = np.nan + Y[75:] = np.nan self.send_signal(widget.Inputs.data, self.iris) self._set_var(domain[0]) @@ -413,8 +422,6 @@ def test_controls_disabling(self): # changing them simultaneously doesn't significantly degrade the tests def test_plot_types_combinations(self): """Check that the widget doesn't crash at any plot combination""" - from AnyQt.QtWidgets import qApp - widget = self.widget c = widget.controls self.send_signal(widget.Inputs.data, self.iris) @@ -429,13 +436,11 @@ def test_plot_types_combinations(self): self._set_check(c.stacked_columns, b) self._set_check(c.show_probs, b) self._set_check(c.sort_by_freq, b) - qApp.processEvents() + widget.grab() # run layout and paint else: def test_plot_types_combinations(self): """Check that the widget doesn't crash at any plot combination""" # pylint: disable=too-many-nested-blocks - from AnyQt.QtWidgets import qApp - widget = self.widget c = widget.controls set_chk = self._set_check @@ -456,14 +461,15 @@ def test_plot_types_combinations(self): set_chk(c.stacked_columns, stack) set_chk(c.show_probs, show_probs) set_chk(c.sort_by_freq, sort_by_freq) - qApp.processEvents() + widget.grab() # run layout and paint def test_selection_grouping(self): """Widget groups consecutive selected bars""" widget = self.widget self.send_signal(widget.Inputs.data, self.iris) self._set_slider(0) - widget.selection = {1, 2, 3, 5, 6, 9} + widget.selected_bars = {widget.ordered_values[x] + for x in [1, 2, 3, 5, 6, 9]} widget.plot_mark.addItem = Mock() widget.show_selection() widget._on_end_selecting() @@ -509,7 +515,8 @@ def test_hide_bars(self): self._set_check(cb, False) self.assertTrue(all(not bar.hidden for bar in widget.bar_items)) - self.assertTrue(all(curve.opts["brush"].style() == Qt.NoBrush + self.assertTrue(all(curve.opts["brush"] is None or + curve.opts["brush"].style() == Qt.NoBrush for curve in widget.curve_items)) self._set_check(cb, True) @@ -518,6 +525,16 @@ def test_hide_bars(self): self.assertTrue(all(curve.opts["brush"] is not None for curve in widget.curve_items)) + self._set_fitter(1) + self._set_check(widget.controls.hide_bars, True) + self.assertTrue(all(bar.hidden for bar in widget.bar_items)) + + self._set_fitter(0) + self.assertTrue(all(not bar.hidden for bar in widget.bar_items)) + + self._set_fitter(1) + self.assertTrue(all(bar.hidden for bar in widget.bar_items)) + def test_report(self): """Report doesn't crash""" widget = self.widget @@ -525,11 +542,10 @@ def test_report(self): widget.send_report() def test_sort_by_freq_no_split(self): - data = Table("heart_disease") - domain = data.domain + domain = self.heart.domain sort_by_freq = self.widget.controls.sort_by_freq - self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.data, self.heart) self._set_var(domain["gender"]) self._set_cvar(None) @@ -548,11 +564,10 @@ def test_sort_by_freq_no_split(self): self.assertEqual(out[1][1], 97) def test_sort_by_freq_split(self): - data = Table("heart_disease") - domain = data.domain + domain = self.heart.domain sort_by_freq = self.widget.controls.sort_by_freq - self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.data, self.heart) self._set_var(domain["gender"]) self._set_cvar(domain["rest ECG"]) @@ -574,6 +589,312 @@ def test_sort_by_freq_split(self): self.assertEqual(out[4][1], "left vent hypertrophy") self.assertEqual(out[4][2], 45) + def test_sort_by_freq_output_selection(self): + widget = self.widget + sort_by_freq = self.widget.controls.sort_by_freq + var = self.heart.domain["chest pain"] + + self.send_signal(self.widget.Inputs.data, self.heart) + self._set_var(var) + + sort_by_freq.setChecked(False) + assert not widget.sort_by_freq + + # Select value[1] + widget._on_item_clicked(widget.bar_items[1], Qt.NoModifier, False) + widget._on_end_selecting() + cp = self.get_output(widget.Outputs.selected_data).get_column(var) + self.assertTrue(np.all(cp == 1)) + + sort_by_freq.setChecked(True) + assert widget.sort_by_freq + + # Select value[2] (because of reordering) + # value[1] remains selected + widget._on_item_clicked(widget.bar_items[1], Qt.ControlModifier, False) + widget._on_end_selecting() + cp = self.get_output(widget.Outputs.selected_data).get_column(var) + self.assertFalse(np.any(cp == 0)) + self.assertTrue(np.any(cp == 1)) + self.assertTrue(np.any(cp == 2)) + self.assertFalse(np.any(cp == 3)) + + # deselect value[1] + widget._on_item_clicked(widget.bar_items[2], Qt.ControlModifier, False) + widget._on_end_selecting() + cp = self.get_output(widget.Outputs.selected_data).get_column(var) + self.assertTrue(np.all(cp == 2)) + + # Select value[0] and also value[1] (!), because it's in between + # This tests checks that shift-selecting works with ordered values + widget._on_item_clicked(widget.bar_items[2], Qt.NoModifier, False) + widget._on_item_clicked(widget.bar_items[0], Qt.ShiftModifier, False) + widget._on_end_selecting() + cp = self.get_output(widget.Outputs.selected_data).get_column(var) + self.assertTrue(np.any(cp == 0)) + self.assertTrue(np.any(cp == 1)) + self.assertTrue(np.any(cp == 2)) + self.assertFalse(np.any(cp == 3)) + + def test_keyboard_interaction_unsorted(self): + press = partial(QKeyEvent, QEvent.KeyPress) + left, right = Qt.Key_Left, Qt.Key_Right + + widget = self.widget + sort_by_freq = self.widget.controls.sort_by_freq + widget.sort_by_freq = False + var = self.heart.domain["chest pain"] + + for ordered in [False, True]: + with self.subTest(ordered=ordered): + sort_by_freq.setChecked(ordered) + assert widget.sort_by_freq is ordered + assert not ordered or list(widget.ordered_values) != list(var.values) + + values = widget.ordered_values if ordered else var.values + + self.send_signal(self.widget.Inputs.data, self.heart) + self._set_var(var) + + # Start selecting by pressing right + for i in [0, 1, 2, 3, 3, 3]: + widget.keyPressEvent(press(right, Qt.NoModifier)) + self.assertEqual(widget.selected_bars, {values[i]}, f"at i={i}") + + # Going left + for i in [2, 1, 0, 0, 0]: + widget.keyPressEvent(press(left, Qt.NoModifier)) + self.assertEqual(widget.selected_bars, {values[i]}, f"at i={i}") + + # Deselect first item (= clear selection) + widget._on_item_clicked(widget.bar_items[0], Qt.NoModifier, False) + assert not widget.selected_bars + + # Going left from clear + for i in [3, 2, 1, 0, 0, 0]: + widget.keyPressEvent(press(left, Qt.NoModifier)) + self.assertEqual(widget.selected_bars, {values[i]}, f"at i={i}") + + widget.keyPressEvent(press(right, Qt.NoModifier)) + assert widget.selected_bars == {values[1]} + + # Shift selecting to the right + widget.keyPressEvent(press(right, Qt.ShiftModifier)) + assert widget.selected_bars == {values[1], values[2]} + + widget.keyPressEvent(press(right, Qt.ShiftModifier)) + assert widget.selected_bars == {values[1], values[2], values[3]} + + widget.keyPressEvent(press(right, Qt.ShiftModifier)) + assert widget.selected_bars == {values[1], values[2], values[3]} + + # Shift deselecting to the left + widget.keyPressEvent(press(left, Qt.ShiftModifier)) + assert widget.selected_bars == {values[1], values[2]} + + widget.keyPressEvent(press(left, Qt.ShiftModifier)) + assert widget.selected_bars == {values[1]} + + # Now we have a single item, so going left should select the one to the left + widget.keyPressEvent(press(left, Qt.ShiftModifier)) + assert widget.selected_bars == {values[0], values[1]} + + # Right deselects it + widget.keyPressEvent(press(right, Qt.ShiftModifier)) + assert widget.selected_bars == {values[1]} + + # Right again selects the item to the right of the single itsm + widget.keyPressEvent(press(right, Qt.ShiftModifier)) + assert widget.selected_bars == {values[1], values[2]} + + @patch("Orange.widgets.visualize.owdistributions.decimal_binnings") + def test_selection_with_offset_cont_hist(self, dec_bin): + widget = self.widget + + dec_bin.return_value = [BinDefinition(np.arange(0, 1000, 100))] + self.send_signal(Table.from_numpy(Domain([ContinuousVariable("y")]), + np.arange(1000)[:, np.newaxis])) + widget._on_item_clicked(widget.bar_items[2], Qt.NoModifier, False) + widget._on_end_selecting() + np.testing.assert_equal( + self.get_output(widget.Outputs.selected_data).X, + np.arange(200, 300)[:, np.newaxis]) + + def test_hide_legend(self): + widget = self.widget + legend = widget._legend + + self._set_check(widget.controls.show_legend, False) + self._set_check(widget.controls.show_legend, True) + + self.send_signal(widget.Inputs.data, self.iris) + self.assertTrue(legend.isVisible()) + self._set_check(widget.controls.show_legend, False) + self.assertFalse(legend.isVisible()) + self._set_check(widget.controls.show_legend, True) + self.assertTrue(legend.isVisible()) + + self.send_signal(widget.Inputs.data, None) + self._set_check(widget.controls.show_legend, False) + self._set_check(widget.controls.show_legend, True) + + @WidgetTest.skipNonEnglish + def test_visual_settings(self): + graph = self.widget.plotview + font = QFont() + font.setItalic(True) + font.setFamily("Helvetica") + + self.send_signal(self.widget.Inputs.data, self.iris[50:]) + key, value = ("Fonts", "Font family", "Font family"), "Helvetica" + self.widget.set_visual_settings(key, value) + + key, value = ("Fonts", "Title", "Font size"), 20 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Title", "Italic"), True + self.widget.set_visual_settings(key, value) + font.setPointSize(20) + self.assertFontEqual(graph.parameter_setter.title_item.item.font(), font) + + key, value = ("Fonts", "Axis title", "Font size"), 16 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis title", "Italic"), True + self.widget.set_visual_settings(key, value) + font.setPointSize(16) + for item in graph.parameter_setter.axis_items: + self.assertFontEqual(item.label.font(), font) + + key, value = ("Fonts", "Axis ticks", "Font size"), 15 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis ticks", "Italic"), True + self.widget.set_visual_settings(key, value) + font.setPointSize(15) + for item in graph.parameter_setter.axis_items: + self.assertFontEqual(item.style["tickFont"], font) + + key, value = ("Fonts", "Legend", "Font size"), 14 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Legend", "Italic"), True + self.widget.set_visual_settings(key, value) + font.setPointSize(14) + legend_item = list(graph.parameter_setter.legend_items)[0] + self.assertFontEqual(legend_item[1].item.font(), font) + + key = "Figure", "Legend", "Hide empty categories in the legend" + value = True + self.assertEqual( + [i[1].text for i in graph.parameter_setter.legend_items], + ["Iris-setosa", "Iris-versicolor", "Iris-virginica"] + ) + self.widget.set_visual_settings(key, value) + self.assertEqual( + [i[1].text for i in graph.parameter_setter.legend_items], + ["Iris-versicolor", "Iris-virginica"] + ) + + self.assertFalse(graph.parameter_setter.title_item.isVisible()) + key, value = ("Annotations", "Title", "Title"), "iris distributions" + self.widget.set_visual_settings(key, value) + self.assertTrue(graph.parameter_setter.title_item.isVisible()) + self.assertEqual(graph.parameter_setter.title_item.item.toPlainText(), "iris distributions") + + key, value = ("Annotations", "x-axis title", "Title"), "x-axis custom label" + self.widget.set_visual_settings(key, value) + self.assertEqual(self.widget.ploti.getAxis("bottom").labelText, "x-axis custom label") + + key, value = ("Annotations", "y-axis title", "Title"), "y-axis custom label" + self.widget.set_visual_settings(key, value) + self.assertEqual(self.widget.ploti.getAxis("left").labelText, "y-axis custom label") + + def test_custom_titles_variable_change(self): + """Custom titles persist when plotted variable is changed""" + self.send_signal(self.widget.Inputs.data, self.iris) + graph = self.widget.plotview + + key, value = ("Annotations", "Title", "Title"), "iris distributions" + self.widget.set_visual_settings(key, value) + key, value = ("Annotations", "x-axis title", "Title"), "x-axis custom label" + self.widget.set_visual_settings(key, value) + key, value = ("Annotations", "y-axis title", "Title"), "y-axis custom label" + self.widget.set_visual_settings(key, value) + + self._set_var(1) + + self.assertEqual(graph.parameter_setter.title_item.item.toPlainText(), "iris distributions") + self.assertEqual(self.widget.ploti.getAxis("bottom").labelText, "x-axis custom label") + self.assertEqual(self.widget.ploti.getAxis("left").labelText, "y-axis custom label") + + key, value = ("Annotations", "x-axis title", "Title"), "" + self.widget.set_visual_settings(key, value) + self.assertEqual(self.widget.ploti.getAxis("bottom").labelText, "sepal length") + + key, value = ("Annotations", "y-axis title", "Title"), "" + self.widget.set_visual_settings(key, value) + self.assertEqual(self.widget.ploti.getAxis("left").labelText, "Frequency") + + def test_saved_workflow(self): + """Visual settings are saved and restored""" + font = QFont() + font.setItalic(True) + font.setFamily("Helvetica") + + key, value = ("Fonts", "Title", "Font size"), 20 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Title", "Italic"), True + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis title", "Font size"), 16 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis title", "Italic"), True + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis ticks", "Font size"), 15 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Axis ticks", "Italic"), True + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Legend", "Font size"), 14 + self.widget.set_visual_settings(key, value) + key, value = ("Fonts", "Legend", "Italic"), True + self.widget.set_visual_settings(key, value) + key, value = ("Figure", "Legend", "Hide empty categories in the legend"), True + self.widget.set_visual_settings(key, value) + key, value = ("Annotations", "Title", "Title"), "iris distributions" + self.widget.set_visual_settings(key, value) + key, value = ("Annotations", "x-axis title", "Title"), "x-axis custom label" + self.widget.set_visual_settings(key, value) + key, value = ("Annotations", "y-axis title", "Title"), "y-axis custom label" + self.widget.set_visual_settings(key, value) + + settings = self.widget.settingsHandler.pack_data(self.widget) + w = self.create_widget(OWDistributions, stored_settings=settings) + + self.send_signal(w.Inputs.data, self.iris[50:], widget=w) + key, value = ("Fonts", "Font family", "Font family"), "Helvetica" + w.set_visual_settings(key, value) + + font.setPointSize(20) + self.assertFontEqual(w.plotview.parameter_setter.title_item.item.font(), font) + font.setPointSize(16) + for item in w.plotview.parameter_setter.axis_items: + self.assertFontEqual(item.label.font(), font) + font.setPointSize(15) + for item in w.plotview.parameter_setter.axis_items: + self.assertFontEqual(item.style["tickFont"], font) + font.setPointSize(14) + legend_item = list(w.plotview.parameter_setter.legend_items)[0] + self.assertFontEqual(legend_item[1].item.font(), font) + self.assertEqual( + [i[1].text for i in w.plotview.parameter_setter.legend_items], + ["Iris-versicolor", "Iris-virginica"] + ) + self.assertTrue(w.plotview.parameter_setter.title_item.isVisible()) + self.assertEqual(w.plotview.parameter_setter.title_item.item.toPlainText(), "iris distributions") + self.assertEqual(w.ploti.getAxis("bottom").labelText, "x-axis custom label") + self.assertEqual(w.ploti.getAxis("left").labelText, "y-axis custom label") + + def assertFontEqual(self, font1, font2): + self.assertEqual(font1.family(), font2.family()) + self.assertEqual(font1.pointSize(), font2.pointSize()) + self.assertEqual(font1.italic(), font2.italic()) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owfreeviz.py b/Orange/widgets/visualize/tests/test_owfreeviz.py index c524847f9a7..f7a3d8554b7 100644 --- a/Orange/widgets/visualize/tests/test_owfreeviz.py +++ b/Orange/widgets/visualize/tests/test_owfreeviz.py @@ -1,7 +1,7 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch import numpy as np @@ -22,7 +22,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWFreeViz.Inputs.data cls.signal_data = cls.data cls.same_input_output_domain = False cls.heart_disease = Table("heart_disease") @@ -156,6 +156,46 @@ def test_discrete_attributes(self): self.assertTrue(self.widget.Warning.removed_features.is_shown()) self.widget.run_button.click() + def test_gravity_slider(self): + w = self.widget + + w.balance = False + w.gravity_index = 0 + + w.grav_slider.setValue(2) + self.assertTrue(w.balance) + self.assertEqual(w.gravity_label.text(), str(w.GravityValues[2])) + + w.grav_slider.setValue(3) + self.assertTrue(w.balance) + self.assertEqual(w.gravity_label.text(), str(w.GravityValues[3])) + + assert w.projector is None + self.send_signal(self.widget.Inputs.data, Table("zoo")) + self.wait_until_finished() + assert w.projector is not None + + # w.projector.gravity has correct value if gravity was set before data + self.assertEqual(w.projector.gravity, w.GravityValues[3]) + + # ... and if set when the data is already present and projector exists + w.grav_slider.setValue(1) + self.assertEqual(w.projector.gravity, w.GravityValues[1]) + + # Check that optimization is restarted if the projection is optimized + with patch.object(w, "_run") as run, \ + patch.object(w, "_OWFreeViz__optimized", new=True): + w.grav_slider.setValue(2) + self.assertEqual(w.projector.gravity, w.GravityValues[2]) + run.assert_called_once() + + # Also, check that checkbox also does all that + run.reset_mock() + w.controls.balance.click() + self.assertFalse(w.balance) + self.assertIsNone(w.projector.gravity) + run.assert_called_once() + class TestOWFreeVizRunner(unittest.TestCase): @classmethod diff --git a/Orange/widgets/visualize/tests/test_owheatmap.py b/Orange/widgets/visualize/tests/test_owheatmap.py index c61075f77dd..d6f42f29154 100644 --- a/Orange/widgets/visualize/tests/test_owheatmap.py +++ b/Orange/widgets/visualize/tests/test_owheatmap.py @@ -29,16 +29,16 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.housing = Table("housing") - cls.titanic = Table("titanic") - cls.brown_selected = Table("brown-selected") - - cls.signal_name = "Data" + cls.signal_name = OWHeatMap.Inputs.data cls.signal_data = cls.data def setUp(self): self.widget = self.create_widget(OWHeatMap) # type: OWHeatMap + self.housing = Table("housing") + self.titanic = Table("titanic") + self.brown_selected = Table("brown-selected") + def test_input_data(self): """Check widget's data with data on the input""" for data in (self.data, self.housing): @@ -72,6 +72,9 @@ def test_information_message(self): self.assertFalse(self.widget.Information.active) self.send_signal(self.widget.Inputs.data, data[:21]) self.assertTrue(self.widget.Information.active) + data = Table("heart_disease.tab")[:10] + self.send_signal(self.widget.Inputs.data, data) + self.assertTrue(self.widget.Information.discrete_ignored.is_shown()) def test_settings_changed(self): self.send_signal(self.widget.Inputs.data, self.data) @@ -143,7 +146,8 @@ def test_cluster_column_on_all_zero_column(self): # Pearson distance used for clustering of columns does not # handle all zero columns well iris = Table("iris") - iris[:, 0] = 0 + with iris.unlocked(): + iris[:, 0] = 0 self.widget.col_clustering = True self.widget.set_dataset(iris) @@ -186,7 +190,7 @@ def test_cls_with_single_instance(self): self.widget.set_row_clustering(Clustering.Clustering) def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_commit') as commit: + with patch.object(self.widget.commit, 'now') as commit: self.widget.auto_commit = False commit.reset_mock() self.send_signal(self.widget.Inputs.data, self.titanic) @@ -205,6 +209,20 @@ def test_saved_selection(self): self.send_signal(w.Inputs.data, iris, widget=w) self.assertEqual(len(self.get_output(w.Outputs.selected_data)), 21) + def test_saved_selection_when_not_possible(self): + # Has stored selection but ot enough columns for clustering. + iris = Table("iris")[:, ["petal width"]] + w = self.create_widget( + OWHeatMap, stored_settings={ + "__version__": 3, + "col_clustering_method": "Clustering", + "selected_rows": [1, 2, 3], + } + ) + self.send_signal(w.Inputs.data, iris) + out = self.get_output(w.Outputs.selected_data) + self.assertSequenceEqual(list(out.ids), list(iris.ids[[1, 2, 3]])) + def test_set_split_var(self): data = self.brown_selected[::3] w = self.widget @@ -218,7 +236,8 @@ def test_set_split_var(self): def test_set_split_var_missing(self): data = self.brown_selected[::3].copy() - data.Y[::5] = np.nan + with data.unlocked(): + data.Y[::5] = np.nan w = self.widget self.send_signal(self.widget.Inputs.data, data, widget=w) self.assertIs(w.split_by_var, data.domain.class_var) @@ -246,7 +265,8 @@ def test_set_split_column_key(self): def test_set_split_column_key_missing(self): data = self._brown_selected_10() - data.Y[:5] = np.nan + with data.unlocked(): + data.Y[:5] = np.nan data_t = data.transpose(data) function = data.domain["function"] w = self.widget @@ -328,15 +348,17 @@ def test_row_color_annotations(self): def test_row_color_annotations_with_na(self): widget = self.widget - data = self._brown_selected_10() - data.Y[:3] = np.nan - data.metas[:3, -1] = np.nan + data = self._brown_selected_10() + with data.unlocked(): + data.Y[:3] = np.nan + data.metas[:3, -1] = np.nan self.send_signal(widget.Inputs.data, data, widget=widget) widget.set_annotation_color_var(data.domain["function"]) self.assertTrue(widget.scene.widget.right_side_colors[0].isVisible()) widget.set_annotation_color_var(data.domain["diau g"]) - data.Y[:] = np.nan - data.metas[:, -1] = np.nan + with data.unlocked(): + data.Y[:] = np.nan + data.metas[:, -1] = np.nan self.send_signal(widget.Inputs.data, data, widget=widget) widget.set_annotation_color_var(data.domain["function"]) widget.set_annotation_color_var(data.domain["diau g"]) @@ -359,15 +381,17 @@ def test_col_color_annotations(self): def test_col_color_annotations_with_na(self): widget = self.widget data = self._brown_selected_10() - data.Y[:3] = np.nan - data.metas[:3, -1] = np.nan + with data.unlocked(): + data.Y[:3] = np.nan + data.metas[:3, -1] = np.nan data_t = data.transpose(data) self.send_signal(widget.Inputs.data, data_t, widget=widget) widget.set_column_annotation_color_var(data.domain["function"]) self.assertTrue(widget.scene.widget.top_side_colors[0].isVisible()) widget.set_column_annotation_color_var(data.domain["diau g"]) - data.Y[:] = np.nan - data.metas[:, -1] = np.nan + with data.unlocked(): + data.Y[:] = np.nan + data.metas[:, -1] = np.nan data_t = data.transpose(data) self.send_signal(widget.Inputs.data, data_t, widget=widget) widget.set_column_annotation_color_var(data.domain["function"]) @@ -375,6 +399,14 @@ def test_col_color_annotations_with_na(self): widget.set_column_annotation_color_var(None) self.assertFalse(widget.scene.widget.top_side_colors[0].isVisible()) + def test_data_with_hidden(self): + w = self.widget + housing = self.housing.copy() + housing.domain.attributes[0].attributes["hidden"] = True + self.send_signal(self.widget.Inputs.data, housing) + self.assertEqual(len(w.effective_data.domain.attributes), + len(housing.domain.attributes) - 1) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owlinearprojection.py b/Orange/widgets/visualize/tests/test_owlinearprojection.py index b0617355c61..6ad9dedcc31 100644 --- a/Orange/widgets/visualize/tests/test_owlinearprojection.py +++ b/Orange/widgets/visualize/tests/test_owlinearprojection.py @@ -4,6 +4,7 @@ import numpy as np from AnyQt.QtCore import QItemSelectionModel +from AnyQt.QtTest import QSignalSpy from Orange.data import Table, Domain, DiscreteVariable, ContinuousVariable from Orange.widgets.settings import Context @@ -13,9 +14,9 @@ ) from Orange.widgets.tests.utils import simulate from Orange.widgets.visualize.owlinearprojection import ( - OWLinearProjection, LinearProjectionVizRank, Placement + OWLinearProjection, Placement ) -from Orange.widgets.visualize.utils import run_vizrank +from Orange.widgets.visualize.utils.vizrank import RunState class TestOWLinearProjection(WidgetTest, AnchorProjectionWidgetTestMixin, @@ -25,7 +26,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWLinearProjection.Inputs.data cls.signal_data = cls.data cls.same_input_output_domain = False @@ -38,8 +39,9 @@ def test_nan_plot(self): simulate.combobox_run_through_all(self.widget.controls.attr_color) simulate.combobox_run_through_all(self.widget.controls.attr_size) - data.X[:, 0] = np.nan - data.Y[:] = np.nan + with data.unlocked(): + data.X[:, 0] = np.nan + data.Y[:] = np.nan self.send_signal(self.widget.Inputs.data, data) self.send_signal(self.widget.Inputs.data_subset, data[2:3]) simulate.combobox_run_through_all(self.widget.controls.attr_color) @@ -58,9 +60,9 @@ def check_vizrank(data): self.widget.controls.attr_color.model(): self.widget.attr_color = data.domain.class_var if self.widget.btn_vizrank.isEnabled(): - vizrank = LinearProjectionVizRank(self.widget) - states = [state for state in vizrank.iterate_states(None)] - self.assertIsNotNone(vizrank.compute_score(states[0])) + vizrank = self.widget.vizrank_dialog + self.assertIsNotNone( + vizrank.compute_score(next(vizrank.state_generator()))) check_vizrank(self.data) check_vizrank(self.data[:, :3]) @@ -84,11 +86,26 @@ def test_no_data_for_lda(self): self.send_signal(self.widget.Inputs.data, self.data) self.widget.radio_placement.buttons[Placement.LDA].click() self.assertTrue(buttons[Placement.LDA].isEnabled()) + output = self.get_output(self.widget.Outputs.components) + self.assertTrue(output and len(output) == 2) self.send_signal(self.widget.Inputs.data, Table("housing")) self.assertFalse(buttons[Placement.LDA].isEnabled()) self.send_signal(self.widget.Inputs.data, None) self.assertTrue(buttons[Placement.LDA].isEnabled()) + def test_lda_not_enough_distinct(self): + buttons = self.widget.radio_placement.buttons + self.send_signal(self.widget.Inputs.data, self.data) + self.assertTrue(buttons[Placement.LDA].isEnabled()) + self.send_signal(self.widget.Inputs.data, self.data[:10]) + self.assertFalse(buttons[Placement.LDA].isEnabled()) + self.send_signal(self.widget.Inputs.data, None) + self.assertTrue(buttons[Placement.LDA].isEnabled()) + self.send_signal(self.widget.Inputs.data, self.data[40:60]) + self.assertFalse(buttons[Placement.LDA].isEnabled()) + self.send_signal(self.widget.Inputs.data, self.data[40:110]) + self.assertTrue(buttons[Placement.LDA].isEnabled()) + def test_data_no_cont_features(self): data = Table("titanic") self.assertFalse(self.widget.Error.no_cont_features.is_shown()) @@ -107,8 +124,9 @@ def assertErrorShown(data, is_shown): self.send_signal(self.widget.Inputs.data, data) self.assertEqual(is_shown, self.widget.Error.no_valid_data.is_shown()) - data = Table("iris")[::30] - data[:, 0] = np.nan + data = Table("iris")[::30].copy() + with data.unlocked(): + data[:, 0] = np.nan for data, is_shown in zip([None, data, Table("iris")[:30]], [False, True, False]): assertErrorShown(data, is_shown) @@ -209,30 +227,32 @@ def setUpClass(cls): def setUp(self): self.widget = self.create_widget(OWLinearProjection) - self.vizrank = self.widget.vizrank + + def tearDown(self): + self.widget.onDeleteWidget() + super().tearDown() def test_discrete_class(self): self.send_signal(self.widget.Inputs.data, self.data) - run_vizrank(self.vizrank.compute_score, - self.vizrank.iterate_states, None, - [], 0, self.vizrank.state_count(), Mock()) + self.widget.vizrank_button().click() def test_continuous_class(self): data = Table("housing")[::100] self.send_signal(self.widget.Inputs.data, data) - run_vizrank(self.vizrank.compute_score, - self.vizrank.iterate_states, None, - [], 0, self.vizrank.state_count(), Mock()) + self.widget.vizrank_button().click() def test_set_attrs(self): self.send_signal(self.widget.Inputs.data, self.data) + vizrank = self.widget.vizrank_dialog prev_selected = self.widget.selected_vars[:] c1 = self.get_output(self.widget.Outputs.components) - self.vizrank.toggle() - self.process_events(until=lambda: not self.vizrank.keep_running) - self.assertEqual(len(self.vizrank.scores), self.vizrank.state_count()) - self.vizrank.rank_table.selectionModel().select( - self.vizrank.rank_model.item(0, 0).index(), + spy = QSignalSpy(self.widget.vizrankRunStateChanged) + self.widget.vizrank_button().click() + while spy.wait() and spy[-1][0] != RunState.Done: + pass + self.assertEqual(len(vizrank.scores), vizrank.state_count()) + vizrank.rank_table.selectionModel().select( + vizrank.rank_model.item(0, 0).index(), QItemSelectionModel.ClearAndSelect ) self.assertNotEqual(self.widget.selected_vars, prev_selected) diff --git a/Orange/widgets/visualize/tests/test_owlineplot.py b/Orange/widgets/visualize/tests/test_owlineplot.py index 0df9f81f750..91d1e292a62 100644 --- a/Orange/widgets/visualize/tests/test_owlineplot.py +++ b/Orange/widgets/visualize/tests/test_owlineplot.py @@ -10,7 +10,6 @@ from AnyQt.QtCore import Qt, QPointF from AnyQt.QtGui import QFont -import pyqtgraph from pyqtgraph import PlotCurveItem from pyqtgraph.Point import Point @@ -21,17 +20,57 @@ WidgetTest, WidgetOutputsTestMixin, datasets ) from Orange.widgets.visualize.owlineplot import ( - OWLinePlot, ccw, intersects, line_intersects_profiles + OWLinePlot, ccw, intersects, line_intersects_profiles, ProfileGroup ) +class TestProfileGroup(unittest.TestCase): + def test_get_disconnected_curve_missing_data_sizes(self): + y = np.array([[1.2, 1.5, 2., 1.4, 2.], + [1.3, np.nan, 4., 1.5, 3.], + [np.nan, np.nan, 5.6, np.nan, 3.4], + [3.4, 5.7, 3.5, 3.3, 3.7]]) + x, y, connect = \ + ProfileGroup._ProfileGroup__get_disconnected_curve_missing_data(y) + self.assertEqual(x.shape, (20,)) + self.assertEqual(y.shape, (20,)) + self.assertEqual(connect.shape, (20,)) + + def test_get_disconnected_curve_missing_data_connect(self): + y = np.array([[1.2, 1.5, 2., 1.4, 2.], + [1.3, np.nan, 4., 1.5, 3.], + [np.nan, np.nan, 5.6, np.nan, 3.4], + [3.4, 5.7, 3.5, 3.3, 3.7]]) + _, _, connect = \ + ProfileGroup._ProfileGroup__get_disconnected_curve_missing_data(y) + con = [False] * 6 + [True] + [False] * 6 + [True] + [False] * 6 + np.testing.assert_array_equal(connect, con) + + y = np.array([[1.2, 1.5, 2., 1.4, 2.], + [np.nan, np.nan, np.nan, np.nan, 3.], + [np.nan, np.nan, np.nan, np.nan, 3.4], + [3.4, 5.7, 3.5, 3.3, 3.7]]) + _, _, connect = \ + ProfileGroup._ProfileGroup__get_disconnected_curve_missing_data(y) + np.testing.assert_array_equal(connect, [False] * 20) + + y = np.array([[np.nan, np.nan, np.nan, np.nan, np.nan], + [np.nan, np.nan, np.nan, np.nan, 3.4], + [np.nan, np.nan, np.nan, np.nan, np.nan], + [3.4, 5.7, 3.5, 3.3, 3.7]]) + _, _, connect = \ + ProfileGroup._ProfileGroup__get_disconnected_curve_missing_data(y) + con = [True] * 4 + [False] * 6 + [True] * 4 + [False] * 6 + np.testing.assert_array_equal(connect, con) + + class TestOWLinePLot(WidgetTest, WidgetOutputsTestMixin): @classmethod def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWLinePlot.Inputs.data cls.signal_data = cls.data def setUp(self): @@ -120,9 +159,10 @@ def test_select(self): def test_saved_selection(self): data = self.data.copy() - data[0, 0] = np.nan + with data.unlocked(): + data[0, 0] = np.nan self.send_signal(self.widget.Inputs.data, data) - mask = np.zeros(len(data) - 1, dtype=bool) + mask = np.zeros(len(data), dtype=bool) mask[::10] = True self.widget.selection_changed(mask) settings = self.widget.settingsHandler.pack_data(self.widget) @@ -185,11 +225,9 @@ def test_max_features(self): def test_data_with_missing_values(self): data = self.data.copy() - data[0, 0] = np.nan + with data.unlocked(): + data[0, 0] = np.nan self.send_signal(self.widget.Inputs.data, data) - self.assertTrue(self.widget.Information.hidden_instances.is_shown()) - self.send_signal(self.widget.Inputs.data, None) - self.assertFalse(self.widget.Information.hidden_instances.is_shown()) def test_display_options(self): self.send_signal(self.widget.Inputs.data, self.data[::10]) @@ -238,7 +276,6 @@ def test_datasets(self): for ds in datasets.datasets(): self.send_signal(self.widget.Inputs.data, ds) self.send_signal(self.widget.Inputs.data, None) - self.assertFalse(self.widget.Error.no_valid_data.is_shown()) def test_none_data(self): self.send_signal(self.widget.Inputs.data, self.data[:0]) @@ -284,12 +321,13 @@ def test_send_report(self): self.widget.report_button.click() def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_commit') as commit: + with patch.object(self.widget.commit, 'now') as commit: self.widget.auto_commit = False commit.reset_mock() self.send_signal(self.widget.Inputs.data, self.titanic) commit.assert_called() + @WidgetTest.skipNonEnglish def test_visual_settings(self, timeout=DEFAULT_TIMEOUT): graph = self.widget.graph font = QFont() @@ -352,6 +390,16 @@ def test_visual_settings(self, timeout=DEFAULT_TIMEOUT): self.assertEqual(axis.label.toPlainText().strip(), "Foo3") self.assertEqual(axis.labelText, "Foo3") + key, value = ("Figure", "Lines (missing value)", "Width"), 10 + self.widget.set_visual_settings(key, value) + for line in graph.parameter_setter.missing_lines_items: + self.assertEqual(line.opts["pen"].width(), 10) + + key, value = ("Figure", "Selected lines (missing value)", "Width"), 11 + self.widget.set_visual_settings(key, value) + for line in graph.parameter_setter.sel_missing_lines_items: + self.assertEqual(line.opts["pen"].width(), 11) + def assertFontEqual(self, font1, font2): self.assertEqual(font1.family(), font2.family()) self.assertEqual(font1.pointSize(), font2.pointSize()) diff --git a/Orange/widgets/visualize/tests/test_owmosaic.py b/Orange/widgets/visualize/tests/test_owmosaic.py index 577fdd5192b..0b9f8f8e191 100644 --- a/Orange/widgets/visualize/tests/test_owmosaic.py +++ b/Orange/widgets/visualize/tests/test_owmosaic.py @@ -4,14 +4,16 @@ import numpy as np -from AnyQt.QtCore import QEvent, QPoint, Qt +from AnyQt.QtCore import QEvent, QPointF, Qt from AnyQt.QtGui import QMouseEvent +from AnyQt.QtTest import QSignalSpy from Orange.data import Table, DiscreteVariable, Domain, ContinuousVariable, \ StringVariable from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin from Orange.widgets.visualize.owmosaic import OWMosaicDisplay from Orange.widgets.tests.utils import simulate +from Orange.widgets.visualize.utils.vizrank import RunState class TestOWMosaicDisplay(WidgetTest, WidgetOutputsTestMixin): @@ -20,7 +22,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWMosaicDisplay.Inputs.data cls.signal_data = cls.data def setUp(self): @@ -36,8 +38,8 @@ def test_empty_column(self): def _select_data(self): self.widget.select_area(1, QMouseEvent( - QEvent.MouseButtonPress, QPoint(), Qt.LeftButton, - Qt.LeftButton, Qt.KeyboardModifiers())) + QEvent.MouseButtonPress, QPointF(), Qt.LeftButton, + Qt.LeftButton, Qt.NoModifier)) return [2, 3, 9, 23, 29, 30, 34, 35, 37, 42, 47, 49] def test_continuous_metas(self): @@ -128,8 +130,8 @@ def test_subset(self): output = self.get_output(self.widget.Outputs.annotated_data) np.testing.assert_array_equal(output.X, self.data[:1].X) - @patch('Orange.widgets.visualize.owmosaic.MosaicVizRank.on_manual_change') - def test_vizrank_receives_manual_change(self, on_manual_change): + @patch('Orange.widgets.visualize.owmosaic.MosaicVizRank.auto_select') + def test_vizrank_receives_manual_change(self, auto_select): # Recreate the widget so the patch kicks in self.widget = self.create_widget(OWMosaicDisplay) data = Table("iris.tab") @@ -138,7 +140,7 @@ def test_vizrank_receives_manual_change(self, on_manual_change): self.widget.variable2 = data.domain[1] simulate.combobox_activate_index(self.widget.controls.variable2, 3) self.assertEqual(self.widget.variable2, data.domain[2]) - call_args = on_manual_change.call_args[0][0] + call_args = auto_select.call_args[0][0] self.assertEqual(len(call_args), 2) self.assertEqual(call_args[0].name, data.domain[0].name) self.assertEqual(call_args[1].name, data.domain[2].name) @@ -150,8 +152,8 @@ def test_selection_setting(self): widget.select_area( 1, - QMouseEvent(QEvent.MouseButtonPress, QPoint(), Qt.LeftButton, - Qt.LeftButton, Qt.KeyboardModifiers())) + QMouseEvent(QEvent.MouseButtonPress, QPointF(), Qt.LeftButton, + Qt.LeftButton, Qt.NoModifier)) # Changing the data must reset the selection self.send_signal(widget.Inputs.data, Table("titanic")) @@ -164,8 +166,8 @@ def test_selection_setting(self): widget.select_area( 1, - QMouseEvent(QEvent.MouseButtonPress, QPoint(), Qt.LeftButton, - Qt.LeftButton, Qt.KeyboardModifiers())) + QMouseEvent(QEvent.MouseButtonPress, QPointF(), Qt.LeftButton, + Qt.LeftButton, Qt.NoModifier)) settings = self.widget.settingsHandler.pack_data(self.widget) # Setting data to None must reset the selection @@ -200,7 +202,7 @@ def setUpClass(cls): def setUp(self): self.widget = self.create_widget(OWMosaicDisplay) - self.vizrank = self.widget.vizrank + self.widget.vizrank_attr_range_index = 0 def tearDown(self): self.widget.onDeleteWidget() @@ -208,205 +210,206 @@ def tearDown(self): def test_count(self): """MosaicVizrank correctly computes the number of combinations""" - vizrank = self.vizrank data = self.iris attributes = [v for v in data.domain.attributes[1:]] metas = [data.domain.attributes[0]] domain = Domain(attributes, data.domain.class_var, metas) new_data = data.from_table(domain, data) - self.send_signal(self.widget.Inputs.data, new_data) for data in [self.iris, new_data]: self.send_signal(self.widget.Inputs.data, data) simulate.combobox_activate_index(self.widget.controls.variable_color, 0, 0) - vizrank.max_attrs = 1 + vizrank = self.widget.vizrank_dialog + vizrank.attr_range_index = 1 self.assertEqual(vizrank.state_count(), 10) # 5x4 / 2 - vizrank.max_attrs = 2 + vizrank.attr_range_index = 2 self.assertEqual(vizrank.state_count(), 10) # 5x4x3 / 2x3 - vizrank.max_attrs = 3 + vizrank.attr_range_index = 3 self.assertEqual(vizrank.state_count(), 5) # 5x4x3x2 / 2x3x4 - vizrank.max_attrs = 4 + vizrank.attr_range_index = 4 self.assertEqual(vizrank.state_count(), 10) # 5x4 / 2 - vizrank.max_attrs = 5 + vizrank.attr_range_index = 5 self.assertEqual(vizrank.state_count(), 20) # above + 5x4x3 / 2x3 - vizrank.max_attrs = 6 + vizrank.attr_range_index = 6 self.assertEqual(vizrank.state_count(), 25) # above + 5x4x3x2 / 2x3x4 simulate.combobox_activate_index(self.widget.controls.variable_color, 2, 0) - vizrank.max_attrs = 0 + vizrank = self.widget.vizrank_dialog + vizrank.attr_range_index = 0 self.assertEqual(vizrank.state_count(), 4) # 4 - vizrank.max_attrs = 1 + vizrank.attr_range_index = 1 self.assertEqual(vizrank.state_count(), 6) # 4x3 / 2 - vizrank.max_attrs = 2 + vizrank.attr_range_index = 2 self.assertEqual(vizrank.state_count(), 4) # 4x3x2 / 3x2 - vizrank.max_attrs = 3 + vizrank.attr_range_index = 3 self.assertEqual(vizrank.state_count(), 1) # 4x3x2x1 / 2x3x4 - vizrank.max_attrs = 4 + vizrank.attr_range_index = 4 self.assertEqual(vizrank.state_count(), 10) # 4 + 4x3 / 2 - vizrank.max_attrs = 5 + vizrank.attr_range_index = 5 self.assertEqual(vizrank.state_count(), 14) # above + 4x3x2 / 3x2 - vizrank.max_attrs = 6 + vizrank.attr_range_index = 6 self.assertEqual(vizrank.state_count(), 15) # above + 4x3x2x1 / 2x3x4 self.send_signal(self.widget.Inputs.data, self.iris_no_class) simulate.combobox_activate_index(self.widget.controls.variable_color, 0, 0) - vizrank.max_attrs = 1 + vizrank = self.widget.vizrank_dialog + vizrank.attr_range_index = 1 self.assertEqual(vizrank.state_count(), 6) # 4x3 / 2 - vizrank.max_attrs = 2 + vizrank.attr_range_index = 2 self.assertEqual(vizrank.state_count(), 4) # 4x3x2 / 3x2 - vizrank.max_attrs = 3 + vizrank.attr_range_index = 3 self.assertEqual(vizrank.state_count(), 1) # 4x3x2x1 / 2x3x4 - vizrank.max_attrs = 4 + vizrank.attr_range_index = 4 self.assertEqual(vizrank.state_count(), 6) # 4x3 / 2 - vizrank.max_attrs = 5 + vizrank.attr_range_index = 5 self.assertEqual(vizrank.state_count(), 10) # above + 4x3x2 / 3x2 - vizrank.max_attrs = 6 + vizrank.attr_range_index = 6 self.assertEqual(vizrank.state_count(), 11) # above + 4x3x2x1 / 2x3x4 def test_iteration(self): """MosaicVizrank correctly iterates through states""" widget = self.widget - vizrank = self.vizrank + widget.vizrank_attr_range_index = 1 self.send_signal(self.widget.Inputs.data, self.iris) - vizrank.compute_attr_order() - - vizrank.max_attrs = 1 - self.assertEqual([state.copy() - for state in vizrank.iterate_states(None)], - [[0, 1], [0, 2], [1, 2], [0, 3], [1, 3], [2, 3]]) - self.assertEqual([state.copy() - for state in vizrank.iterate_states([0, 3])], - [[0, 3], [1, 3], [2, 3]]) - - vizrank.max_attrs = 6 - self.assertEqual([state.copy() - for state in vizrank.iterate_states(None)], - [[0], [1], [2], [3], - [0, 1], [0, 2], [1, 2], [0, 3], [1, 3], [2, 3], - [0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3], - [0, 1, 2, 3]]) - self.assertEqual([state.copy() - for state in vizrank.iterate_states([0, 3])], - [[0, 3], [1, 3], [2, 3], - [0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3], - [0, 1, 2, 3]]) - - vizrank.max_attrs = 4 - self.assertEqual([state.copy() - for state in vizrank.iterate_states(None)], - [[0], [1], [2], [3], - [0, 1], [0, 2], [1, 2], [0, 3], [1, 3], [2, 3]]) - self.assertEqual([state.copy() - for state in vizrank.iterate_states([0, 3])], - [[0, 3], [1, 3], [2, 3]]) + vizrank = self.widget.vizrank_dialog + self.assertEqual(set(vizrank.state_generator()), + {(0, 1), (0, 2), (1, 2), (0, 3), (1, 3), (2, 3)}) + + vizrank.attr_range_index = 6 + self.assertEqual(set(vizrank.state_generator()), + {(0, ), (1, ), (2, ), (3, ), + (0, 1), (0, 2), (1, 2), (0, 3), (1, 3), (2, 3), + (0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), + (0, 1, 2, 3)}) + + vizrank.attr_range_index = 4 + self.assertEqual(set(vizrank.state_generator()), + {(0, ), (1, ), (2, ), (3, ), + (0, 1), (0, 2), (1, 2), (0, 3), (1, 3), (2, 3)}) + + widget.vizrank_attr_range_index = 6 widget.variable_color = None - vizrank.max_attrs = 6 - self.assertEqual([state.copy() - for state in vizrank.iterate_states(None)], - [[0, 1], [0, 2], [1, 2], [0, 3], [1, 3], [2, 3], - [0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3], - [0, 1, 2, 3]]) - self.assertEqual([state.copy() - for state in vizrank.iterate_states([0, 3])], - [[0, 3], [1, 3], [2, 3], - [0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3], - [0, 1, 2, 3]]) - - vizrank.max_attrs = 4 - self.assertEqual([state.copy() - for state in vizrank.iterate_states(None)], - [[0, 1], [0, 2], [1, 2], [0, 3], [1, 3], [2, 3]]) - self.assertEqual([state.copy() - for state in vizrank.iterate_states([0, 3])], - [[0, 3], [1, 3], [2, 3]]) + self.widget.cb_attr_color.setCurrentIndex(0) + simulate.combobox_activate_index(self.widget.controls.variable_color, 0, 0) + vizrank = self.widget.vizrank_dialog + self.assertEqual(set(vizrank.state_generator()), + {(0, 1), (0, 2), (0, 3), (0, 4), + (1, 2), (1, 3), (1, 4), + (2, 3), (2, 4), + (3, 4), + (0, 1, 2), (0, 1, 3), (0, 1, 4), (0, 2, 3), (0, 2, 4), (0, 3, 4), + (1, 2, 3), (1, 2, 4), (1, 3, 4), + (2, 3, 4), + (0, 1, 2, 3), (0, 1, 3, 4), (0, 1, 2, 4), (0, 2, 3, 4), (1, 2, 3, 4)}) + + vizrank.attr_range_index = 4 + self.assertEqual(set(vizrank.state_generator()), + {(0, 1), (0, 2), (0, 3), (0, 4), + (1, 2), (1, 3), (1, 4), + (2, 3), (2, 4), + (3, 4)}) def test_row_for_state(self): """MosaicVizrank returns table row corresponding to the state""" self.send_signal(self.widget.Inputs.data, self.iris) - self.vizrank.attr_ordering = [DiscreteVariable(n) for n in "abcd"] - items = self.vizrank.row_for_state(0, [1, 3, 0]) + vizrank = self.widget.vizrank_dialog + vizrank._attr_order = [DiscreteVariable(n) for n in "abcd"] + items = vizrank.row_for_state(0, [1, 3, 0]) self.assertEqual(len(items), 1) item = items[0] self.assertEqual(item.text(), "a, b, d") self.assertEqual( - item.data(self.vizrank._AttrRole), - tuple(self.vizrank.attr_ordering[i] for i in [0, 1, 3])) + item.data(vizrank._AttrRole), + tuple(vizrank.attr_order[i] for i in [0, 1, 3])) def test_does_not_crash_cont_class(self): """MosaicVizrank computes rankings without crashing""" data = Table("housing.tab") self.send_signal(self.widget.Inputs.data, data) - self.vizrank.toggle() - - def test_pause_continue(self): - data = Table("housing.tab") - self.send_signal(self.widget.Inputs.data, data) - self.vizrank.toggle() # start - self.process_events(until=lambda: self.vizrank.saved_progress > 5) - self.vizrank.toggle() # stop - self.process_events(until=lambda: not self.vizrank.keep_running) - self.vizrank.toggle() # continue - self.process_events(until=lambda: self.vizrank.saved_progress > 20) - - def test_finished(self): - data = Table("iris.tab") - self.send_signal(self.widget.Inputs.data, data) - self.vizrank.toggle() - self.process_events(until=lambda: not self.vizrank.keep_running) - self.assertEqual(len(self.vizrank.scores), self.vizrank.state_count()) + self.widget.vizrank_button().click() def test_max_attr_combo_1_disabling(self): widget = self.widget - vizrank = widget.vizrank - combo = vizrank.max_attr_combo - model = combo.model() enabled = Qt.ItemIsSelectable | Qt.ItemIsEnabled data = Table("iris.tab") self.send_signal(self.widget.Inputs.data, data) + vizrank = widget.vizrank_dialog + combo = vizrank.attrs_combo + model = combo.model() self.assertEqual(model.item(0).flags() & enabled, enabled) - vizrank.max_attrs = 0 + vizrank.attr_range_index = 0 simulate.combobox_activate_index(self.widget.controls.variable_color, 0) - self.assertEqual(vizrank.max_attrs, 1) - self.assertEqual(int(model.item(0).flags() & enabled), 0) + vizrank = widget.vizrank_dialog + combo = vizrank.attrs_combo + model = combo.model() + self.assertEqual(vizrank.attr_range_index, 1) + self.assertEqual(model.item(0).flags() & enabled, Qt.NoItemFlags) simulate.combobox_activate_index(self.widget.controls.variable_color, 1) - self.assertEqual(vizrank.max_attrs, 1) + vizrank = widget.vizrank_dialog + combo = vizrank.attrs_combo + model = combo.model() self.assertEqual(model.item(0).flags() & enabled, enabled) + def test_disable_combo_3_4(self): + def assert_enabled(*args, sel=1): + enabled = Qt.ItemIsSelectable | Qt.ItemIsEnabled + vizrank = widget.vizrank_dialog + combo = vizrank.attrs_combo + model = combo.model() + for opt in (2, 3): + self.assertEqual(model.item(opt).flags() & enabled, + enabled if opt + 1 in args else Qt.NoItemFlags) + self.assertEqual(combo.currentIndex(), sel) + + widget = self.widget + + data = Table("iris.tab") + widget.vizrank_attr_range_index = 3 + self.send_signal(self.widget.Inputs.data, data) + assert_enabled(3, 4, sel=3) + + self.send_signal(self.widget.Inputs.data, data[:, :3]) + assert_enabled(3, sel=2) + + self.send_signal(self.widget.Inputs.data, data[:, :2]) + assert_enabled(sel=1) + def test_attr_range(self): - vizrank = self.widget.vizrank data = Table("iris.tab") domain = data.domain self.send_signal(self.widget.Inputs.data, data) - for vizrank.max_attrs, rge in ( - (0, (1, 1)), (1, (2, 2)), (2, (3, 3)), (3, (4, 4)), - (4, (1, 2)), (5, (1, 3)), (6, (1, 4))): + vizrank = self.widget.vizrank_dialog + for vizrank.attr_range_index, rge in ( + (0, (1, 2)), (1, (2, 3)), (2, (3, 4)), (3, (4, 5)), + (4, (1, 3)), (5, (1, 4)), (6, (1, 5))): self.assertEqual(vizrank.attr_range(), rge, - f"failed at max_attrs={vizrank.max_attrs}") + f"failed at attr_range_index={vizrank.attr_range_index}") reduced = data.transform(Domain(domain.attributes[:2], domain.class_var)) self.send_signal(self.widget.Inputs.data, reduced) - for vizrank.max_attrs, rge in ( - (0, (1, 1)), (1, (2, 2)), (2, (3, 2)), (3, (4, 2)), - (4, (1, 2)), (5, (1, 2)), (6, (1, 2))): + vizrank = self.widget.vizrank_dialog + for vizrank.attr_range_index, rge in ( + (0, (1, 2)), (1, (2, 3)), (2, (3, 3)), (3, (4, 3)), + (4, (1, 3)), (5, (1, 3)), (6, (1, 3))): self.assertEqual(vizrank.attr_range(), rge, - f"failed at max_attrs={vizrank.max_attrs}") - self.assertIs(vizrank.state_count() == 0, rge[0] > rge[1]) + f"failed at attr_range_index={vizrank.attr_range_index}") + self.assertIs(vizrank.state_count() == 0, rge[0] >= rge[1]) simulate.combobox_activate_index(self.widget.controls.variable_color, 0) - for vizrank.max_attrs, rge in ( - (0, (2, 2)), (1, (2, 2)), (2, (3, 3)), (3, (4, 3)), - (4, (2, 2)), (5, (2, 3)), (6, (2, 3))): + vizrank = self.widget.vizrank_dialog + for vizrank.attr_range_index, rge in ( + (0, (2, 3)), (1, (2, 3)), (2, (3, 4)), (3, (4, 4)), + (4, (2, 3)), (5, (2, 4)), (6, (2, 4))): self.assertEqual(vizrank.attr_range(), rge, - f"failed at max_attrs={vizrank.max_attrs}") - self.assertIs(vizrank.state_count() == 0, rge[0] > rge[1]) - + f"failed at attr_range_index={vizrank.attr_range_index}") + self.assertIs(vizrank.state_count() == 0, rge[0] >= rge[1]) def test_nan_column(self): """ @@ -418,9 +421,9 @@ def test_nan_column(self): Domain( [ContinuousVariable("a"), ContinuousVariable("b"), ContinuousVariable("c")]), np.array([ - [0, np.NaN, 0], - [0, np.NaN, 0], - [0, np.NaN, 0] + [0, np.nan, 0], + [0, np.nan, 0], + [0, np.nan, 0] ]) ) self.send_signal(self.widget.Inputs.data, table) @@ -429,17 +432,11 @@ def test_color_combo(self): """ Color combo enables to select class values. Checks if class values are selected correctly. - GH-2133 - GH-2036 """ - RESULTS = [[0, 1, 6], [0, 2, 4], [0, 3, 1], - [0, 4, 6], [0, 5, 10], [0, 6, 11], - [1, 0, 3], [1, 1, 3], [1, 2, 1], [1, 3, 0], - [1, 4, 6], [1, 5, 7], [1, 6, 7]] table = Table("titanic") self.send_signal(self.widget.Inputs.data, table) color_vars = ["(Pearson residuals)"] + [str(x) for x in table.domain.variables] - for i, cv in enumerate(color_vars): + for cv in color_vars: idx = self.widget.cb_attr_color.findText(cv) self.widget.cb_attr_color.setCurrentIndex(idx) color = self.widget.cb_attr_color.currentText() @@ -451,20 +448,10 @@ def test_color_combo(self): else: self.assertEqual(color, str(discrete_data.domain.class_var)) - output = self.get_output("Data") + output = self.get_output(self.widget.Outputs.annotated_data) self.assertEqual(output.domain.class_var, table.domain.class_var) - for ma in range(i == 0, 7): - self.vizrank.max_attrs = ma - sc = self.vizrank.state_count() - self.assertTrue([i > 0, ma, sc] in RESULTS) - def test_scores(self): - """ - Test scores without running vizrank. - GH-2299 - GH-2036 - """ SCORES = {('status', ): 4.35e-40, ('sex', ): 6.18e-100, ('age', ): 2.82e-05, @@ -474,13 +461,12 @@ def test_scores(self): ('age', 'sex', 'status'): 5.3e-128} table = Table("titanic") self.send_signal(self.widget.Inputs.data, table) - self.vizrank.compute_attr_order() - self.widget.vizrank.max_attrs = 3 - state = None - for state in self.vizrank.iterate_states(state): - self.vizrank.iterate_states(state) - attrlist = tuple(sorted(self.vizrank.attr_ordering[i].name for i in state)) - sc = self.vizrank.compute_score(state) + vizrank = self.widget.vizrank_dialog + vizrank.prepare_run() + vizrank.attr_range_index = 3 + for state in vizrank.state_generator(): + attrlist = tuple(sorted(vizrank.attr_order[i].name for i in state)) + sc = vizrank.compute_score(state) self.assertTrue(np.allclose(sc, SCORES[attrlist], rtol=0.003, atol=0)) def test_subset_data(self): @@ -509,21 +495,27 @@ def test_incompatible_subset(self): self.send_signal(self.widget.Inputs.data_subset, self.iris) self.assertFalse(self.widget.Warning.incompatible_subset.is_shown()) - def test_on_manual_change(self): + def test_autoselect(self): data = Table("iris.tab") + self.widget.vizrank_attr_range_index = 2 self.send_signal(self.widget.Inputs.data, data) - self.vizrank.toggle() - self.process_events(until=lambda: not self.vizrank.keep_running) - - model = self.vizrank.rank_model - attrs = model.data(model.index(3, 0), self.vizrank._AttrRole) - self.vizrank.on_manual_change(attrs) - selection = self.vizrank.rank_table.selectedIndexes() + spy = QSignalSpy(self.widget.vizrankRunStateChanged) + self.widget.vizrank_button().click() + # This takes 0.5 s on my M1 Mac (2022), but let us tolerate 20x longer + # on CI + while spy.wait(timeout=10000) and spy[-1][0] != RunState.Done: + pass + + vizrank = self.widget.vizrank_dialog + model = vizrank.rank_model + attrs = model.data(model.index(3, 0), vizrank._AttrRole) + vizrank.auto_select(attrs) + selection = vizrank.rank_table.selectedIndexes() self.assertEqual(len(selection), 1) self.assertEqual(selection[0].row(), 3) - self.vizrank.on_manual_change(attrs[::-1]) - selection = self.vizrank.rank_table.selectedIndexes() + vizrank.auto_select(attrs[::-1]) + selection = vizrank.rank_table.selectedIndexes() self.assertEqual(len(selection), 0) diff --git a/Orange/widgets/visualize/tests/test_ownomogram.py b/Orange/widgets/visualize/tests/test_ownomogram.py index 538dfb0e5bc..299f91070e8 100644 --- a/Orange/widgets/visualize/tests/test_ownomogram.py +++ b/Orange/widgets/visualize/tests/test_ownomogram.py @@ -14,9 +14,10 @@ from Orange.preprocess import Scale, Continuize from Orange.tests import test_filename from Orange.widgets.tests.base import WidgetTest +from Orange.widgets.tests.utils import simulate, qbuttongroup_emit_clicked from Orange.widgets.visualize.ownomogram import ( - OWNomogram, DiscreteFeatureItem, ContinuousFeatureItem, ProbabilitiesDotItem, - MovableToolTip + OWNomogram, DiscreteFeatureItem, ContinuousFeatureItem, + ProbabilitiesDotItem, MovableToolTip, SortBy ) @@ -26,7 +27,7 @@ def setUpClass(cls): super().setUpClass() cls.data = Table("heart_disease") cls.nb_cls = NaiveBayesLearner()(cls.data) - cls.lr_cls = LogisticRegressionLearner()(cls.data) + cls.lr_cls = LogisticRegressionLearner(max_iter=1000)(cls.data) cls.titanic = Table("titanic") cls.lenses = Table(test_filename("datasets/lenses.tab")) @@ -85,8 +86,7 @@ def test_nomogram_nb(self): def test_nomogram_lr(self): """Check probabilities for logistic regression classifier for various values of classes and radio buttons""" - self.widget.display_index = 0 # show ALL features - self._test_helper(self.lr_cls, [58, 42]) + self._test_helper(self.lr_cls, [57, 43]) def test_nomogram_nb_multiclass(self): """Check probabilities for naive bayes classifier for various values @@ -97,10 +97,8 @@ def test_nomogram_nb_multiclass(self): def test_nomogram_lr_multiclass(self): """Check probabilities for logistic regression classifier for various values of classes and radio buttons for multiclass data""" - cls = LogisticRegressionLearner( - multi_class="ovr", solver="liblinear" - )(self.lenses) - self._test_helper(cls, [9, 45, 52]) + cls = LogisticRegressionLearner(max_iter=100)(self.lenses) + self._test_helper(cls, [18, 56, 78]) def test_nomogram_with_instance_nb(self): """Check initialized marker values and feature sorting for naive bayes @@ -159,8 +157,7 @@ def _test_helper(self, cls, values): # check for all class values for i in range(self.widget.class_combo.count()): - self.widget.class_combo.activated.emit(i) - self.widget.class_combo.setCurrentIndex(i) + simulate.combobox_activate_index(self.widget.class_combo, i) # check probabilities marker value self._test_helper_check_probability(values[i]) @@ -170,25 +167,26 @@ def _test_helper(self, cls, values): self._test_helper_check_probability(values[i]) # best ranked - self.widget.n_attributes = 5 - self.widget.controls.display_index.buttons[1].click() + self.widget.n_spin.setValue(5) + self._test_helper_check_probability(values[i]) visible_items = [item for item in self.widget.scene.items() if isinstance(item, (DiscreteFeatureItem, ContinuousFeatureItem)) and item.isVisible()] self.assertGreaterEqual(5, len(visible_items)) + self._test_helper_check_probability(values[i]) # 2D curve - self.widget.n_attributes = 15 - self.widget.cont_feature_dim_combo.activated.emit(1) - self.widget.cont_feature_dim_combo.setCurrentIndex(1) + simulate.combobox_activate_index( + self.widget.cont_feature_dim_combo, 1) self._test_helper_check_probability(values[i]) # initial state self.widget.controls.scale.buttons[1].click() - self.widget.controls.display_index.buttons[0].click() - self.widget.cont_feature_dim_combo.activated.emit(0) - self.widget.cont_feature_dim_combo.setCurrentIndex(0) + self.widget.n_spin.setValue(10) + simulate.combobox_activate_index( + self.widget.cont_feature_dim_combo, 0) + self._test_helper_check_probability(values[i]) def _test_helper_check_probability(self, value): prob_marker = [item for item in self.widget.scene.items() if @@ -239,7 +237,7 @@ def test_output(self): # Set to output all self.widget.display_index = 0 - self.widget.controls.display_index.group.buttonClicked[int].emit(0) + qbuttongroup_emit_clicked(self.widget.controls.display_index.group, 0) attrs = self.get_output(self.widget.Outputs.features) self.assertEqual(attrs, [age, sex, status]) @@ -293,25 +291,98 @@ def test_dots_stop_flashing(self): def test_reconstruct_domain(self): data = Table("heart_disease") cls = LogisticRegressionLearner()(data) - domain = OWNomogram.reconstruct_domain(cls.original_domain, cls.domain) + domain = OWNomogram.reconstruct_domain(cls, cls.domain) transformed_data = cls.original_data.transform(domain) self.assertEqual(transformed_data.X.shape, data.X.shape) self.assertFalse(np.isnan(transformed_data.X[0]).any()) scaled_data = Scale()(data) cls = LogisticRegressionLearner()(scaled_data) - domain = OWNomogram.reconstruct_domain(cls.original_domain, cls.domain) + domain = OWNomogram.reconstruct_domain(cls, cls.domain) transformed_data = cls.original_data.transform(domain) self.assertEqual(transformed_data.X.shape, scaled_data.X.shape) self.assertFalse(np.isnan(transformed_data.X[0]).any()) disc_data = Continuize()(data) cls = LogisticRegressionLearner()(disc_data) - domain = OWNomogram.reconstruct_domain(cls.original_domain, cls.domain) + domain = OWNomogram.reconstruct_domain(cls, cls.domain) transformed_data = cls.original_data.transform(domain) self.assertEqual(transformed_data.X.shape, disc_data.X.shape) self.assertFalse(np.isnan(transformed_data.X[0]).any()) + def test_missing_class_value(self): + iris = Table("iris") + iris_set_ver = iris[:100] + target_cb = self.widget.controls.target_class_index + + lr = LogisticRegressionLearner()(iris) + self.send_signal(self.widget.Inputs.classifier, lr) + simulate.combobox_activate_index(target_cb, 2) + self.assertEqual(target_cb.currentIndex(), 2) + self.assertEqual(target_cb.count(), 3) + + lr = LogisticRegressionLearner()(iris_set_ver) + self.send_signal(self.widget.Inputs.classifier, lr) + self.assertEqual(target_cb.currentIndex(), 0) + self.assertEqual(target_cb.count(), 2) + + nb = NaiveBayesLearner()(iris) + self.send_signal(self.widget.Inputs.classifier, nb) + simulate.combobox_activate_index(target_cb, 2) + self.assertEqual(target_cb.currentIndex(), 2) + self.assertEqual(target_cb.count(), 3) + + nb = NaiveBayesLearner()(iris_set_ver) + self.send_signal(self.widget.Inputs.classifier, nb) + self.assertEqual(target_cb.currentIndex(), 2) + self.assertEqual(target_cb.count(), 3) + + def test_compute_value(self): + class ComputeValue: + def __call__(self, table): + return table.get_column(0) + + iris = Table("iris") + attrs = list(iris.domain.attributes) + attrs[0] = ContinuousVariable(attrs[0].name, 1, ComputeValue()) + domain = Domain(attrs, iris.domain.class_vars) + data = iris.transform(domain) + lr = LogisticRegressionLearner()(data) + self.send_signal(self.widget.Inputs.classifier, lr) + + def test_disable_sorting_pos_neg_for_logistic_regression(self): + w = self.widget + self.send_signal(w.Inputs.classifier, self.nb_cls) + combo = w.sort_combo + simulate.combobox_activate_index(combo, SortBy.POSITIVE) + assert w.sort_index == SortBy.POSITIVE + + self.send_signal(self.widget.Inputs.classifier, self.lr_cls) + self.assertEqual(w.sort_index, SortBy.ABSOLUTE) + self.assertFalse(combo.model().item(SortBy.POSITIVE).isEnabled()) + self.assertFalse(combo.model().item(SortBy.NEGATIVE).isEnabled()) + + self.send_signal(w.Inputs.classifier, self.nb_cls) + self.assertTrue(combo.model().item(SortBy.POSITIVE).isEnabled()) + self.assertTrue(combo.model().item(SortBy.NEGATIVE).isEnabled()) + + simulate.combobox_activate_index(combo, SortBy.POSITIVE) + self.send_signal(self.widget.Inputs.classifier, self.lr_cls) + self.assertEqual(w.sort_index, SortBy.ABSOLUTE) + + self.send_signal(w.Inputs.classifier, None) + self.assertTrue(combo.model().item(SortBy.POSITIVE).isEnabled()) + self.assertTrue(combo.model().item(SortBy.NEGATIVE).isEnabled()) + + def test_report_copy_save_graph(self): + # Test that reporting, copying to clipboard, and saving graph don't crash + self.send_signal(self.widget.Inputs.classifier, self.nb_cls) + self.widget.send_report() + self.widget.copy_to_clipboard() + with patch("orangewidget.utils.filedialogs.open_filename_dialog_save") as m: + m.return_value = (None, None, None) + self.widget.save_graph() + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owprojectionwidget.py b/Orange/widgets/visualize/tests/test_owprojectionwidget.py index 9a6562b8712..75b5fe20a28 100644 --- a/Orange/widgets/visualize/tests/test_owprojectionwidget.py +++ b/Orange/widgets/visualize/tests/test_owprojectionwidget.py @@ -100,6 +100,11 @@ def test_get_column_merge_infrequent(self): self.assertEqual( get_column(disc2, return_labels=True, max_categories=4), disc2.values) + np.testing.assert_almost_equal( + get_column(disc2, max_categories=3), y) + self.assertEqual( + get_column(disc2, return_labels=True, max_categories=3), + disc2.values) # Test that get_columns modify a copy of the data and not the data np.testing.assert_almost_equal(get_column(disc), x) @@ -155,6 +160,7 @@ def get_embedding(self): if not len(x_data[self.valid_data]): return None + x_data = x_data.copy() x_data[x_data == np.inf] = np.nan x_data_ = np.ones(len(x_data)) y_data = np.ones(len(x_data)) @@ -168,7 +174,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = TestableDataProjectionWidget.Inputs.data cls.signal_data = cls.data cls.same_input_output_domain = False @@ -177,12 +183,13 @@ def setUp(self): def test_annotation_with_nans(self): data = Table.from_table_rows(self.data, [0, 1, 2]) - data.X[1, :] = np.nan + with data.unlocked(): + data.X[1, :] = np.nan self.send_signal(self.widget.Inputs.data, data) points = self.widget.graph.scatterplot_item.points() self.widget.graph.select_by_click(None, [points[1]]) annotated = self.get_output(self.widget.Outputs.annotated_data) - np.testing.assert_equal(annotated.get_column_view('Selected')[0], np.array([0, 0, 1])) + np.testing.assert_equal(annotated.get_column('Selected'), np.array([0, 0, 1])) def test_saved_selection(self): self.send_signal(self.widget.Inputs.data, self.data) @@ -254,7 +261,7 @@ def test_sparse_data_reload(self): self.widget.setup_plot.assert_called_once() def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_commit') as commit: + with patch.object(self.widget.commit, 'now') as commit: self.widget.auto_commit = False commit.reset_mock() self.send_signal(self.widget.Inputs.data, self.data) diff --git a/Orange/widgets/visualize/tests/test_owpythagorastree.py b/Orange/widgets/visualize/tests/test_owpythagorastree.py index aec5cec84cf..ba0aa9ddc27 100644 --- a/Orange/widgets/visualize/tests/test_owpythagorastree.py +++ b/Orange/widgets/visualize/tests/test_owpythagorastree.py @@ -104,7 +104,7 @@ def setUpClass(cls): cls.model = tree(cls.data) cls.model.instances = cls.data - cls.signal_name = "Tree" + cls.signal_name = OWPythagorasTree.Inputs.tree cls.signal_data = cls.model # Set up for widget tests diff --git a/Orange/widgets/visualize/tests/test_owpythagoreanforest.py b/Orange/widgets/visualize/tests/test_owpythagoreanforest.py index f2c88b36212..29fa8dc1962 100644 --- a/Orange/widgets/visualize/tests/test_owpythagoreanforest.py +++ b/Orange/widgets/visualize/tests/test_owpythagoreanforest.py @@ -1,5 +1,5 @@ # pylint: disable=missing-docstring,protected-access - +import unittest from unittest.mock import Mock from AnyQt.QtCore import Qt, QItemSelection, QItemSelectionModel @@ -238,3 +238,15 @@ def test_context(self): self.send_signal(self.widget.Inputs.random_forest, iris_tree) self.assertEqual(2, self.widget.target_class_index) + + def test_report(self): + self.widget.send_report() + + self.widget.report_raw = Mock() + self.send_signal(self.widget.Inputs.random_forest, self.titanic) + self.widget.send_report() + self.widget.report_raw.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owradviz.py b/Orange/widgets/visualize/tests/test_owradviz.py index 4cc181eab92..d9055179ace 100644 --- a/Orange/widgets/visualize/tests/test_owradviz.py +++ b/Orange/widgets/visualize/tests/test_owradviz.py @@ -4,6 +4,7 @@ from unittest.mock import Mock import numpy as np +from orangewidget.tests.base import GuiTest from Orange.data import Table from Orange.widgets.tests.base import ( WidgetTest, WidgetOutputsTestMixin, @@ -19,7 +20,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWRadviz.Inputs.data cls.signal_data = cls.data cls.same_input_output_domain = False cls.heart_disease = Table("heart_disease") @@ -33,10 +34,10 @@ def check_vizrank(data): if data is not None and data.domain.class_var in \ self.widget.controls.attr_color.model(): self.widget.attr_color = data.domain.class_var - if self.widget.btn_vizrank.isEnabled(): - vizrank = RadvizVizRank(self.widget) - states = [state for state in vizrank.iterate_states(None)] - self.assertIsNotNone(vizrank.compute_score(states[0])) + if self.widget.vizrank_button().isEnabled(): + vizrank = self.widget.vizrank_dialog + self.assertIsNotNone( + vizrank.compute_score(next(vizrank.state_generator()))) check_vizrank(self.data) check_vizrank(self.data[:, :3]) @@ -131,15 +132,45 @@ def test_invalidated_model_selected(self): self.send_signal(self.widget.Inputs.data, self.data) self.widget.setup_plot.assert_called_once() - def test_score_plots_feature_update(self): - self.send_signal(self.widget.Inputs.data, self.data) - selected_vars = set(self.widget.selected_vars) - output1 = self.get_output(self.widget.Outputs.components) - self.widget.vizrank.toggle() - self.process_events(until=lambda: not self.widget.vizrank.keep_running) - self.assertNotEqual(selected_vars, set(self.widget.selected_vars)) - output2 = self.get_output(self.widget.Outputs.components) - self.assertNotEqual(output1, output2) + +class TestRadvizVizrank(GuiTest): + def test_count_and_states(self): + data = Table("iris") + dialog = RadvizVizRank( + None, + data, data.domain.attributes, data.domain.class_var, + 4) + self.assertEqual(dialog.state_count(), 7) + self.assertEqual(list(dialog.state_generator()), + [(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), + (0, 1, 2, 3), (0, 1, 3, 2), (0, 2, 1, 3)]) + + dialog = RadvizVizRank( + None, + data, data.domain.attributes, data.domain.class_var, + 3) + self.assertEqual(dialog.state_count(), 4) + self.assertEqual(list(dialog.state_generator()), + [(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)]) + + def test_ranking_and_scoring(self): + data = Table("iris") + dialog = RadvizVizRank( + None, + data, data.domain.attributes, data.domain.class_var, + 3) + # Don't crash, and return 4 different things + self.assertEqual(len({dialog.compute_score(state) + for state in dialog.state_generator()}), 4) + self.assertEqual(set(dialog.score_attributes()), + set(data.domain.attributes)) + with data.unlocked(data.X): + data.X[0, :3] = np.nan + # Tolerate missing values, and return 4 different things + self.assertEqual(len({dialog.compute_score(state) + for state in dialog.state_generator()}), 4) + self.assertEqual(set(dialog.score_attributes()), + set(data.domain.attributes)) if __name__ == "__main__": diff --git a/Orange/widgets/visualize/tests/test_owruleviewer.py b/Orange/widgets/visualize/tests/test_owruleviewer.py index 4778f773513..740dc94831d 100644 --- a/Orange/widgets/visualize/tests/test_owruleviewer.py +++ b/Orange/widgets/visualize/tests/test_owruleviewer.py @@ -21,7 +21,7 @@ def setUpClass(cls): # the Rules widget does. We simulate the model we get from the widget. cls.classifier.instances = cls.titanic - cls.signal_name = "Classifier" + cls.signal_name = OWRuleViewer.Inputs.classifier cls.signal_data = cls.classifier cls.data = cls.titanic diff --git a/Orange/widgets/visualize/tests/test_owscatterplot.py b/Orange/widgets/visualize/tests/test_owscatterplot.py index c935f63ff16..b79b7057b7e 100644 --- a/Orange/widgets/visualize/tests/test_owscatterplot.py +++ b/Orange/widgets/visualize/tests/test_owscatterplot.py @@ -8,13 +8,15 @@ from AnyQt.QtWidgets import QToolTip from AnyQt.QtGui import QColor, QFont +from orangewidget.tests.base import DEFAULT_TIMEOUT + from Orange.data import ( Table, Domain, ContinuousVariable, DiscreteVariable, TimeVariable ) from Orange.widgets.tests.base import ( WidgetTest, WidgetOutputsTestMixin, datasets, ProjectionWidgetTestMixin ) -from Orange.widgets.tests.utils import simulate +from Orange.widgets.tests.utils import simulate, excepthook_catch from Orange.widgets.utils.colorpalettes import DefaultRGBColors from Orange.widgets.visualize.owscatterplot import ( OWScatterPlot, ScatterPlotVizRank, OWScatterPlotGraph) @@ -30,7 +32,7 @@ def setUpClass(cls): WidgetOutputsTestMixin.init(cls) cls.same_input_output_domain = False - cls.signal_name = "Data" + cls.signal_name = OWScatterPlot.Inputs.data cls.signal_data = cls.data def setUp(self): @@ -74,9 +76,8 @@ def test_score_heuristics(self): DiscreteVariable("e", values="ab")) a = np.arange(10).reshape((10, 1)) data = Table(domain, np.hstack([a, a, a, a]), a >= 5) - self.send_signal(self.widget.Inputs.data, data) - vizrank = ScatterPlotVizRank(self.widget) - self.assertEqual([x.name for x in vizrank.score_heuristic()], + vizrank = ScatterPlotVizRank(self.widget, data, attr_color=data.domain.class_var) + self.assertEqual([x.name for x in vizrank.score_attributes()], list("abcd")) def test_optional_combos(self): @@ -96,7 +97,8 @@ def test_error_message(self): """Check if error message appears and then disappears when data is removed from input""" data = self.data.copy() - data.X[:, 0] = np.nan + with data.unlocked(): + data.X[:, 0] = np.nan self.send_signal(self.widget.Inputs.data, data) self.assertTrue(self.widget.Warning.missing_coords.is_shown()) self.send_signal(self.widget.Inputs.data, None) @@ -138,17 +140,28 @@ def test_data_column_infs(self): attr_x = self.widget.controls.attr_x simulate.combobox_activate_item(attr_x, "b") - def test_regression_line(self): + def test_regression_line_pair(self): """It is possible to draw the line only for pair of continuous attrs""" self.send_signal(self.widget.Inputs.data, self.data) self.assertTrue(self.widget.cb_reg_line.isEnabled()) - self.assertIsNone(self.widget.graph.reg_line_item) + self.assertListEqual([], self.widget.graph.reg_line_items) self.widget.cb_reg_line.setChecked(True) - self.assertIsNotNone(self.widget.graph.reg_line_item) + self.assertEqual(4, len(self.widget.graph.reg_line_items)) self.widget.cb_attr_y.activated.emit(4) self.widget.cb_attr_y.setCurrentIndex(4) self.assertFalse(self.widget.cb_reg_line.isEnabled()) - self.assertIsNone(self.widget.graph.reg_line_item) + self.assertListEqual([], self.widget.graph.reg_line_items) + + def test_ellipse_pair(self): + self.send_signal(self.widget.Inputs.data, self.data) + self.assertTrue(self.widget.graph.controls.show_ellipse.isEnabled()) + self.assertListEqual([], self.widget.graph.ellipse_items) + self.widget.graph.controls.show_ellipse.setChecked(True) + self.assertEqual(4, len(self.widget.graph.ellipse_items)) + self.widget.cb_attr_y.activated.emit(4) + self.widget.cb_attr_y.setCurrentIndex(4) + self.assertFalse(self.widget.graph.controls.show_ellipse.isEnabled()) + self.assertListEqual([], self.widget.graph.ellipse_items) def test_points_combo_boxes(self): """Check Point box combo models and values""" @@ -276,7 +289,7 @@ def test_points_selection(self): self.assertIsNone(selected_data) def test_migrate_selection(self): - settings = dict(selection=list(range(2))) + settings = {"selection": list(range(2))} OWScatterPlot.migrate_settings(settings, 0) self.assertEqual(settings["selection_group"], [(0, 1), (1, 1)]) @@ -287,8 +300,9 @@ def test_invalid_points_selection(self): OWScatterPlot, stored_settings={ "selection_group": [(i, 1) for i in range(50)]} ) - data = self.data.copy()[:11] - data[0, 0] = np.nan + data = self.data[:11].copy() + with data.unlocked(): + data[0, 0] = np.nan self.send_signal(self.widget.Inputs.data, data) self.assertIsNone(self.get_output(self.widget.Outputs.selected_data)) @@ -331,7 +345,7 @@ def test_features_and_data(self): self.assertIs(self.widget.attr_x, self.data.domain[2]) self.assertIs(self.widget.attr_y, self.data.domain[3]) self.assertFalse(self.widget.attr_box.isEnabled()) - self.assertFalse(self.widget.vizrank.isEnabled()) + self.assertFalse(self.widget.vizrank_button().isEnabled()) x, y = self.widget.graph.scatterplot_item.getData() np.testing.assert_array_equal(x, self.data.X[:, 2]) np.testing.assert_array_equal(y, self.data.X[:, 3]) @@ -341,11 +355,43 @@ def test_features_and_data(self): self.assertIs(self.widget.attr_x, self.data.domain[2]) self.assertIs(self.widget.attr_y, self.data.domain[3]) self.assertFalse(self.widget.attr_box.isEnabled()) - self.assertFalse(self.widget.vizrank.isEnabled()) + self.assertFalse(self.widget.vizrank_button().isEnabled()) + + self.send_signal(self.widget.Inputs.features, None) + self.assertTrue(self.widget.attr_box.isEnabled()) + self.assertTrue(self.widget.vizrank_button().isEnabled()) + + def test_features_and_hidden_data(self): + new_domain = self.data.domain.copy() + new_domain.attributes[0].attributes["hidden"] = True + data = self.data.transform(new_domain) + + self.send_signal(self.widget.Inputs.data, data) + self.send_signal(self.widget.Inputs.features, AttributeList(data.domain[:2])) + self.assertIsNone(self.widget.attr_x) + self.assertIsNone(self.widget.attr_y) + self.assertFalse(self.widget.attr_box.isEnabled()) + self.assertFalse(self.widget.vizrank_button().isEnabled()) self.send_signal(self.widget.Inputs.features, None) + self.assertEqual(self.widget.attr_x, self.data.domain[1]) + self.assertEqual(self.widget.attr_y, self.data.domain[2]) self.assertTrue(self.widget.attr_box.isEnabled()) - self.assertTrue(self.widget.vizrank.isEnabled()) + self.assertTrue(self.widget.vizrank_button().isEnabled()) + + # try with features not in data + bad_feat = AttributeList([ContinuousVariable("a"), ContinuousVariable("b")]) + self.send_signal(self.widget.Inputs.features, bad_feat) + self.assertIsNone(self.widget.attr_x) + self.assertIsNone(self.widget.attr_y) + self.assertFalse(self.widget.attr_box.isEnabled()) + self.assertFalse(self.widget.vizrank_button().isEnabled()) + + self.send_signal(self.widget.Inputs.features, None) + self.assertEqual(self.widget.attr_x, self.data.domain[1]) + self.assertEqual(self.widget.attr_y, self.data.domain[2]) + self.assertTrue(self.widget.attr_box.isEnabled()) + self.assertTrue(self.widget.vizrank_button().isEnabled()) def test_output_features(self): data = Table("iris") @@ -366,22 +412,18 @@ def test_output_features(self): def test_vizrank(self): data = Table("iris") self.send_signal(self.widget.Inputs.data, data) - vizrank = ScatterPlotVizRank(self.widget) + vizrank = self.widget.vizrank_dialog n_states = len(data.domain.attributes) n_states = n_states * (n_states - 1) / 2 - states = [state for state in vizrank.iterate_states(None)] + states = list(vizrank.state_generator()) self.assertEqual(len(states), n_states) self.assertEqual(len(set(states)), n_states) self.assertIsNotNone(vizrank.compute_score(states[0])) + self.send_signal(self.widget.Inputs.data, data[:9]) + vizrank = self.widget.vizrank_dialog self.assertIsNone(vizrank.compute_score(states[0])) - data = Table("housing")[::10] - self.send_signal(self.widget.Inputs.data, data) - vizrank = ScatterPlotVizRank(self.widget) - states = [state for state in vizrank.iterate_states(None)] - self.assertIsNotNone(vizrank.compute_score(states[0])) - def test_vizrank_class_nan(self): """ When class values are nan, vizrank should be disabled. It should behave like @@ -390,16 +432,18 @@ def test_vizrank_class_nan(self): """ def assert_vizrank_enabled(data, is_enabled): self.send_signal(self.widget.Inputs.data, data) - self.assertEqual(is_enabled, self.widget.vizrank_button.isEnabled()) + self.assertEqual(is_enabled, self.widget.vizrank_button().isEnabled()) data1 = Table("iris")[::30] - data2 = Table("iris")[::30] - data2.Y[:] = np.nan + data2 = Table("iris")[::30].copy() + with data2.unlocked(): + data2.Y[:] = np.nan domain = Domain( attributes=data2.domain.attributes[:4], class_vars=DiscreteVariable("iris", values=())) data2 = Table(domain, data2.X, Y=data2.Y) - data3 = Table("iris")[::30] - data3.Y[:] = np.nan + data3 = Table("iris")[::30].copy() + with data3.unlocked(): + data3.Y[:] = np.nan for data, is_enabled in zip([data1, data2, data1, data3, data1], [True, False, True, False, True]): @@ -411,24 +455,23 @@ def test_vizrank_nonprimitives(self): self.send_signal(self.widget.Inputs.data, data) with patch("Orange.widgets.visualize.owscatterplot.ReliefF", new=lambda *_1, **_2: lambda data: np.arange(len(data))): - self.widget.vizrank.score_heuristic() + self.widget.vizrank_button().click() def test_vizrank_enabled(self): self.send_signal(self.widget.Inputs.data, self.data) - self.assertTrue(self.widget.vizrank_button.isEnabled()) - self.assertEqual(self.widget.vizrank_button.toolTip(), "") - self.assertTrue(self.widget.vizrank.button.isEnabled()) - self.widget.vizrank.button.click() + self.assertTrue(self.widget.vizrank_button().isEnabled()) + self.assertEqual(self.widget.vizrank_button().toolTip(), "") + self.assertTrue(self.widget.vizrank_button().isEnabled()) def test_vizrank_enabled_no_data(self): self.send_signal(self.widget.Inputs.data, None) - self.assertFalse(self.widget.vizrank_button.isEnabled()) - self.assertEqual(self.widget.vizrank_button.toolTip(), "No data on input") + self.assertFalse(self.widget.vizrank_button().isEnabled()) + self.assertEqual(self.widget.vizrank_button().toolTip(), "No data on input") def test_vizrank_enabled_sparse_data(self): self.send_signal(self.widget.Inputs.data, self.data.to_sparse()) - self.assertFalse(self.widget.vizrank_button.isEnabled()) - self.assertEqual(self.widget.vizrank_button.toolTip(), "Data is sparse") + self.assertFalse(self.widget.vizrank_button().isEnabled()) + self.assertEqual(self.widget.vizrank_button().toolTip(), "Data is sparse") def test_vizrank_enabled_constant_data(self): domain = Domain([ContinuousVariable("c1"), @@ -439,21 +482,20 @@ def test_vizrank_enabled_constant_data(self): X = np.zeros((10, 4)) table = Table(domain, X, np.random.randint(2, size=10)) self.send_signal(self.widget.Inputs.data, table) - self.assertEqual(self.widget.vizrank_button.toolTip(), "") - self.assertTrue(self.widget.vizrank_button.isEnabled()) - self.assertTrue(self.widget.vizrank.button.isEnabled()) - self.widget.vizrank.button.click() + self.assertEqual(self.widget.vizrank_button().toolTip(), "") + self.assertTrue(self.widget.vizrank_button().isEnabled()) + self.assertTrue(self.widget.vizrank_button().isEnabled()) def test_vizrank_enabled_two_features(self): self.send_signal(self.widget.Inputs.data, self.data[:, :2]) - self.assertFalse(self.widget.vizrank_button.isEnabled()) - self.assertEqual(self.widget.vizrank_button.toolTip(), + self.assertFalse(self.widget.vizrank_button().isEnabled()) + self.assertEqual(self.widget.vizrank_button().toolTip(), "Not enough features for ranking") def test_vizrank_enabled_no_color_var(self): self.send_signal(self.widget.Inputs.data, self.data[:, :3]) - self.assertFalse(self.widget.vizrank_button.isEnabled()) - self.assertEqual(self.widget.vizrank_button.toolTip(), + self.assertFalse(self.widget.vizrank_button().isEnabled()) + self.assertEqual(self.widget.vizrank_button().toolTip(), "Color variable is not selected") def test_vizrank_enabled_color_var_nans(self): @@ -464,10 +506,22 @@ def test_vizrank_enabled_color_var_nans(self): DiscreteVariable("cls", values=("a", "b"))) table = Table(domain, np.random.random((10, 4)), np.full(10, np.nan)) self.send_signal(self.widget.Inputs.data, table) - self.assertFalse(self.widget.vizrank_button.isEnabled()) - self.assertEqual(self.widget.vizrank_button.toolTip(), + self.assertFalse(self.widget.vizrank_button().isEnabled()) + self.assertEqual(self.widget.vizrank_button().toolTip(), "Color variable has no values") + @patch.object(OWScatterPlot.__bases__[1], "init_vizrank") + def test_vizrank_hidden_attributes(self, init_vizrank): + """ + Test hidden attributes not considered in Find Informative Projections + """ + new_domain = self.data.domain.copy() + new_domain.attributes[0].attributes["hidden"] = True + data = self.data.transform(new_domain) + self.send_signal(self.widget.Inputs.data, data) + self.assertEqual(list(init_vizrank.call_args[0][1]), + list(new_domain.variables[1:])) + def test_auto_send_selection(self): """ Scatter Plot automatically sends selection only when the checkbox Send automatically @@ -487,7 +541,7 @@ def test_auto_send_selection(self): def test_color_is_optional(self): zoo = Table("zoo") - backbone, breathes, airborne, type = \ + backbone, breathes, airborne, type_ = \ [zoo.domain[x] for x in ["backbone", "breathes", "airborne", "type"]] default_x, default_y, default_color = \ zoo.domain[0], zoo.domain[1], zoo.domain.class_var @@ -506,7 +560,7 @@ def test_color_is_optional(self): simulate.combobox_activate_item(attr_color, airborne.name) # Send compatible dataset, values should not change - zoo2 = zoo[:, (backbone, breathes, airborne, type)] + zoo2 = zoo[:, (backbone, breathes, airborne, type_)] self.send_signal(self.widget.Inputs.data, zoo2) self.assertEqual(attr_x.currentText(), backbone.name) self.assertEqual(attr_y.currentText(), breathes.name) @@ -514,7 +568,7 @@ def test_color_is_optional(self): # Send dataset without color variable # x and y should remain, color reset to default - zoo3 = zoo[:, (backbone, breathes, type)] + zoo3 = zoo[:, (backbone, breathes, type_)] self.send_signal(self.widget.Inputs.data, zoo3) self.assertEqual(attr_x.currentText(), backbone.name) self.assertEqual(attr_y.currentText(), breathes.name) @@ -522,7 +576,7 @@ def test_color_is_optional(self): # Send dataset without x # y and color should be the same as with zoo - zoo4 = zoo[:, (default_x, default_y, breathes, airborne, type)] + zoo4 = zoo[:, (default_x, default_y, breathes, airborne, type_)] self.send_signal(self.widget.Inputs.data, zoo4) self.assertEqual(attr_x.currentText(), default_x.name) self.assertEqual(attr_y.currentText(), default_y.name) @@ -531,11 +585,11 @@ def test_color_is_optional(self): # Send dataset compatible with zoo2 and zoo3 # Color should reset to one in zoo3, as it was used more # recently - zoo5 = zoo[:, (default_x, backbone, breathes, airborne, type)] + zoo5 = zoo[:, (default_x, backbone, breathes, airborne, type_)] self.send_signal(self.widget.Inputs.data, zoo5) self.assertEqual(attr_x.currentText(), backbone.name) self.assertEqual(attr_y.currentText(), breathes.name) - self.assertEqual(attr_color.currentText(), type.name) + self.assertEqual(attr_color.currentText(), type_.name) def test_handle_metas(self): """ @@ -549,9 +603,10 @@ def test_handle_metas(self): class_vars=data.domain.class_vars, metas=data.domain.attributes[2:] ) - data = data.transform(domain) + data = data.transform(domain).copy() # Sometimes floats in metas are saved as objects - data.metas = data.metas.astype(object) + with data.unlocked(): + data.metas = data.metas.astype(object) self.send_signal(w.Inputs.data, data) simulate.combobox_activate_item(w.cb_attr_x, data.domain.metas[1].name) simulate.combobox_activate_item(w.controls.attr_color, data.domain.metas[0].name) @@ -596,8 +651,9 @@ def test_metas_zero_column(self): data = Table("iris") domain = data.domain domain = Domain(domain.attributes[:3], domain.class_vars, domain.attributes[3:]) - data = data.transform(domain) - data.metas[:, 0] = 0 + data = data.transform(domain).copy() + with data.unlocked(): + data.metas[:, 0] = 0 w = self.widget self.send_signal(w.Inputs.data, data) simulate.combobox_activate_item(w.controls.attr_x, domain.metas[0].name) @@ -608,6 +664,8 @@ def test_tooltip(self): data = Table("heart_disease") self.send_signal(self.widget.Inputs.data, data) widget = self.widget + widget.graph.aggregate_dense_regions = False + graph = widget.graph scatterplot_item = graph.scatterplot_item @@ -626,18 +684,18 @@ def test_tooltip(self): widget.tooltip_shows_all = False self.assertTrue(graph.help_event(event)) (_, text), _ = show_text.call_args - self.assertIn("age = {}".format(data[42, "age"]), text) - self.assertIn("gender = {}".format(data[42, "gender"]), text) - self.assertNotIn("max HR = {}".format(data[42, "max HR"]), text) + self.assertIn(f"age = {data[42, 'age']}", text) + self.assertIn(f"gender = {data[42, 'gender']}", text) + self.assertNotIn(f"max HR = {data[42, 'max HR']}", text) self.assertNotIn("others", text) # Show all attributes widget.tooltip_shows_all = True self.assertTrue(graph.help_event(event)) (_, text), _ = show_text.call_args - self.assertIn("age = {}".format(data[42, "age"]), text) - self.assertIn("gender = {}".format(data[42, "gender"]), text) - self.assertIn("max HR = {}".format(data[42, "max HR"]), text) + self.assertIn(f"age = {data[42, 'age']}", text) + self.assertIn(f"gender = {data[42, 'gender']}", text) + self.assertIn(f"max HR = {data[42, 'max HR']}", text) self.assertIn("... and 4 others", text) # Two points hovered @@ -645,10 +703,10 @@ def test_tooltip(self): return_value=[all_points[42], all_points[100]]): self.assertTrue(graph.help_event(event)) (_, text), _ = show_text.call_args - self.assertIn("age = {}".format(data[42, "age"]), text) - self.assertIn("gender = {}".format(data[42, "gender"]), text) - self.assertIn("age = {}".format(data[100, "age"]), text) - self.assertIn("gender = {}".format(data[100, "gender"]), text) + self.assertIn(f"age = {data[42, 'age']}", text) + self.assertIn(f"gender = {data[42, 'gender']}", text) + self.assertIn(f"age = {data[100, 'age']}", text) + self.assertIn(f"gender = {data[100, 'gender']}", text) # No points hovered with patch.object(scatterplot_item, "pointsAt", @@ -667,19 +725,23 @@ def prepare_data(): data = Table("iris") values = list(range(15)) class_var = DiscreteVariable("iris5", values=[str(v) for v in values]) - data = data.transform(Domain(attributes=data.domain.attributes, class_vars=[class_var])) - data.Y = np.array(values * 10, dtype=float) + data = data.transform( + Domain(attributes=data.domain.attributes, + class_vars=[class_var])).copy() + with data.unlocked(): + data.Y = np.array(values * 10, dtype=float) return data - def assert_equal(data, max): + def assert_equal(data, max_): self.send_signal(self.widget.Inputs.data, data) - pen_data, brush_data = self.widget.graph.get_colors() - self.assertEqual(max, len(np.unique([id(p) for p in pen_data])), ) + pen_data, _ = self.widget.graph.get_colors() + self.assertEqual(max_, len(np.unique([id(p) for p in pen_data])), ) assert_equal(prepare_data(), MAX_COLORS) # data with nan value data = prepare_data() - data.Y[42] = np.nan + with data.unlocked(): + data.Y[42] = np.nan assert_equal(data, MAX_COLORS + 1) def test_invalidated_same_features(self): @@ -733,6 +795,13 @@ def test_invalidated_same_time_features_first(self): self.widget.setup_plot.assert_called_once() self.assertListEqual(self.widget.effective_variables, list(features)) + self.widget.setup_plot.reset_mock() + features = self.data.domain.attributes[2:] + signals = [(self.widget.Inputs.features, AttributeList(features)), + (self.widget.Inputs.data, self.data)] + self.send_signals(signals) + self.widget.setup_plot.assert_called_once() + def test_invalidated_diff_features(self): self.widget.setup_plot = Mock() # send data and set default features @@ -785,8 +854,8 @@ def test_invalidated_diff_features_same_time_features_first(self): self.assertListEqual(self.widget.effective_variables, list(features)) @patch('Orange.widgets.visualize.owscatterplot.ScatterPlotVizRank.' - 'on_manual_change') - def test_vizrank_receives_manual_change(self, on_manual_change): + 'auto_select') + def test_vizrank_receives_manual_change(self, auto_select): # Recreate the widget so the patch kicks in self.widget = self.create_widget(OWScatterPlot) data = Table("iris.tab") @@ -796,25 +865,7 @@ def test_vizrank_receives_manual_change(self, on_manual_change): self.widget.attr_y = model[1] simulate.combobox_activate_index(self.widget.controls.attr_x, 2) self.assertIs(self.widget.attr_x, model[2]) - on_manual_change.assert_called_with(model[2], model[1]) - - def test_on_manual_change(self): - data = Table("iris.tab") - self.send_signal(self.widget.Inputs.data, data) - vizrank = self.widget.vizrank - vizrank.toggle() - self.process_events(until=lambda: not vizrank.keep_running) - - model = vizrank.rank_model - attrs = model.data(model.index(3, 0), vizrank._AttrRole) - vizrank.on_manual_change(*attrs) - selection = vizrank.rank_table.selectedIndexes() - self.assertEqual(len(selection), 1) - self.assertEqual(selection[0].row(), 3) - - vizrank.on_manual_change(*attrs[::-1]) - selection = vizrank.rank_table.selectedIndexes() - self.assertEqual(len(selection), 0) + auto_select.assert_called_with([model[2], model[1]]) def test_regression_lines_appear(self): self.widget.graph.controls.show_reg_line.setChecked(True) @@ -824,18 +875,32 @@ def test_regression_lines_appear(self): simulate.combobox_activate_index(self.widget.controls.attr_color, 0) self.assertEqual(len(self.widget.graph.reg_line_items), 1) data = self.data.copy() - data[:, 0] = np.nan + with data.unlocked(): + data[:, 0] = np.nan self.send_signal(self.widget.Inputs.data, data) self.assertEqual(len(self.widget.graph.reg_line_items), 0) + def test_ellipse_appear(self): + self.widget.graph.controls.show_ellipse.setChecked(True) + self.assertEqual(len(self.widget.graph.ellipse_items), 0) + self.send_signal(self.widget.Inputs.data, self.data) + self.assertEqual(len(self.widget.graph.ellipse_items), 4) + simulate.combobox_activate_index(self.widget.controls.attr_color, 0) + self.assertEqual(len(self.widget.graph.ellipse_items), 1) + data = self.data.copy() + with data.unlocked(): + data[:, 0] = np.nan + self.send_signal(self.widget.Inputs.data, data) + self.assertEqual(len(self.widget.graph.ellipse_items), 0) + def test_regression_line_coeffs(self): widget = self.widget graph = widget.graph xy = np.array([[0, 0], [1, 0], [1, 2], [2, 2], - [0, 1], [1, 3], [2, 5]], dtype=np.float) - colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=np.float) + [0, 1], [1, 3], [2, 5]], dtype=float) + colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float) widget.get_coordinates_data = lambda: xy.T - widget.can_draw_regresssion_line = lambda: True + widget.can_draw_regression_line = lambda: True widget.get_color_data = lambda: colors widget.is_continuous_color = lambda: False graph.palette = DefaultRGBColors @@ -847,13 +912,13 @@ def test_regression_line_coeffs(self): self.assertEqual(line1.pos().x(), 0) self.assertEqual(line1.pos().y(), 0) self.assertEqual(line1.angle, 45) - self.assertEqual(line1.pen.color().getRgb(), graph.palette[0].getRgb()) + self.assertEqual(line1.pen.color().hue(), graph.palette[0].hue()) line2 = graph.reg_line_items[2] self.assertEqual(line2.pos().x(), 0) self.assertEqual(line2.pos().y(), 1) self.assertAlmostEqual(line2.angle, np.degrees(np.arctan2(2, 1))) - self.assertEqual(line2.pen.color().getRgb(), graph.palette[1].getRgb()) + self.assertEqual(line2.pen.color().hue(), graph.palette[1].hue()) graph.orthonormal_regression = True graph.update_regression_line() @@ -862,13 +927,40 @@ def test_regression_line_coeffs(self): self.assertEqual(line1.pos().x(), 0) self.assertAlmostEqual(line1.pos().y(), -0.6180339887498949) self.assertAlmostEqual(line1.angle, 58.28252558853899) - self.assertEqual(line1.pen.color().getRgb(), graph.palette[0].getRgb()) + self.assertEqual(line1.pen.color().hue(), graph.palette[0].hue()) line2 = graph.reg_line_items[2] self.assertEqual(line2.pos().x(), 0) self.assertEqual(line2.pos().y(), 1) self.assertAlmostEqual(line2.angle, np.degrees(np.arctan2(2, 1))) - self.assertEqual(line2.pen.color().getRgb(), graph.palette[1].getRgb()) + self.assertEqual(line2.pen.color().hue(), graph.palette[1].hue()) + + def test_ellipse_coeffs(self): + widget = self.widget + graph = widget.graph + xy = np.array([[0, 0], [1, 0], [1, 2], [2, 2], + [0, 1], [1, 3], [2, 5]], dtype=float) + colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float) + widget.get_coordinates_data = lambda: xy.T + widget.can_draw_regression_line = lambda: True + widget.get_color_data = lambda: colors + widget.is_continuous_color = lambda: False + graph.palette = DefaultRGBColors + graph.controls.show_ellipse.setChecked(True) + + graph.update_ellipse() + + item = graph.ellipse_items[1] + self.assertEqual(item.pos().x(), 0) + self.assertEqual(item.pos().y(), 0) + self.assertEqual(item.opts["pen"].color().hue(), + graph.palette[0].hue()) + + item = graph.ellipse_items[2] + self.assertEqual(item.pos().x(), 0) + self.assertEqual(item.pos().y(), 0) + self.assertEqual(item.opts["pen"].color().hue(), + graph.palette[1].hue()) def test_orthonormal_line(self): color = QColor(1, 2, 3) @@ -953,17 +1045,17 @@ def test_add_line_calls_proper_regressor(self): graph = self.widget.graph graph._orthonormal_line = Mock(return_value=None) graph._regression_line = Mock(return_value=None) - x, y, c, w = Mock(), Mock(), Mock(), Mock() + x, y, c = Mock(), Mock(), Mock() graph.orthonormal_regression = True - graph._add_line(x, y, c, w) - graph._orthonormal_line.assert_called_once_with(x, y, c, w) + graph._add_line(x, y, c) + graph._orthonormal_line.assert_called_once_with(x, y, c, 3, Qt.SolidLine) graph._regression_line.assert_not_called() graph._orthonormal_line.reset_mock() graph.orthonormal_regression = False - graph._add_line(x, y, c, w) - graph._regression_line.assert_called_with(x, y, c, w) + graph._add_line(x, y, c) + graph._regression_line.assert_called_with(x, y, c, 3, Qt.SolidLine) graph._orthonormal_line.assert_not_called() def test_no_regression_line(self): @@ -973,8 +1065,8 @@ def test_no_regression_line(self): graph.plot_widget.addItem = Mock() - x, y, c, w = Mock(), Mock(), Mock(), Mock() - graph._add_line(x, y, c, w) + x, y, c = Mock(), Mock(), Mock() + graph._add_line(x, y, c) graph.plot_widget.addItem.assert_not_called() self.assertEqual(graph.reg_line_items, []) @@ -982,10 +1074,10 @@ def test_update_regression_line_calls_add_line(self): widget = self.widget graph = widget.graph x, y = np.array([[0, 0], [1, 0], [1, 2], [2, 2], - [0, 1], [1, 3], [2, 5]], dtype=np.float).T - colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=np.float) + [0, 1], [1, 3], [2, 5]], dtype=float).T + colors = np.array([0, 0, 0, 0, 1, 1, 1], dtype=float) widget.get_coordinates_data = lambda: (x, y) - widget.can_draw_regresssion_line = lambda: True + widget.can_draw_regression_line = lambda: True widget.get_color_data = lambda: colors widget.is_continuous_color = lambda: False graph.palette = DefaultRGBColors @@ -1001,11 +1093,11 @@ def test_update_regression_line_calls_add_line(self): np.testing.assert_equal(args2[0], x[:4]) np.testing.assert_equal(args2[1], y[:4]) - self.assertEqual(args2[2], graph.palette[0]) + self.assertEqual(args2[2].hue(), graph.palette[0].hue()) np.testing.assert_equal(args3[0], x[4:]) np.testing.assert_equal(args3[1], y[4:]) - self.assertEqual(args3[2], graph.palette[1]) + self.assertEqual(args3[2].hue(), graph.palette[1].hue()) graph._add_line.reset_mock() # Continuous color - just a single line @@ -1015,7 +1107,7 @@ def test_update_regression_line_calls_add_line(self): args1, _ = graph._add_line.call_args_list[0] np.testing.assert_equal(args1[0], x) np.testing.assert_equal(args1[1], y) - self.assertEqual(args1[2], QColor("#505050")) + self.assertEqual(args1[2].hue(), QColor("#505050").hue()) graph._add_line.reset_mock() widget.is_continuous_color = lambda: False @@ -1052,11 +1144,11 @@ def test_update_regression_line_calls_add_line(self): (args1, _), (args2, _) = graph._add_line.call_args_list np.testing.assert_equal(args1[0], x) np.testing.assert_equal(args1[1], y) - self.assertEqual(args1[2], QColor("#505050")) + self.assertEqual(args1[2].hue(), QColor("#505050").hue()) np.testing.assert_equal(args2[0], x[1:]) np.testing.assert_equal(args2[1], y[1:]) - self.assertEqual(args2[2], graph.palette[1]) + self.assertEqual(args2[2].hue(), graph.palette[1].hue()) def test_update_regression_line_is_called(self): widget = self.widget @@ -1112,7 +1204,19 @@ def test_time_axis(self): with self.assertRaises(ValueError): float(ticks[0]) - def test_visual_settings(self): + spacing, ticks = x_axis.tickValues(1581953776, 1582953776, 10)[0] + self.assertEqual(spacing, 1582953776 - 1581953776) + self.assertTrue(not ticks.size or 1581953776 <= ticks[0] <= 1582953776) + + def test_clear_plot(self): + self.widget.cb_class_density.setChecked(True) + self.send_signal(self.widget.Inputs.data, self.data) + data = self.data.transform(Domain(self.data.domain.attributes))[:100] + self.send_signal(self.widget.Inputs.data, data) + with excepthook_catch(): + self.send_signal(self.widget.Inputs.data, self.data) + + def test_visual_settings(self, timeout=DEFAULT_TIMEOUT): super().test_visual_settings() graph = self.widget.graph @@ -1136,6 +1240,285 @@ def test_visual_settings(self): for item in graph.parameter_setter.axis_items: self.assertFontEqual(item.style["tickFont"], font) + self.widget.graph.controls.show_reg_line.setChecked(True) + self.assertGreater(len(graph.parameter_setter.reg_line_label_items), 0) + self.widget.graph.controls.show_ellipse.setChecked(True) + + key, value = ('Fonts', 'Line label', 'Font size'), 16 + self.widget.set_visual_settings(key, value) + key, value = ('Fonts', 'Line label', 'Italic'), True + self.widget.set_visual_settings(key, value) + font.setPointSize(16) + for label in graph.parameter_setter.reg_line_label_items: + self.assertFontEqual(label.textItem.font(), font) + + key, value = ('Figure', 'Lines', 'Width'), 10 + self.widget.set_visual_settings(key, value) + for item in graph.reg_line_items: + self.assertEqual(item.pen.width(), 10) + for item in graph.ellipse_items: + self.assertEqual(item.opts["pen"].width(), 10) + + def test_error_bars_enabled(self): + self.assertFalse(self.widget.button_attr_x.isEnabled()) + self.assertFalse(self.widget.button_attr_y.isEnabled()) + self.send_signal(self.widget.Inputs.data, self.data) + self.assertTrue(self.widget.button_attr_x.isEnabled()) + self.assertTrue(self.widget.button_attr_y.isEnabled()) + self.send_signal(self.widget.Inputs.data, Table("zoo")) + self.assertFalse(self.widget.button_attr_x.isEnabled()) + self.assertFalse(self.widget.button_attr_y.isEnabled()) + + def test_error_bars(self): + data = Table("iris") + var = ContinuousVariable("ϵ") + data = data.add_column(var, np.full(150, 0.1)) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var + self.widget.attr_x_lower = var + self.widget.attr_y_upper = var + self.widget.attr_y_lower = var + + graph = self.widget.graph + graph.reset_graph() + self.assertEqual(len(graph.error_bars_items), 2) + + self.send_signal(self.widget.Inputs.data, None) + self.assertEqual(len(graph.error_bars_items), 0) + + def test_error_bars_missing_values(self): + data = Table("iris") + with data.unlocked(): + data.X[0, 0] = np.nan + data.X[1, 0] = np.nan + data = data[:4] + var = ContinuousVariable("ϵ") + data = data.add_column(var, np.array([0.1, np.nan, 0.1, np.nan])) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var + self.widget.attr_x_lower = var + self.widget.attr_y_upper = var + self.widget.attr_y_lower = var + + graph = self.widget.graph + graph.reset_graph() + self.assertEqual(len(graph.error_bars_items), 2) + self.assertEqual(len(graph.scatterplot_item.data), 2) + self.assertEqual(len(graph.error_bars_items[0].opts["left"]), 2) + + def test_error_bars_jitter(self): + data = Table("iris") + var = ContinuousVariable("ϵ") + data = data.add_column(var, np.full(150, 0.1)) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var + self.widget.attr_x_lower = var + + self.widget.graph.reset_graph() + error_bar_item = self.widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["left"][:3]), [0.1] * 3) + self.assertEqual(list(error_bar_item.opts["right"][:3]), [0.1] * 3) + + + self.widget.graph.controls.jitter_continuous.setChecked(True) + self.widget.graph.controls.jitter_size.setValue(10) + self.widget.graph.reset_graph() + + error_bar_item = self.widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3].round(1)), + [5.2, 5.1, 4.8]) + self.assertEqual(list(error_bar_item.opts["y"][:3].round(1)), + [3.6, 2.9, 3.3]) + self.assertEqual(list(error_bar_item.opts["left"][:3]), [0.1] * 3) + self.assertEqual(list(error_bar_item.opts["right"][:3]), [0.1] * 3) + + def test_error_bars_abs_values(self): + data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper") + var_lower = ContinuousVariable("ϵ_lower") + data = data.add_column(var_upper, data.X[:, 0] + 0.1) + data = data.add_column(var_lower, data.X[:, 0] - 0.1) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var_upper + self.widget.attr_x_lower = var_lower + self.widget.attr_x_is_abs = True + + self.widget.graph.reset_graph() + error_bar_item = self.widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["left"][:3].round(1)), + [0.1] * 3) + self.assertEqual(list(error_bar_item.opts["right"][:3].round(1)), + [0.1] * 3) + + def test_error_bars_button_clicked(self): + data = Table("iris") + var1 = ContinuousVariable("ϵ1") + var2 = ContinuousVariable("ϵ2") + var3 = ContinuousVariable("ϵ3") + var4 = ContinuousVariable("ϵ4") + data = data.add_column(var1, np.full(150, 0.1)) + data = data.add_column(var2, np.full(150, 0.1)) + data = data.add_column(var3, np.full(150, 0.1)) + data = data.add_column(var4, np.full(150, 0.1)) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var1 + self.widget.attr_x_lower = var2 + self.widget.attr_y_upper = var3 + self.widget.attr_y_lower = var4 + + x_dlg = self.widget._OWScatterPlot__x_axis_dlg + x_dlg._set_data = Mock() + x_dlg.show = Mock() + x_dlg.raise_ = Mock() + x_dlg.activateWindow = Mock() + self.widget.button_attr_x.click() + x_dlg._set_data.assert_called_with(data.domain, var1, var2, False) + + y_dlg = self.widget._OWScatterPlot__y_axis_dlg + y_dlg._set_data = Mock() + y_dlg.show = Mock() + y_dlg.raise_ = Mock() + y_dlg.activateWindow = Mock() + self.widget.button_attr_y.click() + y_dlg._set_data.assert_called_with(data.domain, var3, var4, False) + + def test_error_bars_dlg_changed(self): + data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper") + var_lower = ContinuousVariable("ϵ_lower") + data = data.add_column(var_upper, data.X[:, 1] + 0.2) + data = data.add_column(var_lower, data.X[:, 1] - 0.1) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_y_upper = var_upper + self.widget.attr_y_lower = var_lower + + y_dlg = self.widget._OWScatterPlot__y_axis_dlg + y_dlg.show = Mock() + y_dlg.raise_ = Mock() + y_dlg.activateWindow = Mock() + self.widget.button_attr_y.click() + y_dlg._ErrorBarsDialog__radio_buttons.buttons()[1].click() + + self.widget.graph.reset_graph() + error_bar_item = self.widget.graph.error_bars_items[1] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["top"][:3].round(1)), + [0.2] * 3) + self.assertEqual(list(error_bar_item.opts["bottom"][:3].round(1)), + [0.1] * 3) + + def test_error_bars_saved_settings(self): + data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper") + var_lower = ContinuousVariable("ϵ_lower") + data = data.add_column(var_upper, data.X[:, 0] + 0.2) + data = data.add_column(var_lower, data.X[:, 0] - 0.1) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var_upper + self.widget.attr_x_lower = var_lower + self.widget.attr_x_is_abs = True + + settings = self.widget.settingsHandler.pack_data(self.widget) + widget = self.create_widget(OWScatterPlot, stored_settings=settings) + self.send_signal(widget.Inputs.data, data, widget=widget) + + widget.graph.reset_graph() + error_bar_item = widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["right"][:3].round(1)), + [0.2] * 3) + self.assertEqual(list(error_bar_item.opts["left"][:3].round(1)), + [0.1] * 3) + + def test_error_bars_change_domain(self): + data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper") + var_lower = ContinuousVariable("ϵ_lower") + data = data.add_column(var_upper, data.X[:, 0] + 0.1) + data = data.add_column(var_lower, data.X[:, 0] - 0.1) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.attr_x_upper = var_upper + self.widget.attr_x_lower = var_lower + self.widget.attr_x_is_abs = True + + _data = Table("iris") + var_upper = ContinuousVariable("ϵ_upper_") + var_lower = ContinuousVariable("ϵ_lower_") + _data = _data.add_column(var_upper, _data.X[:, 0] + 0.1) + _data = _data.add_column(var_lower, _data.X[:, 0] - 0.1) + self.send_signal(self.widget.Inputs.data, _data) + self.assertEqual(self.widget.graph.error_bars_items, []) + + self.send_signal(self.widget.Inputs.data, data) + self.widget.graph.reset_graph() + error_bar_item = self.widget.graph.error_bars_items[0] + self.assertEqual(list(error_bar_item.opts["x"][:3]), + list(data.X[:3, 0])) + self.assertEqual(list(error_bar_item.opts["y"][:3]), + list(data.X[:3, 1])) + self.assertEqual(list(error_bar_item.opts["right"][:3].round(1)), + [0.1] * 3) + self.assertEqual(list(error_bar_item.opts["left"][:3].round(1)), + [0.1] * 3) + + def test_allow_aggregation(self): + widget = self.widget + graph = self.widget.graph + + self.send_signal(widget.Inputs.data, self.data) + + self.assertTrue(graph.allow_aggregation()) + + self._select_data() + self.assertFalse(graph.allow_aggregation()) + + graph.unselect_all() + self.assertTrue(graph.allow_aggregation()) + + self.send_signal(widget.Inputs.data_subset, self.data[:5]) + self.assertFalse(graph.allow_aggregation()) + + self.send_signal(widget.Inputs.data_subset, None) + self.assertTrue(graph.allow_aggregation()) + + graph.jitter_size = 1 + self.assertTrue(graph.allow_aggregation()) + + graph.jitter_continuous = True + self.assertFalse(graph.allow_aggregation()) + + graph.jitter_size = 0 + self.assertTrue(graph.allow_aggregation()) + + graph.jitter_size = 1 + graph.jitter_continuous = False + self.send_signal(widget.Inputs.data, Table("titanic")) + self.assertFalse(graph.allow_aggregation()) + + graph.jitter_size = 0 + self.assertTrue(graph.allow_aggregation()) if __name__ == "__main__": import unittest diff --git a/Orange/widgets/visualize/tests/test_owscatterplotbase.py b/Orange/widgets/visualize/tests/test_owscatterplotbase.py index f997387812a..2e0f44db651 100644 --- a/Orange/widgets/visualize/tests/test_owscatterplotbase.py +++ b/Orange/widgets/visualize/tests/test_owscatterplotbase.py @@ -4,10 +4,9 @@ from unittest.mock import patch, Mock import numpy as np -from AnyQt.QtCore import QRectF, Qt -from AnyQt.QtGui import QColor +from AnyQt.QtCore import QRectF, QPointF, Qt +from AnyQt.QtGui import QColor, QTransform from AnyQt.QtTest import QSignalSpy - from pyqtgraph import mkPen, mkBrush from orangewidget.tests.base import GuiTest @@ -22,20 +21,49 @@ class MockWidget(OWWidget): name = "Mock" - get_coordinates_data = Mock(return_value=(None, None)) - get_size_data = Mock(return_value=None) - get_shape_data = Mock(return_value=None) - get_color_data = Mock(return_value=None) - get_label_data = Mock(return_value=None) - get_color_labels = Mock(return_value=None) - get_shape_labels = Mock(return_value=None) - get_subset_mask = Mock(return_value=None) - get_tooltip = Mock(return_value="") - - is_continuous_color = Mock(return_value=False) - can_draw_density = Mock(return_value=True) - combined_legend = Mock(return_value=False) - selection_changed = Mock(return_value=None) + def __init__(self): + super().__init__() + self.graph = OWScatterPlotBase(self) + self.xy = None, None + + def get_coordinates_data(self): + return self.xy + + def get_size_data(self): + return None + + def get_shape_data(self): + return None + + def get_color_data(self): + return None + + def get_label_data(self): + return None + + def get_color_labels(self): + return None + + def get_shape_labels(self): + return None + + def get_subset_mask(self): + return None + + def get_tooltip(self): + return "" + + def is_continuous_color(self): + return False + + def can_draw_density(self): + return True + + def combined_legend(self): + return False + + def selection_changed(self): + return None GRAPH_CLASS = OWScatterPlotBase graph = SettingProvider(OWScatterPlotBase) @@ -46,27 +74,19 @@ def get_palette(self): else: return colorpalettes.DefaultDiscretePalette - @staticmethod - def reset_mocks(): - for m in MockWidget.__dict__.values(): - if isinstance(m, Mock): - m.reset_mock() + def onDeleteWidget(self): + self.graph.clear() + super().onDeleteWidget() class TestOWScatterPlotBase(WidgetTest): def setUp(self): super().setUp() - self.master = MockWidget() - self.graph = OWScatterPlotBase(self.master) - - self.xy = (np.arange(10, dtype=float), np.arange(10, dtype=float)) - self.master.get_coordinates_data = lambda: self.xy + self.master = self.create_widget(MockWidget) + self.graph = self.master.graph + self.master.xy = (np.arange(10, dtype=float), np.arange(10, dtype=float)) def tearDown(self): - self.master.onDeleteWidget() - self.master.deleteLater() - # Clear mocks as they keep ref to widget instance when called - MockWidget.reset_mocks() del self.master del self.graph super().tearDown() @@ -79,19 +99,19 @@ def setRange(self, rect=None, *_, **__): [rect.top(), rect.bottom()]] def test_update_coordinates_no_data(self): - self.xy = None, None + self.master.xy = None, None self.graph.reset_graph() self.assertIsNone(self.graph.scatterplot_item) self.assertIsNone(self.graph.scatterplot_item_sel) - self.xy = [], [] + self.master.xy = [], [] self.graph.reset_graph() self.assertIsNone(self.graph.scatterplot_item) self.assertIsNone(self.graph.scatterplot_item_sel) def test_update_coordinates(self): graph = self.graph - xy = self.xy = (np.array([1, 2]), np.array([3, 4])) + xy = self.master.xy = (np.array([1, 2]), np.array([3, 4])) graph.reset_graph() scatterplot_item = graph.scatterplot_item @@ -124,7 +144,7 @@ def test_update_coordinates(self): def test_update_coordinates_and_labels(self): graph = self.graph - xy = self.xy = (np.array([1., 2]), np.array([3, 4])) + xy = self.master.xy = (np.array([1., 2]), np.array([3, 4])) self.master.get_label_data = lambda: np.array(["a", "b"]) graph.reset_graph() self.assertEqual(graph.labels[0].pos().x(), 1) @@ -137,7 +157,7 @@ def test_update_coordinates_and_labels(self): def test_update_coordinates_and_density(self): graph = self.graph - xy = self.xy = (np.array([1, 2]), np.array([3, 4])) + xy = self.master.xy = (np.array([1, 2]), np.array([3, 4])) self.master.get_label_data = lambda: np.array(["a", "b"]) graph.reset_graph() self.assertEqual(graph.labels[0].pos().x(), 1) @@ -149,7 +169,7 @@ def test_update_coordinates_and_density(self): def test_update_coordinates_reset_view(self): graph = self.graph graph.view_box.setRange = self.setRange - xy = self.xy = (np.array([2, 1]), np.array([3, 10])) + xy = self.master.xy = (np.array([2, 1]), np.array([3, 10])) self.master.get_label_data = lambda: np.array(["a", "b"]) graph.reset_graph() self.assertEqual(self.last_setRange, [[1, 2], [3, 10]]) @@ -159,7 +179,7 @@ def test_update_coordinates_reset_view(self): self.assertEqual(self.last_setRange, [[0, 2], [3, 10]]) def test_reset_graph_no_data(self): - self.xy = (None, None) + self.master.xy = (None, None) self.graph.scatterplot_item = ScatterPlotItem([1, 2], [3, 4]) self.graph.reset_graph() self.assertIsNone(self.graph.scatterplot_item) @@ -167,7 +187,7 @@ def test_reset_graph_no_data(self): def test_update_coordinates_indices(self): graph = self.graph - self.xy = (np.array([2, 1]), np.array([3, 10])) + self.master.xy = (np.array([2, 1]), np.array([3, 10])) graph.reset_graph() np.testing.assert_almost_equal( graph.scatterplot_item.data["data"], [0, 1]) @@ -178,8 +198,8 @@ def test_sampling(self): # Enable sampling before getting the data graph.set_sample_size(3) - xy = self.xy = (np.arange(10, dtype=float), - np.arange(0, 30, 3, dtype=float)) + xy = self.master.xy = (np.arange(10, dtype=float), + np.arange(0, 30, 3, dtype=float)) d = np.arange(10, dtype=float) master.get_size_data = lambda: d master.get_shape_data = lambda: d % 5 if d is not None else None @@ -286,9 +306,9 @@ def test_sampling(self): (x[2] - x[1]) / (x[1] - x[0])) # Reset graph when data is present and sampling is enabled - self.xy = (np.arange(100, 105, dtype=float), - np.arange(100, 105, dtype=float)) - d = self.xy[0] - 100 + self.master.xy = (np.arange(100, 105, dtype=float), + np.arange(100, 105, dtype=float)) + d = self.master.xy[0] - 100 graph.reset_graph() self.process_events(until=lambda: not ( self.graph.timer is not None and self.graph.timer.isActive())) @@ -303,7 +323,7 @@ def test_sampling(self): (x[2] - x[1]) / (x[1] - x[0])) # Don't sample when unnecessary - self.xy = (np.arange(100, dtype=float), ) * 2 + self.master.xy = (np.arange(100, dtype=float), ) * 2 d = None delattr(master, "get_label_data") graph.reset_graph() @@ -315,8 +335,8 @@ def test_sampling(self): def test_sampling_keeps_selection(self): graph = self.graph - self.xy = (np.arange(100, dtype=float), - np.arange(100, dtype=float)) + self.master.xy = (np.arange(100, dtype=float), + np.arange(100, dtype=float)) graph.reset_graph() graph.select_by_indices(np.arange(1, 100, 2)) graph.set_sample_size(30) @@ -326,14 +346,13 @@ def test_sampling_keeps_selection(self): base = "Orange.widgets.visualize.owscatterplotgraph.OWScatterPlotBase." - @staticmethod @patch(base + "update_sizes") @patch(base + "update_colors") @patch(base + "update_selection_colors") @patch(base + "update_shapes") @patch(base + "update_labels") - def test_reset_calls_all_updates_and_update_doesnt(*mocks): - master = MockWidget() + def test_reset_calls_all_updates_and_update_doesnt(self, *mocks): + master = self.create_widget(MockWidget) graph = OWScatterPlotBase(master) for mock in mocks: mock.assert_not_called() @@ -514,10 +533,10 @@ def test_size_animation(self): step_resizing.wait(200) end_resizing.wait(200) self.assertEqual(len(begin_resizing), 2) # reset and update - self.assertEqual(len(step_resizing), 5) + self.assertEqual(len(step_resizing), 9) self.assertEqual(len(end_resizing), 2) # reset and update - self.assertEqual(self.graph.scatterplot_item.setSize.call_count, 6) - self._update_sizes_for_points(6) + self.assertEqual(self.graph.scatterplot_item.setSize.call_count, 10) + self._update_sizes_for_points(10) self.graph.scatterplot_item.setSize.assert_called_once() def _update_sizes_for_points(self, n: int): @@ -586,8 +605,8 @@ def test_colors_continuous_reused(self): self.master.is_continuous_color = lambda: True graph = self.graph - self.xy = (np.arange(100, dtype=float), - np.arange(100, dtype=float)) + self.master.xy = (np.arange(100, dtype=float), + np.arange(100, dtype=float)) d = np.arange(100, dtype=float) self.master.get_color_data = lambda: d @@ -635,12 +654,12 @@ def run_tests(): self.master.get_subset_mask = lambda: np.arange(10) >= 5 graph.update_colors() brushes = graph.scatterplot_item.data["brush"] - self.assertEqual(brushes[0].color().alpha(), 0) - self.assertEqual(brushes[1].color().alpha(), 0) - self.assertEqual(brushes[4].color().alpha(), 0) - self.assertEqual(brushes[5].color().alpha(), 123) - self.assertEqual(brushes[6].color().alpha(), 123) - self.assertEqual(brushes[7].color().alpha(), 123) + a0 = brushes[0].color().alpha() + self.assertEqual(brushes[1].color().alpha(), a0) + self.assertEqual(brushes[4].color().alpha(), a0) + self.assertGreater(brushes[5].color().alpha(), a0) + self.assertGreater(brushes[6].color().alpha(), a0) + self.assertGreater(brushes[7].color().alpha(), a0) graph = self.graph @@ -681,8 +700,14 @@ def test_colors_none(self): data = graph.scatterplot_item.data self.assertTrue(all(pen.color().hue() == hue for pen in data["pen"])) self.assertTrue(all(pen.color().hue() == hue for pen in data["brush"])) - self.assertEqual(len(set(map(id, data["pen"]))), 1) + self.assertEqual(len(set(map(id, data["pen"]))), 2) + self.assertEqual(data["pen"][3].color(), data["pen"][4].color()) + self.assertNotEqual(data["pen"][4].color().alpha(), + data["pen"][5].color().alpha()) self.assertEqual(len(set(map(id, data["brush"]))), 2) # transparent and colored + self.assertEqual(data["brush"][3].color(), data["brush"][4].color()) + self.assertNotEqual(data["brush"][4].color().alpha(), + data["brush"][5].color().alpha()) def test_colors_update_legend_and_density(self): graph = self.graph @@ -1205,15 +1230,14 @@ def test_label_mask_with_invisible_and_view(self): def test_labels_observes_mask(self): graph = self.graph - get_label_data = graph.master.get_label_data graph.reset_graph() self.assertEqual(graph.labels, []) - get_label_data.reset_mock() - graph._label_mask = lambda *_: None - graph.update_labels() - get_label_data.assert_not_called() + with patch.object(graph.master, "get_label_data") as m: + graph._label_mask = lambda *_: None + graph.update_labels() + m.assert_not_called() self.master.get_label_data = lambda: \ np.array([str(x) for x in range(10)], dtype=object) @@ -1294,6 +1318,87 @@ def impute0(data, _): self.assertEqual(graph.scatterplot_item.data["symbol"][2], graph.CurveSymbols[0]) + def test_set_aggregations(self): + graph = self.graph + graph.aggregate_dense_regions = True + + + with patch.object(graph, "allow_aggregation") as aa: + assert self.graph.scatterplot_item is None + aa.return_value = True + # Must not crash + graph.set_aggregation() + + graph.reset_graph() + aa.return_value = True + graph.set_aggregation() + self.assertIsNotNone(self.graph.scatterplot_item._aggregation_size) + + aa.return_value = False + graph.set_aggregation() + self.assertIsNone(self.graph.scatterplot_item._aggregation_size) + + def test_allow_aggregation(self): + graph = self.graph + + self.assertTrue(graph.allow_aggregation()) + + graph.selection = [1, 2, 3] + self.assertFalse(graph.allow_aggregation()) + graph.selection = [] + self.assertTrue(graph.allow_aggregation()) + + graph.subset_is_shown = True + self.assertFalse(graph.allow_aggregation()) + graph.subset_is_shown = False + self.assertTrue(graph.allow_aggregation()) + + graph.labels = ["Foo"] + self.assertFalse(graph.allow_aggregation()) + graph.labels = [] + self.assertTrue(graph.allow_aggregation()) + + graph.jitter_size = 1 + self.assertFalse(graph.allow_aggregation()) + graph.jitter_size = 0 + self.assertTrue(graph.allow_aggregation()) + + @patch("AnyQt.QtWidgets.QToolTip.showText") + def test_help_event(self, _): + master = self.master + graph = self.graph + event = Mock() + + graph.scatterplot_item = None + self.assertFalse(graph.help_event(event)) + + scp = graph.scatterplot_item = Mock() + scp.mapFromScene = Mock() + scp.aggregatedPointsAt = Mock(return_value=[]) + master.get_aggregated_tooltip = Mock() + scp.pointsAt = Mock(return_value=[]) + master.get_tooltip = Mock() + + self.assertFalse(graph.help_event(event)) + + mv = [Mock(), Mock()] + scp.pointsAt = Mock(return_value=mv) + self.assertTrue(graph.help_event(event)) + master.get_aggregated_tooltip.assert_not_called() + master.get_tooltip.assert_called_with([mv[0].data.return_value, + mv[1].data.return_value]) + master.get_aggregated_tooltip.reset_mock() + scp.pointsAt.reset_mock() + master.get_tooltip.reset_mock() + + scp.aggregatedPointsAt = Mock(return_value=mv) + self.assertTrue(graph.help_event(event)) + master.get_tooltip.assert_not_called() + scp.pointsAt.assert_not_called() + master.get_aggregated_tooltip.assert_called_with([mv[0].data.return_value, + mv[1].data.return_value]) + + def test_show_grid(self): graph = self.graph show_grid = self.graph.plot_widget.showGrid = Mock() @@ -1428,7 +1533,7 @@ def select(modifiers, indices): graph.update_labels.assert_not_called() self.master.selection_changed.assert_called_with() - select(0, [7, 8, 9]) + select(Qt.NoModifier, [7, 8, 9]) np.testing.assert_almost_equal( graph.selection, [0, 0, 0, 0, 0, 0, 0, 1, 1, 1]) @@ -1444,12 +1549,12 @@ def select(modifiers, indices): np.testing.assert_almost_equal( graph.selection, [0, 0, 0, 0, 2, 2, 1, 0, 1, 1]) - select(0, [1, 8]) + select(Qt.NoModifier, [1, 8]) np.testing.assert_almost_equal( graph.selection, [0, 1, 0, 0, 0, 0, 0, 0, 1, 0]) graph.label_only_selected = False - select(0, [3, 4]) + select(Qt.NoModifier, [3, 4]) def test_unselect_all(self): graph = self.graph @@ -1624,6 +1729,125 @@ def test_self_data(this, *_, **_1): np.testing.assert_equal(x, np.arange(10, 15)) np.testing.assert_equal(y, np.arange(20, 25)) + def test_set_aggregation(self): + orig_x = np.arange(10, 15) + orig_y = np.arange(20, 25) + scp = ScatterPlotItem(x=orig_x[:], y=orig_y[:]) + scp.update = Mock() + + self.assertIsNone(scp._aggregation_size) + scp.setAggregation(False) + scp.update.assert_not_called() + + scp.setAggregation(True) + self.assertIsNotNone(scp._aggregation_size) + scp.update.assert_called_once() + scp.update.reset_mock() + + scp.setAggregation(True) + self.assertIsNotNone(scp._aggregation_size) + scp.update.assert_not_called() + + scp.setAggregation(False) + scp.update.assert_called_once() + + def test_get_aggregate_points(self): + x, y = np.array([[1, 3], [1, 3], [1.5, 3.2], + [24, 100], + [200, 1], + [24.5, 100], [200, 20]]).T + n = len(x) + scp = ScatterPlotItem(x=x, y=y) + scp.setBrush([mkBrush(QColor(0, 0, 0))] + + [mkBrush(QColor(10 * i, 10 * i, 10 * i)) for i in range(n - 1)]) + + scp._agg_size_default = 10 + scp._agg_threshold_default = 3 + scp._maskAt = Mock(return_value=np.arange(n - 1)) + painter = Mock() + painter.transform = lambda: QTransform(1, 0, 0, 0, -1, 0, 0, 0, 1) + + self.assertIsNone(scp._get_aggregated_points(painter)) + np.testing.assert_equal(scp.data["visible"], np.ones(n)) + + scp.setAggregation(True) + scp._nonaggregated = True + colors = scp._get_aggregated_points(painter) + pts = scp._agg_coords + np.testing.assert_almost_equal(pts, [[ 1.16666667, -3.06666667]]) + self.assertEqual(colors, [{(0, 0, 0, 255): 2, (10, 10, 10, 255): 1}]) + np.testing.assert_equal(scp._nonaggregated, [0, 0, 0, 1, 1, 1, 1]) + + scp._aggregation_threshold = 2 + scp._nonaggregated = True + colors = scp._get_aggregated_points(painter) + pts = scp._agg_coords + np.testing.assert_almost_equal(pts, [[1.16666667, -3.06666667], + [24.25, -100]]) + self.assertEqual(colors, [ + {(0, 0, 0, 255): 2, (10, 10, 10, 255): 1}, + {(20, 20, 20, 255): 1, (40, 40, 40, 255): 1} + ]) + np.testing.assert_equal(scp._nonaggregated, [0, 0, 0, 0, 1, 0, 1]) + scp.data["visible"] = np.ones(n) + + scp._aggregation_threshold = 4 + scp._nonaggregated = True + self.assertIsNone(scp._get_aggregated_points(painter)) + np.testing.assert_equal(scp._nonaggregated, np.ones(n)) + + def test_paint_aggregated_points(self): + scp = ScatterPlotItem() + painter = Mock() + + scp._agg_coordspts = [[1, 2], [3, 4]] + colors = [ + {(0, 0, 0, 255): 2, (10, 10, 10, 255): 1}, + {(20, 20, 20, 255): 3} + ] + + # Well ... don't crash, OK? + scp._paint_aggregated_points(painter, colors) + scp._paint_aggregated_points(painter, None) + + def test_pointsAt(self): + x, y = np.array([[1, 3], [1, 3], [1.5, 3.2], + [24, 100], + [200, 1], + [24.5, 100], [200, 20]]).T + n = len(x) + scp = ScatterPlotItem(x=x, y=y) + scp.setBrush([mkBrush(QColor(0, 0, 0))] + + [mkBrush(QColor(10 * i, 10 * i, 10 * i)) for i in range(n - 1)]) + scp.setPointData(np.arange(n)) + scp._agg_size_default = 10 + scp._maskAt = Mock(return_value=np.arange(n - 1)) + painter = Mock() + painter.transform = lambda: QTransform(1, 0, 0, 0, -1, 0, 0, 0, 1) + scp.setAggregation(True) + scp._aggregation_threshold = 2 + + scp._nonaggregated = True + agg_colors = scp._get_aggregated_points(painter) + scp._paint_aggregated_points(painter, agg_colors) + + self.assertEqual( + {p.data() for p in scp.aggregatedPointsAt(QPointF(1.2, 1.3))}, {0, 1, 2}) + self.assertEqual( + len(scp.aggregatedPointsAt(QPointF(200, 1))), 0) + + def test_mask(*_, **__): + np.testing.assert_equal( + scp.data["visible"], + [False, False, False, False, True, False, True]) + + with patch( + "pyqtgraph.graphicsItems.ScatterPlotItem.ScatterPlotItem.pointsAt", + new=test_mask): + scp.pointsAt(QPointF(1.2, 1.3)) + np.testing.assert_equal(scp.data["visible"], True) + + if __name__ == "__main__": import unittest diff --git a/Orange/widgets/visualize/tests/test_owscoringsheetviewer.py b/Orange/widgets/visualize/tests/test_owscoringsheetviewer.py new file mode 100644 index 00000000000..165ed3be91c --- /dev/null +++ b/Orange/widgets/visualize/tests/test_owscoringsheetviewer.py @@ -0,0 +1,181 @@ +import unittest + +from AnyQt.QtCore import Qt + +from orangewidget.tests.base import WidgetTest + +from Orange.data import Table +from Orange.classification import LogisticRegressionLearner, ScoringSheetLearner +from Orange.widgets.widget import AttributeList +from Orange.widgets.visualize.owscoringsheetviewer import OWScoringSheetViewer + + +class TestOWScoringSheetViewer(WidgetTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.heart = Table("heart_disease") + cls.scoring_sheet_learner = ScoringSheetLearner(20, 5, 5, None) + cls.scoring_sheet_model = cls.scoring_sheet_learner(cls.heart) + cls.logistic_regression_learner = LogisticRegressionLearner(tol=1) + cls.logistic_regression_model = cls.logistic_regression_learner(cls.heart[:10]) + + def setUp(self): + self.widget = self.create_widget(OWScoringSheetViewer) + + def test_no_classifier_input(self): + coef_table = self.widget.coefficient_table + risk_slider = self.widget.risk_slider + class_combo = self.widget.class_combo + + self.assertEqual(coef_table.rowCount(), 0) + self.assertEqual(risk_slider.slider.value(), 0) + self.assertEqual(class_combo.count(), 0) + + def test_no_classifier_output(self): + self.assertIsNone(self.get_output(self.widget.Outputs.features)) + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + self.send_signal(self.widget.Inputs.classifier, None) + self.assertIsNone(self.get_output(self.widget.Outputs.features)) + + def test_classifier_output(self): + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + output = self.get_output(self.widget.Outputs.features) + self.assertIsInstance(output, AttributeList) + self.assertEqual(len(output), self.scoring_sheet_learner.num_decision_params) + + def test_table_population_on_model_input(self): + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + table = self.widget.coefficient_table + self.assertEqual( + table.rowCount(), self.scoring_sheet_learner.num_decision_params + ) + + for column in range(table.columnCount()): + for row in range(table.rowCount()): + self.assertIsNotNone(table.item(row, column)) + if column == 2: + self.assertEqual(table.item(row, column).checkState(), Qt.Unchecked) + + def test_slider_population_on_model_input(self): + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + slider = self.widget.risk_slider + self.assertIsNotNone(slider.points) + self.assertIsNotNone(slider.probabilities) + self.assertEqual(len(slider.points), len(slider.probabilities)) + + def test_slider_update_on_checkbox_toggle(self): + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + + coef_table = self.widget.coefficient_table + risk_slider = self.widget.risk_slider + risk_slider_points = risk_slider.points + + # Get the items in the first row of the table + checkbox_item = coef_table.item(0, 2) + attribute_points_item = coef_table.item(0, 1) + + # Check if the slider value is "0" before changing the checkbox + self.assertEqual(risk_slider.slider.value(), risk_slider_points.index(0)) + + # Directly change the checkbox state to Checked + checkbox_item.setCheckState(Qt.Checked) + + # Re-fetch the items after change + attribute_points_item = coef_table.item(0, 1) + + # Check if the slider value is now the same as the attribute's coefficient + self.assertEqual( + risk_slider.slider.value(), + risk_slider_points.index(float(attribute_points_item.text())), + ) + + # Directly change the checkbox state to Unchecked + checkbox_item.setCheckState(Qt.Unchecked) + + # Check if the slider value is "0" again + self.assertEqual(risk_slider.slider.value(), risk_slider_points.index(0)) + + def test_target_class_change(self): + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + self.class_combo = self.widget.class_combo + + # Check if the values of the combobox "match" the domain + self.assertEqual( + self.class_combo.count(), + len(self.scoring_sheet_model.domain.class_var.values), + ) + for i in range(self.class_combo.count()): + self.assertEqual( + self.class_combo.itemText(i), + self.scoring_sheet_model.domain.class_var.values[i], + ) + + old_coefficients = self.widget.coefficients.copy() + old_all_scores = self.widget.all_scores.copy() + old_all_risks = self.widget.all_risks.copy() + + # Change the target class to the second class + self.class_combo.setCurrentIndex(1) + self.widget._class_combo_changed() + + # Check if the coefficients, scores, and risks have changed + self.assertNotEqual(old_coefficients, self.widget.coefficients) + self.assertNotEqual(old_all_scores, self.widget.all_scores) + self.assertNotEqual(old_all_risks, self.widget.all_risks) + + def test_invalid_classifier_error(self): + self.send_signal(self.widget.Inputs.classifier, self.logistic_regression_model) + self.assertTrue(self.widget.Error.invalid_classifier.is_shown()) + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + self.assertFalse(self.widget.Error.invalid_classifier.is_shown()) + + def test_multiple_instances_information(self): + self.send_signal(self.widget.Inputs.data, self.heart[:2]) + self.assertTrue(self.widget.Information.multiple_instances.is_shown()) + self.send_signal(self.widget.Inputs.data, self.heart[:1]) + self.assertFalse(self.widget.Information.multiple_instances.is_shown()) + + def _get_checkbox_states(self, coef_table): + for row in range(coef_table.rowCount()): + if self.widget.instance_points[row] == 1: + self.assertEqual(coef_table.item(row, 2).checkState(), Qt.Checked) + else: + self.assertEqual(coef_table.item(row, 2).checkState(), Qt.Unchecked) + + def test_checkbox_after_instance_input(self): + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + self.send_signal(self.widget.Inputs.data, self.heart[:1]) + coef_table = self.widget.coefficient_table + self._get_checkbox_states(coef_table) + self.send_signal(self.widget.Inputs.data, self.heart[1:2]) + self._get_checkbox_states(coef_table) + + def test_no_classifier_UI(self): + coef_table = self.widget.coefficient_table + risk_slider = self.widget.risk_slider + class_combo = self.widget.class_combo + + self.assertEqual(coef_table.rowCount(), 0) + self.assertEqual(risk_slider.points, []) + self.assertEqual(class_combo.count(), 0) + + self.send_signal(self.widget.Inputs.classifier, self.scoring_sheet_model) + + self.assertEqual( + coef_table.rowCount(), self.scoring_sheet_learner.num_decision_params + ) + self.assertIsNotNone(risk_slider.points) + self.assertEqual( + class_combo.count(), len(self.scoring_sheet_model.domain.class_var.values) + ) + + self.send_signal(self.widget.Inputs.classifier, None) + + self.assertEqual(coef_table.rowCount(), 0) + self.assertEqual(risk_slider.points, []) + self.assertEqual(class_combo.count(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owsieve.py b/Orange/widgets/visualize/tests/test_owsieve.py index 3aab7721e04..4b5a96e0868 100644 --- a/Orange/widgets/visualize/tests/test_owsieve.py +++ b/Orange/widgets/visualize/tests/test_owsieve.py @@ -6,7 +6,7 @@ import numpy as np -from AnyQt.QtCore import QEvent, QPoint, Qt +from AnyQt.QtCore import QEvent, QPointF, Qt from AnyQt.QtGui import QMouseEvent from Orange.data import ContinuousVariable, DiscreteVariable, Domain, Table @@ -24,7 +24,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWSieveDiagram.Inputs.data cls.signal_data = cls.data cls.titanic = Table("titanic") cls.iris = Table("iris") @@ -79,8 +79,8 @@ def _select_data(self): self.widget.attr_x, self.widget.attr_y = self.data.domain[:2] area = self.widget.areas[0] self.widget.select_area(area, QMouseEvent( - QEvent.MouseButtonPress, QPoint(), Qt.LeftButton, - Qt.LeftButton, Qt.KeyboardModifiers())) + QEvent.MouseButtonPress, QPointF(), Qt.LeftButton, + Qt.LeftButton, Qt.NoModifier)) return [0, 4, 6, 7, 11, 17, 19, 21, 22, 24, 26, 39, 40, 43, 44, 46] def test_missing_values(self): @@ -110,6 +110,33 @@ def test_chisquare(self): chi = ChiSqStats(table, 0, 1) self.assertFalse(isnan(chi.chisq)) + def test_cochran_messages(self): + a = DiscreteVariable("A", values=("a1", "a2", "a3")) + b = DiscreteVariable("B", values=("b1", "b2", "b3")) + + # PASS: all expected frequencies >= 5 (20/20/20 × 20/20/20) + rows_ok = ["a1"] * 20 + ["a2"] * 20 + ["a3"] * 20 + cols_ok = ["b1"] * 20 + ["b2"] * 20 + ["b3"] * 20 + table_ok = Table.from_list(Domain([a,b]), list(zip(rows_ok, cols_ok))) + self.send_signal(self.widget.Inputs.data, table_ok) + self.widget.attr_x, self.widget.attr_y = a, b + self.widget.update_graph() + self.assertFalse(self.widget.Warning.cochran.is_shown()) + + # FAIL: some expected frequencies < 5 (10/20/30 × 20/20/20) + rows_bad = ["a1"] * 10 + ["a2"] * 20 + ["a3"] * 30 + cols_bad = ["b1"] * 20 + ["b2"] * 20 + ["b3"] * 20 + table_bad = Table.from_list(Domain([a,b]), list(zip(rows_bad, cols_bad))) + self.send_signal(self.widget.Inputs.data, table_bad) + self.widget.attr_x, self.widget.attr_y = a, b + self.widget.update_graph() + self.assertTrue(self.widget.Warning.cochran.is_shown()) + msg_text = str(self.widget.Warning.cochran) + self.assertIn("expected", msg_text.lower()) + + self.send_signal(self.widget.Inputs.data, None) + self.assertFalse(self.widget.Warning.cochran.is_shown()) + def test_metadata(self): """ Widget should interpret meta data which are continuous or discrete in @@ -142,17 +169,17 @@ def test_sparse_data(self): self.send_signal(self.widget.Inputs.data, self.iris) self.assertEqual(len(self.widget.discrete_data.domain.variables), len(self.iris.domain.variables)) - output = self.get_output("Data") + output = self.get_output(self.widget.Inputs.data) self.assertFalse(output.is_sparse()) table = self.iris.to_sparse() self.send_signal(self.widget.Inputs.data, table) self.assertEqual(len(self.widget.discrete_data.domain.variables), 2) - output = self.get_output("Data") + output = self.get_output(self.widget.Inputs.data) self.assertTrue(output.is_sparse()) - @patch('Orange.widgets.visualize.owsieve.SieveRank.on_manual_change') - def test_vizrank_receives_manual_change(self, on_manual_change): + @patch('Orange.widgets.visualize.owsieve.SieveRank.auto_select') + def test_vizrank_receives_manual_change(self, auto_select): # Recreate the widget so the patch kicks in self.widget = self.create_widget(OWSieveDiagram) data = Table("iris.tab") @@ -161,21 +188,32 @@ def test_vizrank_receives_manual_change(self, on_manual_change): self.widget.attr_x = model[2] self.widget.attr_y = model[3] simulate.combobox_activate_index(self.widget.controls.attr_x, 4) - call_args = on_manual_change.call_args[0] - self.assertEqual(len(call_args), 2) - self.assertEqual(call_args[0].name, data.domain[2].name) - self.assertEqual(call_args[1].name, data.domain[1].name) + call_args = auto_select.call_args[0][0] + self.assertEqual([v.name for v in call_args], + [data.domain[2].name, data.domain[1].name]) def test_input_features(self): self.assertTrue(self.widget.attr_box.isEnabled()) self.send_signal(self.widget.Inputs.data, self.iris) - self.send_signal(self.widget.Inputs.features, - AttributeList(self.iris.domain.attributes)) + + # Force a known initial state different from the incoming features + a0, a1, a2, a3 = self.iris.domain.attributes + self.widget.attr_x, self.widget.attr_y = a2, a3 + + # Send features -> triggers set_input_features -> resolve_shown_attributes + feats = AttributeList([a0, a1]) + self.send_signal(self.widget.Inputs.features, feats) + + # Attributes should now follow the provided features + self.assertEqual((self.widget.attr_x, self.widget.attr_y), (a0, a1)) + self.assertFalse(self.widget.attr_box.isEnabled()) - self.assertFalse(self.widget.vizrank.isEnabled()) + self.assertFalse(self.widget.vizrank_button().isEnabled()) + + # Remove features -> widget returns to interactive mode self.send_signal(self.widget.Inputs.features, None) self.assertTrue(self.widget.attr_box.isEnabled()) - self.assertTrue(self.widget.vizrank.isEnabled()) + self.assertTrue(self.widget.vizrank_button().isEnabled()) if __name__ == "__main__": diff --git a/Orange/widgets/visualize/tests/test_owsilhouetteplot.py b/Orange/widgets/visualize/tests/test_owsilhouetteplot.py index 4791a155266..3674722b61e 100644 --- a/Orange/widgets/visualize/tests/test_owsilhouetteplot.py +++ b/Orange/widgets/visualize/tests/test_owsilhouetteplot.py @@ -3,9 +3,12 @@ # pylint: disable=missing-docstring import random import unittest +from unittest.mock import Mock import numpy as np +from orangewidget.settings import Context + import Orange.distance from Orange.data import ( Table, Domain, ContinuousVariable, DiscreteVariable, StringVariable) @@ -23,7 +26,7 @@ def setUpClass(cls): WidgetOutputsTestMixin.init(cls) cls.same_input_output_domain = False - cls.signal_name = "Data" + cls.signal_name = OWSilhouettePlot.Inputs.data cls.signal_data = cls.data cls.scorename = "Silhouette ({})".format(cls.data.domain.class_var.name) @@ -63,9 +66,17 @@ def test_insufficient_clusters(self): self.send_signal(self.widget.Inputs.data, data_singletons) self.assertTrue(self.widget.Error.singleton_clusters_all.is_shown()) + def test_not_symmetric(self): + w = self.widget + self.send_signal(w.Inputs.data, DistMatrix([[1, 2, 3], [4, 5, 6]])) + self.assertTrue(w.Error.input_validation_error.is_shown()) + self.send_signal(w.Inputs.data, None) + self.assertFalse(w.Error.input_validation_error.is_shown()) + def test_unknowns_in_labels(self): data = self.data[[0, 1, 2, 50, 51, 52, 100, 101, 102]] - data.Y[::3] = np.nan + with data.unlocked(data.Y): + data.Y[::3] = np.nan valid = ~np.isnan(data.Y.flatten()) self.send_signal(self.widget.Inputs.data, data) output = self.get_output(ANNOTATED_DATA_SIGNAL_NAME) @@ -87,7 +98,8 @@ def test_nan_distances(self): self.assertEqual(self.widget.Distances[self.widget.distance_idx][0], 'Cosine') data = self.data[[0, 1, 2, 50, 51, 52, 100, 101, 102]] - data.X[::3] = 0 + with data.unlocked(data.X): + data.X[::3] = 0 valid = np.any(data.X != 0, axis=1) self.assertFalse(self.widget.Warning.nan_distances.is_shown()) self.send_signal(self.widget.Inputs.data, data) @@ -146,7 +158,7 @@ def test_bad_data_range(self): Silhouette Plot now sets axis range properly. GH-2377 """ - nan = np.NaN + nan = np.nan table = Table.from_list( Domain( [ContinuousVariable("a"), ContinuousVariable("b"), ContinuousVariable("c")], @@ -224,6 +236,72 @@ def test_unique_output_domain(self): output = self.get_output(self.widget.Outputs.annotated_data) self.assertEqual(output.domain.metas[0].name, 'Silhouette (iris) (1)') + def test_report(self): + widget = self.widget + widget.report_plot = Mock() + widget.report_caption = Mock() + + widget.send_report() + widget.report_plot.assert_not_called() + widget.report_caption.assert_not_called() + + data = Table("zoo") + self.send_signal(widget.Inputs.data, data) + + widget.annotation_var = None + widget.send_report() + widget.report_plot.assert_called() + widget.report_caption.assert_called() + text = widget.report_caption.call_args[0][0] + self.assertIn(data.domain.class_var.name, text) + self.assertNotIn("nnotated", text) + + widget.annotation_var = data.domain.metas[0] + widget._silplot.rowNamesVisible = lambda: True + widget.send_report() + text = widget.report_caption.call_args[0][0] + self.assertIn(data.domain.class_var.name, text) + self.assertIn("nnotated", text) + self.assertIn(data.domain.metas[0].name, text) + + def test_migration(self): + enc_domain = dict( + attributes=(('foo', 1), ('bar', 1), ('baz', 1), ('bax', 1), + ('cfoo', 1), ('mbaz', 1))) + + # No annotation + context = Context( + values=dict(cluster_var_idx=(0, -2), annotation_var_idx=(0, -2)), + **enc_domain + ) + OWSilhouettePlot.migrate_context(context, 1) + values = context.values + self.assertNotIn("cluster_var_idx", values) + self.assertNotIn("annotation_var_idx", values) + self.assertEqual(values["cluster_var"], ("foo", 101)) + self.assertEqual(values["annotation_var"], None) + + # We have annotation + context = Context( + values=dict(cluster_var_idx=(2, -2), annotation_var_idx=(4, -2)), + **enc_domain + ) + OWSilhouettePlot.migrate_context(context, 1) + self.assertNotIn("cluster_var_idx", values) + self.assertNotIn("annotation_var_idx", values) + self.assertEqual(context.values["cluster_var"], ("baz", 101)) + self.assertEqual(context.values["annotation_var"], ("bax", 101)) + + # We thought was had annotation, but the index is wrong due to + # incorrect domain + context = Context( + values=dict(cluster_var_idx=(4, -2), annotation_var_idx=(7, -2)), + **enc_domain + ) + OWSilhouettePlot.migrate_context(context, 1) + self.assertEqual(context.values["cluster_var"], ("cfoo", 101)) + self.assertNotIn("annotation_var_idx", values) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owtreegraph.py b/Orange/widgets/visualize/tests/test_owtreegraph.py index 75fd1943730..9e6691c70f8 100644 --- a/Orange/widgets/visualize/tests/test_owtreegraph.py +++ b/Orange/widgets/visualize/tests/test_owtreegraph.py @@ -1,9 +1,15 @@ # Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring, protected-access from os import path +import unittest +from unittest.mock import Mock +import numpy as np from Orange.classification import TreeLearner -from Orange.data import Table +from Orange.data import ( + Table, ContinuousVariable, DiscreteVariable, StringVariable, Domain) +from Orange.tree import DiscreteNode, MappedDiscreteNode, Node, NumericNode, \ + TreeModel from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin from Orange.widgets.visualize.owtreeviewer import OWTreeGraph @@ -18,7 +24,7 @@ def setUpClass(cls): cls.model = tree(cls.data) cls.model.instances = cls.data - cls.signal_name = "Tree" + cls.signal_name = OWTreeGraph.Inputs.tree cls.signal_data = cls.model # Load a dataset that contains two variables with the same entropy @@ -28,6 +34,42 @@ def setUpClass(cls): cls.data_same_entropy = tree(data_same_entropy) cls.data_same_entropy.instances = data_same_entropy + vara = DiscreteVariable("aaa", values=("e", "f", "g")) + root = DiscreteNode(vara, 0, np.array([42, 8])) + root.subset = np.arange(50) + + varb = DiscreteVariable("bbb", values=tuple("ijkl")) + child0 = MappedDiscreteNode(varb, 1, np.array([0, 1, 0, 0]), (38, 5)) + child0.subset = np.arange(16) + child1 = Node(None, 0, (13, 3)) + child1.subset = np.arange(16, 30) + varc = ContinuousVariable("ccc") + child2 = NumericNode(varc, 2, 42, (78, 12)) + child2.subset = np.arange(30, 50) + root.children = (child0, child1, child2) + + child00 = Node(None, 0, (15, 4)) + child00.subset = np.arange(10) + child01 = Node(None, 0, (10, 5)) + child01.subset = np.arange(10, 16) + child0.children = (child00, child01) + + child20 = Node(None, 0, (90, 4)) + child20.subset = np.arange(30, 35) + child21 = Node(None, 0, (70, 9)) + child21.subset = np.arange(35, 50) + child2.children = (child20, child21) + + domain = Domain([vara, varb, varc], ContinuousVariable("y")) + t = [[i, j, k] + for i in range(3) + for j in range(4) + for k in (40, 44)] + x = np.array((t * 3)[:50]) + data = Table.from_numpy( + domain, x, np.arange(len(x))) + cls.tree = TreeModel(data, root) + def setUp(self): self.widget = self.create_widget(OWTreeGraph) @@ -86,3 +128,238 @@ def _check_all_same(data): "sent to widget after receiving a dataset with variables with " "same entropy." % n_tries ) + + def test_update_node_info(self): + widget = self.widget + self.send_signal(widget.Inputs.tree, self.signal_data) + + node = Mock() + + widget.tree_adapter = Mock() + widget.tree_adapter.attribute = lambda *_: ContinuousVariable("foo") + widget.node_content_cls = lambda *_: "bar
      ban" + + widget.tree_adapter.has_children = lambda *_: True + widget.show_intermediate = False + widget.update_node_info(node) + args = node.setHtml.call_args[0][0] + self.assertIn("foo", args) + self.assertNotIn("bar", args) + + widget.tree_adapter.has_children = lambda *_: True + widget.show_intermediate = True + widget.update_node_info(node) + args = node.setHtml.call_args[0][0] + self.assertIn("bar
      ban
      foo", args) + + widget.tree_adapter.has_children = lambda *_: False + widget.show_intermediate = True + widget.update_node_info(node) + args = node.setHtml.call_args[0][0] + self.assertIn("bar
      ban
      foo", args) + + widget.tree_adapter.has_children = lambda *_: False + widget.show_intermediate = False + widget.update_node_info(node) + args = node.setHtml.call_args[0][0] + self.assertIn("bar
      ban
      foo", args) + + def test_tree_labels(self): + w = self.widget + w.show_intermediate = True + + self.send_signal(w.Inputs.tree, self.tree) + + txt = w.root_node.toPlainText() + self.assertIn("42.0 ± 8.0", txt) + self.assertIn("50 instances", txt) + self.assertIn("aaa", txt) + + children = [edge.node2 + for edge in w.root_node.graph_edges()] + + txt = children[0].toPlainText() + self.assertIn("38.0 ± 5.0", txt) + self.assertIn("16 instances", txt) + self.assertIn("bbb", txt) + + txt = children[1].toPlainText() + self.assertIn("13.0 ± 3.0", txt) + self.assertIn("14 instances", txt) + + txt = children[2].toPlainText() + self.assertIn("78.0 ± 12.0", txt) + self.assertIn("20 instances", txt) + self.assertIn("ccc", txt) + + w.controls.show_intermediate.click() + + txt = w.root_node.toPlainText() + self.assertNotIn("42.0 ± 8.0", txt) + self.assertNotIn("50 instances", txt) + self.assertIn("aaa", txt) + + children = [edge.node2 + for edge in w.root_node.graph_edges()] + + txt = children[0].toPlainText() + self.assertNotIn("38.0 ± 5.0", txt) + self.assertNotIn("16 instances", txt) + self.assertIn("bbb", txt) + + txt = children[1].toPlainText() + self.assertIn("13.0 ± 3.0", txt) + self.assertIn("14 instances", txt) + + txt = children[2].toPlainText() + self.assertNotIn("78.0 ± 12.0", txt) + self.assertNotIn("20 instances", txt) + self.assertIn("ccc", txt) + + def test_select_node_labels(self): + widget = self.widget + combo = self.widget.controls.node_labels + + def check_labels(attr): + column = widget.dataset.get_column(attr) + to_str = widget.domain[attr].str_val + for node in widget.scene.nodes(): + if widget.tree_adapter.has_children(node.node_inst): + continue + subset = node.node_inst.subset + exp = ", ".join(map(to_str, column[subset[:4]])) + if len(subset) > 4: + exp += ", …" + self.assertIn(exp, node.toHtml()) + + def switch_to(attr): + idx = combo.model().indexOf(attr and widget.domain[attr]) + combo.setCurrentIndex(idx) + combo.activated[int].emit(idx) + + zoo = Table("zoo") + iris = Table("iris") + zootree = TreeLearner()(zoo) + iristree = TreeLearner()(iris) + + self.assertIsNone(widget.node_labels) + + # Default label is a string variable (with most unique values) + self.send_signal(widget.Inputs.tree, zootree) + self.assertIs(widget.node_labels, zootree.domain["name"]) + check_labels("name") + + # Change to another variable + switch_to("hair") + check_labels("hair") + + # See that losing the data for a while keeps the label + self.send_signal(widget.Inputs.tree, None) + self.assertIsNone(widget.node_labels) + self.send_signal(widget.Inputs.tree, zootree) + check_labels("hair") + + self.send_signal(widget.Inputs.tree, iristree) + self.assertIsNone(widget.node_labels) + switch_to("petal length") + check_labels("petal length") + self.send_signal(widget.Inputs.tree, None) + self.assertIsNone(widget.node_labels) + self.send_signal(widget.Inputs.tree, iristree) + self.assertEqual(widget.node_labels, iristree.domain["petal length"]) + check_labels("petal length") + + instances = zootree.instances + zootree.instances = None + self.send_signal(widget.Inputs.tree, zootree) + self.assertIsNone(widget.node_labels) + self.assertEqual(list(widget.label_model), [None]) + + zootree.instances = instances + widget.node_labels_hint = "" # Reset to no hint + self.send_signal(widget.Inputs.tree, zootree) + self.assertIs(widget.node_labels, zootree.domain["name"]) + check_labels("name") + + def test_select_node_many_labels(self): + zoo = Table("zoo") + zootree = TreeLearner()(zoo) + + self.send_signal(self.widget.Inputs.tree, zootree) + + ta = self.widget.tree_adapter + node = next(node for node in self.widget.scene.nodes() + if not ta.has_children(node.node_inst)) + + var = zoo.domain["name"] + values = zoo.get_column(var) + ta.get_instances_in_nodes = lambda *_: zoo[[0, 50]] + self.widget.update_node_info(node) + self.assertIn(", ".join(map(var.str_val, values[[0, 50]])), + node.toHtml()) + + ta.get_instances_in_nodes = lambda *_: zoo[[0, 50, 75]] + self.widget.update_node_info(node) + self.assertIn(", ".join(map(var.str_val, values[[0, 50, 75]])), + node.toHtml()) + + ta.get_instances_in_nodes = lambda *_: zoo[[0, 50, 75, 11]] + self.widget.update_node_info(node) + self.assertIn(", ".join(map(var.str_val, values[[0, 50, 75, 11]])), + node.toHtml()) + + ta.get_instances_in_nodes = lambda *_: zoo[[0, 50, 75, 11, 3]] + self.widget.update_node_info(node) + self.assertIn(", ".join(map(var.str_val, values[[0, 50, 75, 11]])) + ", …", + node.toHtml()) + + var = zoo.domain["legs"] + values = zoo.get_column(var) + self.widget.node_labels = var + ta.get_instances_in_nodes = lambda *_: zoo[[0, 50, 75, 11]] + self.widget.update_node_info(node) + self.assertIn(", ".join(map(var.str_val, values[[0, 50, 75, 11]])), + node.toHtml()) + + ta.get_instances_in_nodes = lambda *_: zoo[[0, 50, 75, 11, 3]] + self.widget.update_node_info(node) + self.assertIn(", ".join(map(var.str_val, values[[0, 50, 75, 11]])) + ", …", + node.toHtml()) + + iris = Table("iris") + iristree = TreeLearner()(iris) + self.send_signal(self.widget.Inputs.tree, iristree) + var = iris.domain["petal length"] + values = iris.get_column(var) + self.widget.node_labels = var + ta = self.widget.tree_adapter + node = next(node for node in self.widget.scene.nodes() + if not ta.has_children(node.node_inst)) + + ta.get_instances_in_nodes = lambda *_: iris[[0, 50, 75, 11, 13, + 14, 15, 16]] + self.widget.update_node_info(node) + self.assertIn(", ".join(map(var.str_val, values[[0, 50, 75, 11]])) + ", …", + node.toHtml()) + + def test_select_node_heuristic(self): + zoo = Table("zoo") + dom = zoo.domain + d = DiscreteVariable("d", values=tuple("abcde")) + s1 = StringVariable("s1") + s2 = StringVariable("s2") + s3 = StringVariable("s3") + domain = Domain(dom.attributes, dom.class_var, [d, s1, s2, s3, dom["name"]]) + data = zoo.transform(domain) + with data.unlocked(data.metas): + data.metas[:, 0] = np.random.randint(0, 5, len(data)) + data.metas[:, 1] = [str(i % 10) for i in range(len(data))] + data.metas[:, 2] = [str(i) for i in range(len(data))] + data.metas[:, 3] = [str(i) for i in range(len(data))] + zootree = TreeLearner()(data) + self.send_signal(self.widget.Inputs.tree, zootree) + self.assertIs(self.widget.node_labels, s2) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/visualize/tests/test_owvenndiagram.py b/Orange/widgets/visualize/tests/test_owvenndiagram.py index 9d025291cea..c0b4dad8172 100644 --- a/Orange/widgets/visualize/tests/test_owvenndiagram.py +++ b/Orange/widgets/visualize/tests/test_owvenndiagram.py @@ -3,7 +3,6 @@ import unittest from unittest.mock import patch -from copy import deepcopy import numpy as np @@ -36,8 +35,9 @@ def _select_data(self): def test_rows_id(self): data = Table('zoo') - data1 = deepcopy(data) - data1[:, 1] = 1 + data1 = data.copy() + with data1.unlocked(): + data1[:, 1] = 1 self.widget.rowwise = True self.send_signal(self.signal_name, data1[:10], 1) self.widget.selected_feature = IDENTITY_STR @@ -177,7 +177,7 @@ def test_multiple_input_over_cols(self): selected_atr_name = 'Selected' input2 = self.data.transform(Domain([self.data.domain.attributes[0]], self.data.domain.class_vars, - self.data.domain.metas)) + self.data.domain.metas)).copy() self.send_signal(self.signal_name, self.data, (1, 'Data', None)) self.send_signal(self.signal_name, input2, (2, 'Data', None)) @@ -200,7 +200,8 @@ def test_multiple_input_over_cols(self): input2.metas) #domain matches but the values do not - input2.X = input2.X - 1 + with input2.unlocked(input2.X): + input2.X = input2.X - 1 self.send_signal(self.signal_name, input2, (2, 'Data', None)) self.widget.vennwidget.vennareas()[3].setSelected(True) annotated = self.get_output(self.widget.Outputs.annotated_data) @@ -216,6 +217,28 @@ def test_multiple_input_over_cols(self): self.assertFalse(out_domain[3].attributes[selected_atr_name]) self.assertFalse(out_domain[4].attributes[selected_atr_name]) + def test_test_explicit_closing(self): + data = self.data[:3] + self.widget.rowwise = True + self.send_signal(self.signal_name, data[:1], 1) + self.send_signal(self.signal_name, data[1:2], 2) + self.send_signal(self.signal_name, data[2:3], 3) + out = self.get_output(self.widget.Outputs.annotated_data) + np.testing.assert_array_equal(out.ids, data[:3].ids) + + self.send_signal(self.signal_name, None, 2) + out = self.get_output(self.widget.Outputs.annotated_data) + np.testing.assert_array_equal(out.ids, data[0:3:2].ids) + + self.send_signal(self.signal_name, data[1:2], 2) + out = self.get_output(self.widget.Outputs.annotated_data) + np.testing.assert_array_equal(out.ids, data[:3].ids) + + self.send_signal(self.signal_name, + self.widget.Inputs.data.closing_sentinel, 1) + out = self.get_output(self.widget.Outputs.annotated_data) + np.testing.assert_array_equal(out.ids, data[1:3].ids) + def test_no_data(self): """Check that the widget doesn't crash on empty data""" self.send_signal(self.signal_name, self.data[:0], 1) @@ -237,7 +260,7 @@ def test_no_data(self): self.send_signal(self.signal_name, self.data[:0], 3) def test_unconditional_commit_on_new_signal(self): - with patch.object(self.widget, 'unconditional_commit') as commit: + with patch.object(self.widget.commit, 'now') as commit: self.widget.autocommit = False commit.reset_mock() self.send_signal(self.signal_name, self.data[:100], 1) @@ -285,7 +308,8 @@ def test_too_many_inputs(self): self.send_signal(self.signal_name, self.data, 6) self.assertTrue(self.widget.Error.too_many_inputs.is_shown()) - self.send_signal(self.signal_name, None, 6) + self.send_signal(self.signal_name, + self.widget.Inputs.data.closing_sentinel, 6) self.assertFalse(self.widget.Error.too_many_inputs.is_shown()) def test_no_attributes(self): diff --git a/Orange/widgets/visualize/tests/test_owviolinplot.py b/Orange/widgets/visualize/tests/test_owviolinplot.py index 07905ff84d0..a164c48f932 100644 --- a/Orange/widgets/visualize/tests/test_owviolinplot.py +++ b/Orange/widgets/visualize/tests/test_owviolinplot.py @@ -9,7 +9,7 @@ from pyqtgraph import ViewBox -from Orange.data import Table +from Orange.data import Table, Domain, ContinuousVariable, TimeVariable from Orange.widgets.tests.base import datasets, simulate, \ WidgetOutputsTestMixin, WidgetTest from Orange.widgets.visualize.owviolinplot import OWViolinPlot, \ @@ -31,7 +31,7 @@ def setUpClass(cls): super().setUpClass() WidgetOutputsTestMixin.init(cls) - cls.signal_name = "Data" + cls.signal_name = OWViolinPlot.Inputs.data cls.signal_data = cls.data cls.housing = Table("housing") @@ -66,6 +66,7 @@ def test_controls(self): self.widget.controls.show_strip_plot.setChecked(True) self.widget.controls.show_rug_plot.setChecked(True) self.widget.controls.order_violins.setChecked(True) + self.widget.controls.show_grid.setChecked(True) self.widget.controls.orientation_index.buttons[0].click() self.widget.controls.kernel_index.setCurrentIndex(1) self.widget.controls.scale_index.setCurrentIndex(1) @@ -75,6 +76,7 @@ def test_controls(self): self.widget.controls.show_strip_plot.setChecked(False) self.widget.controls.show_rug_plot.setChecked(False) self.widget.controls.order_violins.setChecked(False) + self.widget.controls.show_grid.setChecked(False) self.widget.controls.orientation_index.buttons[1].click() self.widget.controls.kernel_index.setCurrentIndex(0) self.widget.controls.scale_index.setCurrentIndex(2) @@ -84,6 +86,7 @@ def test_controls(self): self.widget.controls.show_strip_plot.setChecked(True) self.widget.controls.show_rug_plot.setChecked(True) self.widget.controls.order_violins.setChecked(True) + self.widget.controls.show_grid.setChecked(True) self.widget.controls.orientation_index.buttons[0].click() self.widget.controls.kernel_index.setCurrentIndex(1) self.widget.controls.scale_index.setCurrentIndex(1) @@ -108,6 +111,39 @@ def test_enable_controls(self): self.assertTrue(self.widget.controls.order_violins.isEnabled()) self.assertTrue(self.widget.controls.scale_index.isEnabled()) + @patch("Orange.widgets.visualize.owviolinplot.ViolinPlot.set_show_grid") + def test_show_grid_sets_show_grid(self, show_grid): + self.send_signal(self.widget.Inputs.data, self.data) + self.widget.show_grid = True + + show_grid.reset_mock() + self.widget.controls.show_grid.click() + show_grid.assert_called_once() + + show_grid.reset_mock() + self.widget.controls.show_grid.click() + show_grid.assert_called_once() + + def test_show_grid_orientation(self): + if not self.widget.show_grid: + self.widget.controls.show_grid.click() + assert self.widget.show_grid + + self.send_signal(self.widget.Inputs.data, self.data) + + self.widget.controls.orientation_index.buttons[1].click() # Vertical + get_axis = self.widget.graph.plotItem.getAxis + self.assertIs(get_axis("bottom").grid, False) + self.assertIsNot(get_axis("left").grid, False) + + self.widget.controls.orientation_index.buttons[0].click() + self.assertIsNot(get_axis("bottom").grid, False) + self.assertIs(get_axis("left").grid, False) + + self.widget.controls.show_grid.click() + self.assertIs(get_axis("bottom").grid, False) + self.assertIs(get_axis("left").grid, False) + def test_datasets(self): self.widget.controls.show_strip_plot.setChecked(True) self.widget.controls.show_rug_plot.setChecked(True) @@ -282,6 +318,7 @@ def test_saved_selection(self): widget=widget) self.assert_table_equal(selected2, selected3) + @WidgetTest.skipNonEnglish def test_visual_settings(self): graph = self.widget.graph @@ -345,6 +382,37 @@ def assertFontEqual(self, font1, font2): self.assertEqual(font1.pointSize(), font2.pointSize()) self.assertEqual(font1.italic(), font2.italic()) + def test_use_time(self): + widget = self.widget + widget.orientation_index = 1 # Vertical + left_axis = widget.graph.plotItem.getAxis("left") + bottom_axis = widget.graph.plotItem.getAxis("bottom") + x, y = TimeVariable("x"), ContinuousVariable("y") + domain = Domain([x, y], []) + data = Table.from_list(domain, [ + ["2020-01-01", 1], + ["2020-01-02", 2], + ["2020-01-03", 3], + ["2020-01-04", 4], + ["2020-01-05", 5]]) + self.send_signal(widget.Inputs.data, data) + self.__select_value(widget._value_var_view, "y") + self.assertFalse(left_axis.is_time()) + self.assertFalse(bottom_axis.is_time()) + + self.__select_value(widget._value_var_view, "x") + self.assertTrue(left_axis.is_time()) + self.assertFalse(bottom_axis.is_time()) + + widget.controls.orientation_index.buttons[0].click() + assert widget.orientation_index == 0 # Horizontal + self.assertFalse(left_axis.is_time()) + self.assertTrue(bottom_axis.is_time()) + + self.__select_value(widget._value_var_view, "y") + self.assertFalse(left_axis.is_time()) + self.assertFalse(bottom_axis.is_time()) + @staticmethod def __select_value(list_, value): model = list_.model() diff --git a/Orange/widgets/visualize/tests/test_vizrankdialog.py b/Orange/widgets/visualize/tests/test_vizrankdialog.py index 343bb6e41ca..6261347684d 100644 --- a/Orange/widgets/visualize/tests/test_vizrankdialog.py +++ b/Orange/widgets/visualize/tests/test_vizrankdialog.py @@ -2,6 +2,7 @@ import unittest from unittest.mock import Mock from queue import Queue +import warnings from AnyQt.QtGui import QStandardItem @@ -94,6 +95,12 @@ def assertQueueEqual(self, queue, positions, f, states, next_states): class TestVizRankDialog(WidgetTest): + def setUp(self): + warnings.filterwarnings( + "ignore", + ".*Orange.widgets.visualize.utils.vizrank.VizRankDialog.*") + super().setUp() + def test_on_partial_result(self): def iterate_states(initial_state): if initial_state is not None: diff --git a/Orange/widgets/visualize/utils/__init__.py b/Orange/widgets/visualize/utils/__init__.py index da19cbac3d8..6f47d1cba60 100644 --- a/Orange/widgets/visualize/utils/__init__.py +++ b/Orange/widgets/visualize/utils/__init__.py @@ -16,6 +16,7 @@ QVBoxLayout, QLineEdit ) from Orange.data import Variable +from Orange.util import deprecated from Orange.widgets import gui from Orange.widgets.gui import HorizontalGridDelegate, TableBarItem from Orange.widgets.utils.concurrent import ConcurrentMixin, TaskState @@ -101,6 +102,7 @@ class VizRankDialog(QDialog, ProgressBarMixin, WidgetMessagesMixin, class Information(WidgetMessagesMixin.Information): nothing_to_rank = Msg("There is nothing to rank.") + @deprecated("Orange.widgets.visualize.utils.vizrank.VizRankDialog") def __init__(self, master): """Initialize the attributes and set up the interface""" QDialog.__init__(self, master, windowTitle=self.captionTitle) diff --git a/Orange/widgets/visualize/utils/component.py b/Orange/widgets/visualize/utils/component.py index da54482a2fc..8a1a1c2216e 100644 --- a/Orange/widgets/visualize/utils/component.py +++ b/Orange/widgets/visualize/utils/component.py @@ -1,7 +1,7 @@ """Common gui.OWComponent components.""" from AnyQt.QtCore import Qt, QRectF -from AnyQt.QtGui import QColor, QFont +from AnyQt.QtGui import QFont, QPalette from AnyQt.QtWidgets import QGraphicsEllipseItem import pyqtgraph as pg @@ -71,7 +71,8 @@ def update_circle(self): if self.scatterplot_item is not None and not self.circle_item: self.circle_item = QGraphicsEllipseItem() self.circle_item.setRect(QRectF(-1, -1, 2, 2)) - self.circle_item.setPen(pg.mkPen(QColor(0, 0, 0), width=2)) + color = self.plot_widget.palette().color(QPalette.Text) + self.circle_item.setPen(pg.mkPen(color, width=2)) self.plot_widget.addItem(self.circle_item) def reset_button_clicked(self): diff --git a/Orange/widgets/visualize/utils/customizableplot.py b/Orange/widgets/visualize/utils/customizableplot.py index 50f09cc1e4b..3996f0adca4 100644 --- a/Orange/widgets/visualize/utils/customizableplot.py +++ b/Orange/widgets/visualize/utils/customizableplot.py @@ -27,7 +27,7 @@ def available_font_families() -> List: """ if not QApplication.instance(): _ = QApplication(sys.argv) - fonts = QFontDatabase().families() + fonts = QFontDatabase.families() default = default_font_family() defaults = [default] @@ -123,10 +123,12 @@ def update_axes_titles_font(items: List[pg.AxisItem], **settings: _SettingType): for item in items: font = Updater.change_font(item.label.font(), settings) + default_color = pg.mkPen(pg.getConfigOption("foreground")) item.label.setFont(font) fstyle = ["normal", "italic"][font.italic()] style = {"font-size": f"{font.pointSize()}pt", "font-family": f"{font.family()}", + "color": item.labelStyle.get("color", default_color), "font-style": f"{fstyle}"} item.setLabel(item.labelText, item.labelUnits, item.labelUnitPrefix, **style) @@ -213,6 +215,30 @@ def update_lines(items: List[pg.PlotCurveItem], **settings: _SettingType): item.setPen(pen) + @staticmethod + def update_inf_lines(items, **settings): + for item in items: + pen = item.pen + + alpha = settings.get(Updater.ALPHA_LABEL) + if alpha is not None: + color = pen.color() + color.setAlpha(alpha) + pen.setColor(color) + + if hasattr(item, "label"): + item.label.setColor(color) + + style = settings.get(Updater.STYLE_LABEL) + if style is not None: + pen.setStyle(Updater.LINE_STYLES[style]) + + width = settings.get(Updater.WIDTH_LABEL) + if width is not None: + pen.setWidth(width) + + item.setPen(pen) + class CommonParameterSetter: """ Subclass to add 'setter' functionality to a plot. """ @@ -225,9 +251,11 @@ class CommonParameterSetter: AXIS_TICKS_LABEL = "Axis ticks" LEGEND_LABEL = "Legend" LABEL_LABEL = "Label" + LINE_LAB_LABEL = "Line label" X_AXIS_LABEL = "x-axis title" Y_AXIS_LABEL = "y-axis title" TITLE_LABEL = "Title" + LINE_LABEL = "Lines" FONT_FAMILY_SETTING = None # set in __init__ because it requires a running QApplication FONT_SETTING = None # set in __init__ because it requires a running QApplication diff --git a/Orange/widgets/visualize/utils/error_bars_dialog.py b/Orange/widgets/visualize/utils/error_bars_dialog.py new file mode 100644 index 00000000000..bfb96b4222e --- /dev/null +++ b/Orange/widgets/visualize/utils/error_bars_dialog.py @@ -0,0 +1,125 @@ +import sys +from typing import Optional + +from AnyQt.QtCore import Signal, Qt +from AnyQt.QtWidgets import QVBoxLayout, QWidget, QComboBox, \ + QFormLayout, QLabel, QButtonGroup, QRadioButton, QLayout + +from Orange.data import ContinuousVariable, Domain +from Orange.widgets.utils import disconnected +from Orange.widgets.utils.itemmodels import DomainModel + + +class ErrorBarsDialog(QWidget): + changed = Signal() + + def __init__( + self, + parent: QWidget, + ): + super().__init__(parent) + self.setWindowFlags(self.windowFlags() | Qt.Popup) + self.hide() + self.__model = DomainModel( + separators=False, + valid_types=(ContinuousVariable,), + placeholder="(None)" + ) + + self.__upper_combo = upper_combo = QComboBox() + upper_combo.setMinimumWidth(200) + upper_combo.setModel(self.__model) + upper_combo.currentIndexChanged.connect(self.changed) + + self.__lower_combo = lower_combo = QComboBox() + lower_combo.setMinimumWidth(200) + lower_combo.setModel(self.__model) + lower_combo.currentIndexChanged.connect(self.changed) + + button_diff = QRadioButton("Difference from plotted value", + checked=True) + button_abs = QRadioButton("Absolute position on the plot") + self.__radio_buttons = QButtonGroup() + self.__radio_buttons.addButton(button_diff, 0) + self.__radio_buttons.addButton(button_abs, 1) + self.__radio_buttons.buttonClicked.connect(self.changed) + + form = QFormLayout() + form.addRow(QLabel("Upper:"), upper_combo) + form.addRow(QLabel("Lower:"), lower_combo) + form.setVerticalSpacing(10) + form.addRow(button_diff) + form.addRow(button_abs) + + layout = QVBoxLayout() + self.setLayout(layout) + layout.addLayout(form) + layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) + + def get_data(self) -> tuple[ + Optional[ContinuousVariable], Optional[ContinuousVariable], bool + ]: + upper_var, lower_var = None, None + if self.__model: + upper_var = self.__model[self.__upper_combo.currentIndex()] + lower_var = self.__model[self.__lower_combo.currentIndex()] + return upper_var, lower_var, bool(self.__radio_buttons.checkedId()) + + def show_dlg( + self, + domain: Domain, + x: int, y: int, + attr_upper: Optional[ContinuousVariable] = None, + attr_lower: Optional[ContinuousVariable] = None, + is_abs: bool = True + ): + self._set_data(domain, attr_upper, attr_lower, is_abs) + self.show() + self.raise_() + self.move(x, y) + self.activateWindow() + + def _set_data( + self, + domain: Domain, + upper_attr: Optional[ContinuousVariable], + lower_attr: Optional[ContinuousVariable], + is_abs: bool + ): + upper_combo, lower_combo = self.__upper_combo, self.__lower_combo + with disconnected(upper_combo.currentIndexChanged, self.changed): + with disconnected(lower_combo.currentIndexChanged, self.changed): + self.__model.set_domain(domain) + upper_combo.setCurrentIndex(self.__model.indexOf(upper_attr)) + lower_combo.setCurrentIndex(self.__model.indexOf(lower_attr)) + self.__radio_buttons.buttons()[int(is_abs)].setChecked(True) + + +if __name__ == "__main__": + # pylint: disable=ungrouped-imports + from AnyQt.QtWidgets import QApplication, QPushButton + + from Orange.data import Table + + app = QApplication(sys.argv) + w = QWidget() + w.setFixedSize(400, 200) + + dlg = ErrorBarsDialog(w) + dlg.changed.connect(lambda: print(dlg.get_data())) + + btn = QPushButton(w) + btn.setText("Open") + + _domain: Domain = Table("iris").domain + + + def _on_click(): + dlg.show_dlg(_domain, 500, 500, _domain.attributes[2], + _domain.attributes[3], is_abs=False) + + + btn.clicked.connect(_on_click) + + w.show() + sys.exit(app.exec()) diff --git a/Orange/widgets/visualize/utils/heatmap.py b/Orange/widgets/visualize/utils/heatmap.py index 8212e841060..5ed2e2ebd0a 100644 --- a/Orange/widgets/visualize/utils/heatmap.py +++ b/Orange/widgets/visualize/utils/heatmap.py @@ -19,8 +19,6 @@ QGraphicsLayoutItem ) -import pyqtgraph as pg - from Orange.clustering import hierarchical from Orange.clustering.hierarchical import Tree from Orange.widgets.utils import apply_all @@ -32,6 +30,7 @@ from Orange.widgets.utils.graphicstextlist import TextListWidget from Orange.widgets.utils.dendrogram import DendrogramWidget +from Orange.widgets.visualize.utils.plotutils import AxisItem def leaf_indices(tree: Tree) -> Sequence[int]: @@ -405,6 +404,7 @@ def setHeatmaps(self, parts: 'Parts') -> None: for i, rowitem in enumerate(parts.rows): if rowitem.title: item = QGraphicsSimpleTextItem(rowitem.title, parent=self) + item.setBrush(self.palette().text()) item.setTransform(item.transform().rotate(-90)) item = SimpleLayoutItem(item, parent=grid, anchor=(0, 1), anchorItem=(0, 0)) @@ -433,10 +433,10 @@ def setHeatmaps(self, parts: 'Parts') -> None: for j, colitem in enumerate(parts.columns): if colitem.title: - item = SimpleLayoutItem( - QGraphicsSimpleTextItem(colitem.title, parent=self), - parent=grid, anchor=(0.5, 0.5), anchorItem=(0.5, 0.5) - ) + item = QGraphicsSimpleTextItem(colitem.title, parent=self) + item.setBrush(self.palette().text()) + item = SimpleLayoutItem(item, parent=grid, anchor=(0.5, 0.5), + anchorItem=(0.5, 0.5)) item.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) grid.addItem(item, self.GroupTitleRow, Col0 + 2 * j + 1) @@ -935,6 +935,11 @@ def setShowAverages(self, visible): item.setVisible(visible) item.setPreferredWidth(0 if not visible else 10) + def changeEvent(self, event): + if event.type() == QEvent.PaletteChange: + self.__update_palette() + super().changeEvent(event) + def event(self, event): # type: (QEvent) -> bool rval = super().event(event) @@ -951,7 +956,7 @@ def __update_selection_geometry(self): self.__selection_manager.update_selection_rects() rects = self.__selection_manager.selection_rects palette = self.palette() - pen = QPen(palette.color(QPalette.Foreground), 2) + pen = QPen(palette.color(QPalette.WindowText), 2) pen.setCosmetic(True) brushcolor = QColor(palette.color(QPalette.Highlight)) brushcolor.setAlpha(50) @@ -991,6 +996,12 @@ def __select_by_cluster(self, item, dendrogramindex): node.value.first, node.value.last - 1, hm, clear=clear, remove=remove, append=append) + def __update_palette(self): + for item in layout_items_recursive(self.layout()): + if isinstance(item, SimpleLayoutItem) \ + and isinstance(item.item, QGraphicsSimpleTextItem): + item.item.setBrush(self.palette().text()) + def heatmapAtPos(self, pos: QPointF) -> Optional['GraphicsHeatmapWidget']: for hw in chain.from_iterable(self.heatmap_widget_grid): if hw.contains(hw.mapFromItem(self, pos)): @@ -1044,7 +1055,7 @@ def selectRows(self, selection: Sequence[int]): indices = np.hstack([r.normalized_indices for r in self.parts.rows]) else: indices = [] - condition = np.in1d(indices, selection) + condition = np.isin(indices, selection) visual_indices = np.flatnonzero(condition) self.__selection_manager.select_rows(visual_indices.tolist()) @@ -1233,7 +1244,7 @@ def remove_item(item: QGraphicsItem) -> None: item.setParentItem(None) -class _GradientLegendAxisItem(pg.AxisItem): +class _GradientLegendAxisItem(AxisItem): def boundingRect(self): br = super().boundingRect() if self.orientation in ["top", "bottom"]: @@ -1334,12 +1345,6 @@ def __update(self): self.updateGeometry() - def changeEvent(self, event: QEvent) -> None: - if event.type() == QEvent.PaletteChange: - pen = QPen(self.palette().color(QPalette.Text)) - self.__axis.setPen(pen) - super().changeEvent(event) - class CategoricalColorLegend(QGraphicsWidget): def __init__( @@ -1430,20 +1435,31 @@ def legend_item_pair(color: QColor, size: float, text: str): def changeEvent(self, event: QEvent) -> None: if event.type() == QEvent.FontChange: self._updateFont(self.font()) + elif event.type() == QEvent.PaletteChange: + self._updatePalette() super().changeEvent(event) def _updateFont(self, font): w = QFontMetrics(font).horizontalAdvance("X") - for item in filter( - lambda item: isinstance(item, SimpleLayoutItem), - layout_items_recursive(self.__layout) - ): + for item in self.__layoutItems(): if isinstance(item.item, QGraphicsSimpleTextItem): item.item.setFont(font) elif isinstance(item.item, QGraphicsRectItem): item.item.setRect(QRectF(0, 0, w, w)) item.updateGeometry() + def _updatePalette(self): + palette = self.palette() + for item in self.__layoutItems(): + if isinstance(item.item, QGraphicsSimpleTextItem): + item.item.setBrush(palette.brush(QPalette.Text)) + + def __layoutItems(self): + return filter( + lambda item: isinstance(item, SimpleLayoutItem), + layout_items_recursive(self.__layout) + ) + def layout_items(layout: QGraphicsLayout) -> Iterable[QGraphicsLayoutItem]: for item in map(layout.itemAt, range(layout.count())): diff --git a/Orange/widgets/visualize/utils/lac.py b/Orange/widgets/visualize/utils/lac.py index 55e868b7f57..6a01716fd10 100644 --- a/Orange/widgets/visualize/utils/lac.py +++ b/Orange/widgets/visualize/utils/lac.py @@ -91,7 +91,7 @@ def lac(conts, k, nsteps=30, window_size=1): print("Done") w = [np.empty((k, len(c[0]),)) for c in conts] - active = np.ones(k, dtype=np.bool) + active = np.ones(k, dtype=bool) for i in range(1, nsteps + 1): for l, (c, cw) in enumerate(conts): diff --git a/Orange/widgets/visualize/utils/owlegend.py b/Orange/widgets/visualize/utils/owlegend.py index 0e27c2e410b..26bd00b6f3b 100644 --- a/Orange/widgets/visualize/utils/owlegend.py +++ b/Orange/widgets/visualize/utils/owlegend.py @@ -99,8 +99,8 @@ def __calculate_actual_offset(self, offset): actual offset from the top left corner of the item so positioning can be done correctly.""" off_x, off_y = offset.x(), offset.y() - width = self.boundingRect().width() - height = self.boundingRect().height() + width = int(self.boundingRect().width()) + height = int(self.boundingRect().height()) if self.__corner_str == self.TOP_LEFT: return QPoint(-off_x, -off_y) diff --git a/Orange/widgets/visualize/utils/plotutils.py b/Orange/widgets/visualize/utils/plotutils.py index ef79028c7f6..076ae93282c 100644 --- a/Orange/widgets/visualize/utils/plotutils.py +++ b/Orange/widgets/visualize/utils/plotutils.py @@ -1,4 +1,6 @@ import itertools +from math import log10, floor, ceil +from typing import Union, Optional, Callable import numpy as np @@ -6,10 +8,11 @@ QRectF, QLineF, QObject, QEvent, Qt, pyqtSignal as Signal ) from AnyQt.QtGui import QTransform, QFontMetrics, QStaticText, QBrush, QPen, \ - QFont + QFont, QPalette from AnyQt.QtWidgets import ( QGraphicsLineItem, QGraphicsSceneMouseEvent, QPinchGesture, - QGraphicsItemGroup, QWidget) + QGraphicsItemGroup, QWidget, QGraphicsWidget +) import pyqtgraph as pg import pyqtgraph.functions as fn @@ -32,11 +35,12 @@ def get_xy(self): return point.x(), point.y() -class AnchorItem(pg.GraphicsObject): +class AnchorItem(pg.GraphicsWidget): def __init__(self, parent=None, line=QLineF(), text="", **kwargs): super().__init__(parent, **kwargs) self._text = text - self.setFlag(pg.GraphicsObject.ItemHasNoContents) + self.setFlag(QGraphicsWidget.ItemSendsScenePositionChanges) + self.setFlag(QGraphicsWidget.ItemHasNoContents) self._spine = QGraphicsLineItem(line, self) angle = line.angle() @@ -49,10 +53,13 @@ def __init__(self, parent=None, line=QLineF(), text="", **kwargs): self._label = TextItem(text=text, color=(10, 10, 10)) self._label.setParentItem(self) self._label.setPos(*self.get_xy()) + self._label.setColor(self.palette().color(QPalette.Text)) if parent is not None: self.setParentItem(parent) + self.__updateLayout() + def get_xy(self): point = self._spine.line().p2() return point.x(), point.y() @@ -117,11 +124,28 @@ def __updateLayout(self): self._label.setPos(label_pos) self._label.setAnchor(pg.Point(*anchor)) - self._label.setRotation(-angle if left_quad else 180 - angle) + self._label.setAngle(angle if left_quad else 180 + angle) self._arrow.setPos(self._spine.line().p2()) self._arrow.setRotation(180 - angle) + def changeEvent(self, event): + if event.type() == QEvent.PaletteChange: + self._label.setColor(self.palette().color(QPalette.Text)) + super().changeEvent(event) + + def itemChange(self, change, value): + if change in ( + QGraphicsWidget.ItemParentHasChanged, + QGraphicsWidget.ItemSceneHasChanged, + # ItemScenePositionHasChanged seems to trigger for any scene + # transform change (even if the pos has not actually changed). + # Das ist gut. + QGraphicsWidget.ItemScenePositionHasChanged, + ): + self.__updateLayout() + return super().itemChange(change, value) + class HelpEventDelegate(QObject): def __init__(self, delegate, parent=None): @@ -151,6 +175,7 @@ def __init__(self, graph, enable_menu=False): self.init_history() pg.ViewBox.__init__(self, enableMenu=enable_menu) self.graph = graph + self.dragged_points = None self.setMouseMode(self.PanMode) self.grabGesture(Qt.PinchGesture) @@ -211,13 +236,15 @@ def select(): self.safe_update_scale_box(ev.buttonDownPos(), ev.pos()) if ev.isFinish(): self._updateDragtipShown(False) - self.graph.unsuspend_jittering() + if hasattr(self.graph, "unsuspend_jittering"): + self.graph.unsuspend_jittering() self.rbScaleBox.hide() value_rect = get_mapped_rect() self.graph.select_by_rectangle(value_rect) else: self._updateDragtipShown(True) - self.graph.suspend_jittering() + if hasattr(self.graph, "suspend_jittering"): + self.graph.suspend_jittering() self.safe_update_scale_box(ev.buttonDownPos(), ev.pos()) def zoom(): @@ -230,14 +257,36 @@ def zoom(): self.axHistoryPointer += 1 self.axHistory = self.axHistory[:self.axHistoryPointer] + [ax] - if self.graph.state == SELECT and axis is None: + def drag(): + if ev.isFinish(): + if self.dragged_points: + self.graph.finish_dragging() + self.dragged_points = None + self.graph.unsuspend_jittering() + else: + ev.accept() + p0 = self.mapToView(ev.buttonDownPos()) + p1 = self.mapToView(ev.pos()) + dist = p1 - p0 + self.graph.move_dragged_points(self.dragged_points, dist) + + if ev.isStart() and ev.button() & (Qt.LeftButton | Qt.MiddleButton) \ + and hasattr(self.graph, "get_dragged_points"): + view_pos = self.mapSceneToView(ev.pos()) + self.dragged_points = self.graph.get_dragged_points(view_pos) + if self.dragged_points is not None: + self.graph.suspend_jittering() + + if self.dragged_points: + drag() + elif self.graph.state == SELECT and axis is None: select() elif self.graph.state == ZOOMING or self.graph.state == PANNING: # Inherited mouseDragEvent doesn't work for large zooms because it # uses mapRectFromParent. We don't want to copy the parts of the # method that work, hence we only use our code under the following # conditions. - if ev.button() & (Qt.LeftButton | Qt.MidButton) \ + if ev.button() & (Qt.LeftButton | Qt.MiddleButton) \ and self.state['mouseMode'] == pg.ViewBox.RectMode \ and ev.isFinish(): zoom() @@ -258,7 +307,10 @@ def tag_history(self): lastview = self.axHistory[self.axHistoryPointer] inters = currentview & lastview united = currentview.united(lastview) - if inters.width()*inters.height()/(united.width()*united.height()) > 0.95: + # multiplication instead of division to avoid occasional + # division by zero in tests on github + if inters.width() * inters.height() \ + > 0.95 * united.width() * united.height(): return self.axHistoryPointer += 1 self.axHistory = self.axHistory[:self.axHistoryPointer] + \ @@ -420,17 +472,74 @@ class ElidedLabelsAxis(pg.AxisItem): def generateDrawSpecs(self, p): axis_spec, tick_specs, text_specs = super().generateDrawSpecs(p) bounds = self.mapRectFromParent(self.geometry()) - max_width = 0.9 * bounds.width() / (len(text_specs) or 1) + max_width = int(0.9 * bounds.width() / (len(text_specs) or 1)) elide = QFontMetrics(QWidget().font()).elidedText text_specs = [(rect, flags, elide(text, Qt.ElideRight, max_width)) for rect, flags, text in text_specs] return axis_spec, tick_specs, text_specs +class DiscretizedScale: + """ + Compute suitable bins for continuous value from its minimal and + maximal value. + + The width of the bin is a power of 10 (including negative powers). + The minimal value is rounded up and the maximal is rounded down. If this + gives less than 3 bins, the width is divided by four; if it gives + less than 6, it is halved. + + .. attribute:: offset + The start of the first bin. + + .. attribute:: width + The width of the bins + + .. attribute:: bins + The number of bins + + .. attribute:: decimals + The number of decimals used for printing out the boundaries + """ + def __init__(self, min_v, max_v): + """ + :param min_v: Minimal value + :type min_v: float + :param max_v: Maximal value + :type max_v: float + """ + super().__init__() + dif = max_v - min_v if max_v != min_v else 1 + if np.isnan(dif): + min_v = 0 + dif = decimals = 1 + else: + decimals = -floor(log10(dif)) + resolution = 10 ** -decimals + bins = ceil(dif / resolution) + if bins < 6: + decimals += 1 + if bins < 3: + resolution /= 4 + else: + resolution /= 2 + bins = ceil(dif / resolution) + self.offset: Union[int, float] = resolution * floor(min_v // resolution) + self.bins = bins + self.decimals = max(decimals, 0) + self.width: Union[int, float] = resolution + + def get_bins(self): + # if width is a very large int, dtype of width * np.arange is object + # hence we cast it to float + return self.offset + float(self.width) * np.arange(self.bins + 1) + + class PaletteItemSample(ItemSample): """A color strip to insert into legends for discretized continuous values""" - def __init__(self, palette, scale, label_formatter=None): + def __init__(self, palette, scale, + label_formatter: Optional[Callable[[float], str]] = None): """ :param palette: palette used for showing continuous values :type palette: BinnedContinuousPalette @@ -443,7 +552,9 @@ def __init__(self, palette, scale, label_formatter=None): self.scale = scale if label_formatter is None: label_formatter = "{{:.{}f}}".format(scale.decimals).format - cuts = [label_formatter(scale.offset + i * scale.width) + # offset and width can be in, but label_formatter expects float + # (because it can be ContinuousVariable.str_val), hence cast to float + cuts = [label_formatter(float(scale.offset + i * scale.width)) for i in range(scale.bins + 1)] self.labels = [QStaticText("{} - {}".format(fr, to)) for fr, to in zip(cuts, cuts[1:])] @@ -474,12 +585,13 @@ def paint(self, p, *args): p.translate(5, 5) p.setFont(self.font) colors = self.palette.qcolors + foreground = super().palette().color(QPalette.Text) h = self.bin_height for i, color, label in zip(itertools.count(), colors, self.labels): p.setPen(Qt.NoPen) p.setBrush(QBrush(color)) p.drawRect(0, i * h, h, h) - p.setPen(QPen(Qt.black)) + p.setPen(QPen(foreground)) p.drawStaticText(h + 5, i * h + 1, label) @@ -497,7 +609,57 @@ def paint(self, p, *args): drawSymbol(p, self.__symbol, self.__size, self.__pen, self.__brush) -class AxisItem(pg.AxisItem): +class StyledAxisItem(pg.AxisItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.label.setDefaultTextColor(self.palette().color(QPalette.Text)) + + def changeEvent(self, event: QEvent) -> None: + if event.type() == QEvent.FontChange: + self.picture = None + self.update() + elif event.type() == QEvent.PaletteChange: + self.picture = None + self.label.setDefaultTextColor(self.palette().color(QPalette.Text)) + self.update() + super().changeEvent(event) + + __hasTextPen = False + + def setTextPen(self, *args, **kwargs): + self.__hasTextPen = args or kwargs + super().setTextPen(*args, **kwargs) + if not self.__hasTextPen: + self.__clear_labelStyle_color() + + def textPen(self): + if self.__hasTextPen: + return super().textPen() + else: # bypass pg.AxisItem + return QPen(self.palette().brush(QPalette.Text), 1) + + __hasPen = False + + def setPen(self, *args, **kwargs): + self.__hasPen = bool(args or kwargs) + super().setPen(*args, **kwargs) + if not self.__hasPen: + self.__clear_labelStyle_color() + + def pen(self): + if self.__hasPen: + return super().pen() + else: # bypass pg.AxisItem + return QPen(self.palette().brush(QPalette.Text), 1) + + def __clear_labelStyle_color(self): + try: + self.labelStyle.pop("color") + except AttributeError: + pass + + +class AxisItem(StyledAxisItem): def __init__(self, orientation, rotate_ticks=False, **kwargs): super().__init__(orientation, **kwargs) self.style["rotateTicks"] = rotate_ticks @@ -543,3 +705,85 @@ def drawPicture(self, p, axisSpec, tickSpecs, textSpecs): self._updateMaxTextSize(max_text_size + offset) else: super().drawPicture(p, axisSpec, tickSpecs, textSpecs) + + +class PlotWidget(pg.PlotWidget): + """ + A pyqtgraph.PlotWidget with better QPalette integration. + + A default constructed plot will respect and adapt to the current palette + """ + def __init__(self, *args, background=None, **kwargs): + axisItems = kwargs.pop("axisItems", None) + if axisItems is None: # Use palette aware AxisItems + axisItems = {"left": AxisItem("left"), "bottom": AxisItem("bottom")} + super().__init__(*args, background=background, axisItems=axisItems, + **kwargs) + if background is None: + # Revert the pg.PlotWidget's modifications, use default + # for QGraphicsView background role + self.setBackgroundRole(QPalette.Base) + # Reset changes to the palette (undo changes from pg.GraphicsView) + self.setPalette(QPalette()) + self.__updateScenePalette() + + def setScene(self, scene): + super().setScene(scene) + self.__updateScenePalette() + + def changeEvent(self, event): + if event.type() == QEvent.PaletteChange: + self.__updateScenePalette() + self.resetCachedContent() + super().changeEvent(event) + + def __updateScenePalette(self): + scene = self.scene() + if scene is not None: + scene.setPalette(self.palette()) + + +class GraphicsView(pg.GraphicsView): + """ + A pyqtgraph.GraphicsView with better QPalette integration. + + A default constructed plot will respect and adapt to the current palette + """ + def __init__(self, *args, background=None, **kwargs): + super().__init__(*args, background=background, **kwargs) + if background is None: + # Revert the pg.PlotWidget's modifications, use default + # for QGraphicsView + self.setBackgroundRole(QPalette.Base) + # Reset changes to the palette (undo changes from pg.GraphicsView) + self.setPalette(QPalette()) + self.__updateScenePalette() + + def setScene(self, scene): + super().setScene(scene) + self.__updateScenePalette() + + def changeEvent(self, event): + if event.type() == QEvent.PaletteChange: + self.__updateScenePalette() + self.resetCachedContent() + + super().changeEvent(event) + + def __updateScenePalette(self): + scene = self.scene() + if scene is not None: + scene.setPalette(self.palette()) + + +class PlotItem(pg.PlotItem): + """ + A pyqtgraph.PlotItem with better QPalette integration. + + A default constructed plot will respect and adapt to the current palette + """ + def __init__(self, *args, **kwargs): + axisItems = kwargs.pop("axisItems", None) + if axisItems is None: + axisItems = {"left": AxisItem("left"), "bottom": AxisItem("bottom")} + super().__init__(*args, axisItems=axisItems, **kwargs) diff --git a/Orange/widgets/visualize/utils/tests/test_customizableplot.py b/Orange/widgets/visualize/utils/tests/test_customizableplot.py index 5863ced5fa3..c3566e26482 100644 --- a/Orange/widgets/visualize/utils/tests/test_customizableplot.py +++ b/Orange/widgets/visualize/utils/tests/test_customizableplot.py @@ -12,13 +12,13 @@ def test_available_font_families(self): font.return_value.family = Mock(return_value="mock regular") db.return_value = Mock() - db.return_value.families = Mock( + db.families = Mock( return_value=["a", ".d", "e", ".b", "mock regular", "c"]) self.assertEqual(customizableplot.available_font_families(), ["mock regular", "", "a", ".b", "c", ".d", "e"]) db.return_value = Mock() - db.return_value.families = Mock( + db.families = Mock( return_value=["a", ".d", "e", ".b", "mock regular", "mock bold", "mock italic", "c", "mock semi"]) self.assertEqual(customizableplot.available_font_families(), diff --git a/Orange/widgets/visualize/utils/tests/test_error_bars_dialog.py b/Orange/widgets/visualize/utils/tests/test_error_bars_dialog.py new file mode 100644 index 00000000000..a092331c17d --- /dev/null +++ b/Orange/widgets/visualize/utils/tests/test_error_bars_dialog.py @@ -0,0 +1,86 @@ +# pylint: disable=protected-access +import unittest +from unittest.mock import Mock + +from orangewidget.tests.base import GuiTest + +from Orange.data import Table +from Orange.widgets.visualize.utils.error_bars_dialog import ErrorBarsDialog + + +class TestErrorBarsDialog(GuiTest): + def setUp(self) -> None: + self._dlg = ErrorBarsDialog(None) + + def test_init(self): + form = self._dlg.layout().itemAt(0) + + self.assertEqual(form.itemAt(0).widget().text(), "Upper:") + self.assertEqual(form.itemAt(1).widget().currentText(), "(None)") + + self.assertEqual(form.itemAt(2).widget().text(), "Lower:") + self.assertEqual(form.itemAt(3).widget().currentText(), "(None)") + + self.assertEqual(form.itemAt(4).widget().text(), + "Difference from plotted value") + self.assertTrue(form.itemAt(4).widget().isChecked()) + + self.assertEqual(form.itemAt(5).widget().text(), + "Absolute position on the plot") + self.assertFalse(form.itemAt(5).widget().isChecked()) + + def test_get_data(self): + upper_var, lower_var, is_abs = self._dlg.get_data() + self.assertIsNone(upper_var) + self.assertIsNone(lower_var) + self.assertFalse(is_abs) + + def test_set_data(self): + data = Table("iris") + + self._dlg._set_data(data.domain, data.domain.attributes[2], + data.domain.attributes[1], True) + upper_var, lower_var, is_abs = self._dlg.get_data() + self.assertIs(upper_var, data.domain.attributes[2]) + self.assertIs(lower_var, data.domain.attributes[1]) + self.assertTrue(is_abs) + + self._dlg._set_data(data.domain, None, None, True) + upper_var, lower_var, is_abs = self._dlg.get_data() + self.assertIsNone(upper_var) + self.assertIsNone(lower_var) + self.assertTrue(is_abs) + + def test_set_data_none(self): + self._dlg._set_data(None, None, None, False) + upper_var, lower_var, is_abs = self._dlg.get_data() + self.assertIsNone(upper_var) + self.assertIsNone(lower_var) + self.assertFalse(is_abs) + + def test_set_data_err(self): + data = Table("iris") + self.assertRaises(ValueError, self._dlg._set_data, data.domain, + data.domain.class_var, data.domain.class_var, False) + + def test_changed(self): + data = Table("iris") + mock = Mock() + self._dlg.changed.connect(mock) + self._dlg._set_data(data.domain, data.domain.attributes[2], + data.domain.attributes[1], True) + + self._dlg._ErrorBarsDialog__upper_combo.setCurrentIndex(1) + mock.assert_called_once() + + mock.reset_mock() + self._dlg._ErrorBarsDialog__lower_combo.setCurrentIndex(0) + mock.assert_called_once() + + mock.reset_mock() + self._dlg._ErrorBarsDialog__radio_buttons.buttons()[0].click() + mock.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/visualize/utils/tests/test_heatmap.py b/Orange/widgets/visualize/utils/tests/test_heatmap.py index 1aa48c6af4d..6d8e55d2233 100644 --- a/Orange/widgets/visualize/utils/tests/test_heatmap.py +++ b/Orange/widgets/visualize/utils/tests/test_heatmap.py @@ -1,7 +1,7 @@ import numpy as np from AnyQt.QtCore import Qt, QPoint -from AnyQt.QtGui import QFont +from AnyQt.QtGui import QFont, QPalette from AnyQt.QtTest import QTest, QSignalSpy from AnyQt.QtWidgets import QGraphicsScene, QGraphicsView @@ -93,6 +93,14 @@ def test_widget(self): w.headerGeometry() w.footerGeometry() + # Trigger the change events. + p = QPalette() + p.setColor(QPalette.All, QPalette.Text, Qt.red) + w.setPalette(p) + f = QFont() + f.setPointSizeF(19.5) + w.setFont(f) + def test_widget_annotations(self): w = HeatmapGridWidget() self.scene.addItem(w) diff --git a/Orange/widgets/visualize/utils/tests/test_vizrank.py b/Orange/widgets/visualize/utils/tests/test_vizrank.py new file mode 100644 index 00000000000..6b46c3a9820 --- /dev/null +++ b/Orange/widgets/visualize/utils/tests/test_vizrank.py @@ -0,0 +1,324 @@ +import unittest +from unittest.mock import patch, Mock + +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QStandardItem +from AnyQt.QtWidgets import QDialog +from AnyQt.QtTest import QSignalSpy + +from orangewidget.tests.base import GuiTest +from Orange.widgets.widget import OWWidget +from Orange.data import Table, Domain, ContinuousVariable +from Orange.widgets.visualize.utils.vizrank import ( + RunState, VizRankMixin, + VizRankDialog, VizRankDialogAttrs, VizRankDialogAttrPair, + VizRankDialogNAttrs +) + + +class MockDialog(VizRankDialog): + class Task: + interrupt = False + set_partial_result = Mock() + + def is_interruption_requested(self): + return self.interrupt + + task = Task() + + def __init__(self, parent=None): + super().__init__(parent) + + # False positive?! "Number of parameters was 4 in 'ConcurrentMixin.start' + # and is now 4 in overriding 'MockDialog.start' method". + # pylint: disable=arguments-differ + def start(self, func, *args, **kwargs): + func(*args, self.task) + + def state_generator(self): + return range(10) + + def state_count(self): + return 10 + + def compute_score(self, state): + return 10 * (state % 2) + state // 2 if state != 3 else None + + row_for_state = Mock() + + +class TestVizRankDialog(GuiTest): + + @patch.object(VizRankDialog, "start") + @patch.object(VizRankDialog, "prepare_run", + new=lambda self: setattr(self.run_state, + "state", RunState.Ready)) + def test_init_and_button(self, run_vizrank): + dialog = VizRankDialog(None) + self.assertEqual(dialog.run_state.state, RunState.Initialized) + self.assertEqual(dialog.button.text(), dialog.button_labels[RunState.Initialized]) + dialog.button.click() + run_vizrank.assert_called_once() + self.assertEqual(dialog.run_state.state, RunState.Running) + self.assertEqual(dialog.button.text(), dialog.button_labels[RunState.Running]) + + dialog.button.click() + run_vizrank.assert_called_once() + self.assertEqual(dialog.run_state.state, RunState.Paused) + self.assertEqual(dialog.button.text(), dialog.button_labels[RunState.Paused]) + + dialog.button.click() + self.assertEqual(run_vizrank.call_count, 2) + self.assertEqual(dialog.run_state.state, RunState.Running) + self.assertEqual(dialog.button.text(), dialog.button_labels[RunState.Running]) + + def test_running(self): + dialog = MockDialog() + dialog.task.interrupt = False + dialog.start_computation() + result = dialog.task.set_partial_result.call_args[0][0] + self.assertEqual(result.scores, [0, 1, 2, 3, 4, 10, 12, 13, 14]) + self.assertEqual(result.completed_states, 10) + + dialog.rank_model.appendRow(QStandardItem("foo")) + dialog.rank_model.appendRow(QStandardItem("bar")) + dialog.on_done(result) + self.assertEqual(dialog.run_state.state, RunState.Done) + self.assertEqual(dialog.button.text(), dialog.button_labels[RunState.Done]) + self.assertFalse(dialog.button.isEnabled()) + selection = dialog.rank_table.selectedIndexes() + self.assertEqual(len(selection), 1) + self.assertEqual(selection[0].row(), 0) + + def test_interruption(self): + dialog = MockDialog() + dialog.task.interrupt = True + dialog.start_computation() + result = dialog.task.set_partial_result.call_args[0][0] + self.assertEqual(result.scores, [0]) + self.assertEqual(result.completed_states, 1) + + +class TestVizRankMixin(GuiTest): + def setUp(self): + self.mock_dialog = Mock() + self.mock_dialog.__name__ = "foo" + + class Widget(OWWidget, VizRankMixin(self.mock_dialog)): + pass + + self.widget = Widget() + + def test_button(self): + widget, dialog = self.widget, self.mock_dialog + + # False positives, pylint: disable=attribute-defined-outside-init) + widget.start_vizrank = Mock() + widget.raise_vizrank = Mock() + + button = widget.vizrank_button("Let's Vizrank") + self.assertEqual(button.text(), "Let's Vizrank") + self.assertFalse(button.isEnabled()) + + widget.disable_vizrank("Too lazy to rank to-day.") + self.assertEqual(button.text(), "Let's Vizrank") + self.assertEqual(button.toolTip(), "Too lazy to rank to-day.") + self.assertFalse(button.isEnabled()) + dialog.assert_not_called() + + widget.init_vizrank() + dialog.assert_called_once() + dialog.reset_mock() + self.assertEqual(button.text(), "Let's Vizrank") + self.assertEqual(button.toolTip(), "") + self.assertTrue(button.isEnabled()) + + widget.disable_vizrank("Too lazy to rank to-day.") + self.assertEqual(button.text(), "Let's Vizrank") + self.assertEqual(button.toolTip(), "Too lazy to rank to-day.") + self.assertFalse(button.isEnabled()) + dialog.assert_not_called() + + widget.init_vizrank() + dialog.assert_called_once() # new data, new dialog! + dialog.reset_mock() + + button.click() + widget.start_vizrank.assert_called_once() + widget.raise_vizrank.assert_called_once() + + def test_no_button(self): + widget, dialog = self.widget, self.mock_dialog + + widget.disable_vizrank("Too lazy to rank to-day.") + dialog.assert_not_called() + + widget.init_vizrank() + dialog.assert_called_once() + dialog.reset_mock() + widget.disable_vizrank("Too lazy to rank to-day.") + + widget.disable_vizrank("Too lazy to rank to-day.") + dialog.assert_not_called() + + widget.init_vizrank() + dialog.assert_called_once() + dialog.reset_mock() + widget.disable_vizrank("Too lazy to rank to-day.") + + def test_init_vizrank(self): + widget, dialog = self.widget, self.mock_dialog + a, b = Mock(), Mock() + widget.init_vizrank(a, b) + dialog.assert_called_with(widget, a, b) + + +class TestVizRankDialogWithData(GuiTest): + def setUp(self): + self.attrs = tuple(ContinuousVariable(n) for n in "abcdef") + self.class_var = ContinuousVariable("y") + self.variables = (*self.attrs, self.class_var) + self.metas = tuple(ContinuousVariable(n) for n in "mn") + self.data = Table.from_list( + Domain(self.attrs, self.class_var, self.metas), + [[0] * 9]) + + +class TestVizRankDialogAttrs(TestVizRankDialogWithData): + def test_init(self): + dialog = VizRankDialogAttrs(None, self.data) + self.assertIs(dialog.data, self.data) + self.assertEqual(dialog.attrs, self.variables) + self.assertIsNone(dialog.attr_color) + + dialog = VizRankDialogAttrs(None, self.data, self.attrs[1:]) + self.assertIs(dialog.data, self.data) + self.assertEqual(dialog.attrs, self.attrs[1:]) + self.assertIsNone(dialog.attr_color) + + dialog = VizRankDialogAttrs(None, self.data, self.attrs[1:], self.attrs[3]) + self.assertIs(dialog.data, self.data) + self.assertEqual(dialog.attrs, self.attrs[1:]) + self.assertIs(dialog.attr_color, self.attrs[3]) + + def test_attr_order(self): + # pylint: disable=abstract-method + dialog = VizRankDialogAttrs(None, self.data) + self.assertEqual(dialog.attr_order, self.variables) + + class OrderedAttr(VizRankDialogAttrs): + call_count = 0 + + def score_attributes(self): + self.call_count += 1 + return self.attrs[::-1] + + dialog = OrderedAttr(None, self.data) + self.assertEqual(dialog.attr_order, self.variables[::-1]) + self.assertEqual(dialog.attr_order, self.variables[::-1]) + self.assertEqual(dialog.call_count, 1) + + def test_row_for_state(self): + # pylint: disable=protected-access + dialog = VizRankDialogAttrs(None, self.data) + item = dialog.row_for_state(0, [3, 1])[0] + self.assertEqual(item.data(Qt.DisplayRole), "d, b") + self.assertEqual(item.data(dialog._AttrRole), [self.attrs[3], self.attrs[1]]) + + dialog.sort_names_in_row = True + item = dialog.row_for_state(0, [3, 1])[0] + self.assertEqual(item.data(Qt.DisplayRole), "b, d") + self.assertEqual(item.data(dialog._AttrRole), [self.attrs[1], self.attrs[3]]) + + def test_autoselect(self): + # False positive, pylint: disable=no-value-for-parameter + class Widget(OWWidget, VizRankMixin(VizRankDialogAttrs)): + pass + widget = Widget() + try: + widget.init_vizrank(self.data) + dialog = widget.vizrank_dialog + for state in ([0, 1], [0, 3], [1, 3], [3, 1]): + dialog.rank_model.appendRow(dialog.row_for_state(0, state)) + + widget.vizrankAutoSelect.emit([self.attrs[1], self.attrs[3]]) + selection = dialog.rank_table.selectedIndexes() + self.assertEqual(len(selection), 1) + self.assertEqual(selection[0].row(), 2) + + widget.vizrankAutoSelect.emit([self.attrs[3], self.attrs[1]]) + selection = dialog.rank_table.selectedIndexes() + self.assertEqual(len(selection), 1) + self.assertEqual(selection[0].row(), 3) + + widget.vizrankAutoSelect.emit([self.attrs[3], self.attrs[0]]) + selection = dialog.rank_table.selectedIndexes() + self.assertEqual(len(selection), 0) + finally: + widget.onDeleteWidget() + + +class TestVizRankDialogAttrPair(TestVizRankDialogWithData): + def test_count_and_generator(self): + dialog = VizRankDialogAttrPair(None, self.data, self.attrs[:5]) + self.assertEqual(dialog.state_count(), 5 * 4 / 2) + self.assertEqual( + list(dialog.state_generator()), + [(0, 1), (0, 2), (1, 2), (0, 3), (1, 3), (2, 3), + (0, 4), (1, 4), (2, 4), (3, 4)]) + + +class TestVizRankDialogNAttrs(TestVizRankDialogWithData): + def test_spin_interaction(self): + dialog = VizRankDialogNAttrs(None, + self.data, self.attrs[:5], None, 4) + spy = QSignalSpy(dialog.runStateChanged) + + with patch.object( + VizRankDialog, "pause_computation", + side_effect=lambda: dialog.set_run_state(RunState.Paused)): + with patch.object( + VizRankDialog, "start_computation", + side_effect=lambda: dialog.set_run_state(RunState.Running)): + spin = dialog.n_attrs_spin + self.assertEqual(spin.value(), 4) + self.assertEqual(spin.maximum(), 5) + + dialog.start_computation() + self.assertEqual(dialog.run_state.state, RunState.Running) + self.assertEqual(spy[-1][1]["n_attrs"], 4) + + spin.setValue(3) + # Ranking must be paused + self.assertEqual(dialog.run_state.state, RunState.Paused) + # Label should be changed to "restart with ..." + self.assertNotEqual(dialog.button.text(), + dialog.button_labels[RunState.Paused]) + + spin.setValue(4) + self.assertEqual(dialog.run_state.state, RunState.Paused) + # Label should be reset to "Continue" + self.assertEqual(dialog.button.text(), + dialog.button_labels[RunState.Paused]) + + # Remove the side-effect so that we see that start_computation + # resets the state to Initialized before calling super + with patch.object(VizRankDialog, "start_computation"): + dialog.start_computation() + self.assertEqual(spy[-1][1]["n_attrs"], 4) + # Here, the state must not be reset to Initialized + self.assertEqual(dialog.run_state.state, RunState.Paused) + # But now manually set it to appropriate state + dialog.set_run_state(RunState.Running) + + spin.setValue(3) + self.assertEqual(dialog.run_state.state, RunState.Paused) + self.assertNotEqual(dialog.button.text(), dialog.button_labels[RunState.Paused]) + + dialog.start_computation() + self.assertEqual(dialog.run_state.state, RunState.Initialized) + self.assertEqual(spy[-1][1]["n_attrs"], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/visualize/utils/tree.py b/Orange/widgets/visualize/utils/tree.py new file mode 100644 index 00000000000..66aae6ed88a --- /dev/null +++ b/Orange/widgets/visualize/utils/tree.py @@ -0,0 +1,11 @@ +import sys +import warnings + +from Orange.utils import tree + +warnings.warn( + f"{__name__} module was moved. Use {tree.__name__} instead", + DeprecationWarning, + stacklevel=2 +) +sys.modules[__name__] = tree \ No newline at end of file diff --git a/Orange/widgets/visualize/utils/vizrank.py b/Orange/widgets/visualize/utils/vizrank.py new file mode 100644 index 00000000000..917639ca557 --- /dev/null +++ b/Orange/widgets/visualize/utils/vizrank.py @@ -0,0 +1,797 @@ +from bisect import bisect_left +from queue import Queue, Empty +from types import SimpleNamespace as namespace +from typing import Optional, Iterable, List, Callable, Iterator, Any, Type +from threading import Timer + +from AnyQt.QtCore import ( + Qt, QSize, QSortFilterProxyModel, QObject, pyqtSignal as Signal) +from AnyQt.QtGui import ( + QStandardItemModel, QStandardItem, QShowEvent, QCloseEvent, QHideEvent) +from AnyQt.QtWidgets import ( + QTableView, QDialog, QVBoxLayout, QLineEdit, QPushButton) + +from orangewidget.widget import OWBaseWidget +from Orange.widgets import gui +from Orange.widgets.gui import HorizontalGridDelegate, TableBarItem +from Orange.widgets.utils.concurrent import ConcurrentMixin, TaskState +from Orange.widgets.utils.progressbar import ProgressBarMixin + + +class Result(namespace): + queue = None # type: Queue[QueuedScore, ...] + scores = None # type: Optional[List[float, ...]] + + +class QueuedScore(namespace): + position = None # type: int + score = None # type: float + state = None # type: Iterable + + +class RunState(namespace): + Invalid = 0 # Used only as default, changed to Initialized when instantiated + Initialized = 1 # Has data; prepare_run must be called before starting computation + Ready = 2 # Has data, iterator is initialized, but never used + Running = 3 # Scoring thread is running + Paused = 4 # Scoring thread is inactive, but can continue (without prepare_run) + Hidden = 5 # Dialog is hidden and ranking paused; will resume on reopen + Done = 6 # Scoring is done + + # The difference between Paused and Hidden is that for the former the + # ranking is not automatically restarted when the dialog is reopened. + + state: int = Invalid + iterator: Iterable = None + completed: int = 0 + + def can_run(self): + return self.state in (self.Ready, self.Paused, self.Hidden) + + +class VizRankDialog(QDialog, ProgressBarMixin, ConcurrentMixin): + """ + Base class for VizRank dialogs, providing a GUI with a table and a button, + and the skeleton for managing the evaluation of visualizations. + + A new instance of this class is used for new data or any change made in + the widget (e.g. color attribute in the scatter plot). + + The attribute run_state (and the related signal runStateChanged) can be + used for tracking the work flow of the widget. run_state.state can be + + - RunState.Initialized (after __init__): the widget has the data, but + the state generator is not constructed, total statecount is not known + yet. The dialog may reenter this state after, for instance, changing + the number of attributes per combination in radviz. + - RunState.Ready (after prepare_run): the iterator is ready, state count + is known: the ranking can commence. + - RunState.Running (after start_computation): ranking is in progress. + This state is entered by start_computation. If start_computation is + called when the state is Initialized, start computation will call + prepare_run. + - RunState.Paused (after pause_computation): ranking is paused. This may + continue (with start_computation) or be reset when parameters of VizRank + are changed (by calling `set_run_state(RunState.Initialized)` and then + start_computation, which will first call prepare_run to go to Ready). + - RunState.Hidden (after closing the dialog): ranking is paused. This is + similar to `RunState.Paused`, except that reopening dialog resumes it. + - RunState.Done: ranking is done. The only allowed next state is + Initialized (with the procedure described in the previous point). + + State should be changed through set_run_state, which also changes the button + label and disables the button when Done. + + Derived classes must provide methods + + - `__init__(parent, *args, **kwargs)`: stores the data to run vizrank, + - `state_generator()`: generates combinations (e.g. of attributes), + - `compute_score(state)`: computes the score for the combination, + - `row_for_state(state)`: returns a list of items inserted into the + table for the given state. + + and, probably, + + - emit `selectionChanged` when the user changes the selection in the table, + + and, optionally, + + - `state_count`: return the number of combinations (used for progress bar) + - `bar_length`: return the length of the bar corresponding to the score, + - `auto_select`: selects the row corresponding to the given data. + + Derived classes are also responsible for connecting to + rank_table.selectionModel().selectionChanged and emitting something useful + via selectionChanged + + The constructor shouldn't do anything but store the necessary data (as is) + because instances of this object are created on any new data. The actual + work can only start in `prepare_run`. `prepare_run` usually won't be + overriden, so the first computation in derived classes will usually happen + in `compute_score` or, in classes derived from`VizRankAttributes`, in + `score_attributes`. + + Args: + parent (Orange.widget.OWWidget): widget to which the dialog belongs + + Attributes: + captionTitle (str): the caption for the dialog. This can be a class + attribute. `captionTitle` is used by the `ProgressBarMixin`. + show_bars (True): if True (default), table with scores contains bars + of length -score. For a different length, override `bar_lenght`. + To hide bars (e.g. because scores can't be normalized) set this to + `False`. + + Signal: + selectionChanged(object): emitted when selection in the table is + changed. The data type depends on the derived class (e.g. a list + of attributes) + runStateChanged(int, dict): emitted when the run state changes + (e.g. start, pause...). Derived classes can fill the dictionary + with additional data, e.g. the state of the user interface + """ + captionTitle = "Score Plots" + show_bars = True + + selectionChanged = Signal(object) + runStateChanged = Signal(int, dict) + + button_labels = {RunState.Initialized: "Start", + RunState.Ready: "Start", + RunState.Running: "Pause", + RunState.Paused: "Continue", + RunState.Hidden: "Continue", + RunState.Done: "Finished", + RunState.Invalid: "Start"} + + def __init__(self, parent): + QDialog.__init__(self, parent, windowTitle=self.captionTitle) + ConcurrentMixin.__init__(self) + ProgressBarMixin.__init__(self) + self.setLayout(QVBoxLayout()) + + self.scores = [] + self.add_to_model = Queue() + self.run_state = RunState() + self.total_states = 1 + + self.filter = QLineEdit() + self.filter.setPlaceholderText("Filter ...") + self.layout().addWidget(self.filter) + + self.rank_model = QStandardItemModel(self) + self.model_proxy = QSortFilterProxyModel( + self, filterCaseSensitivity=Qt.CaseInsensitive) + self.model_proxy.setSourceModel(self.rank_model) + self.filter.textChanged.connect(self.model_proxy.setFilterFixedString) + + self.rank_table = view = QTableView( + selectionBehavior=QTableView.SelectRows, + selectionMode=QTableView.SingleSelection, + showGrid=False, + editTriggers=gui.TableView.NoEditTriggers) + view.setItemDelegate(TableBarItem() if self.show_bars + else HorizontalGridDelegate()) + view.setModel(self.model_proxy) + view.horizontalHeader().setStretchLastSection(True) + view.horizontalHeader().hide() + self.layout().addWidget(view) + + self.button = gui.button(self, self, "Start", default=True) + + @self.button.pressed.connect + def on_button_pressed(): + if self.run_state.state == RunState.Running: + self.pause_computation() + else: + self.start_computation() + + self.set_run_state(RunState.Initialized) + + def prepare_run(self) -> None: + """ + Called by start_computation before running for the first time or with + new parameters within the vizrank gui, e.g. a different number of + attributes in combinations. + + Derived classes may override this method to add other preparation steps, + but shouldn't need to call it. + """ + self.progressBarInit() + self.scores = [] + self._update_model() # empty queue + self.rank_model.clear() + self.run_state.iterator = self.state_generator() + self.total_states = self.state_count() or 1 + self.set_run_state(RunState.Ready) + + def start_computation(self) -> None: + if self.run_state.state == RunState.Initialized: + self.prepare_run() + if not self.run_state.can_run(): + return + self.set_run_state(RunState.Running) + self.start( + self.run_vizrank, + self.compute_score, self.scores, + self.run_state.iterator, self.run_state.completed) + + def pause_computation(self, new_state=RunState.Paused) -> None: + if not self.run_state.state == RunState.Running: + return + self.set_run_state(new_state) + self.cancel() + self._update_model() + + def dialog_reopened(self): + if self.run_state.state != RunState.Paused: + self.start_computation() + + @staticmethod + def run_vizrank(compute_score: Callable, scores: List, + state_iterator: Iterator, completed: int, task: TaskState): + res = Result(queue=Queue(), scores=None, completed_states=completed) + scores = scores.copy() + can_set_partial_result = True + + def do_work(st: Any): + score = compute_score(st) + if score is not None: + pos = bisect_left(scores, score) + res.queue.put_nowait(QueuedScore(position=pos, score=score, + state=st)) + scores.insert(pos, score) + res.scores = scores.copy() + res.completed_states += 1 + + def reset_flag(): + nonlocal can_set_partial_result + can_set_partial_result = True + + for state in state_iterator: + do_work(state) + # Prevent simple scores from invoking 'task.set_partial_result') + # too frequently and making the widget unresponsive + if can_set_partial_result: + task.set_partial_result(res) + can_set_partial_result = False + Timer(0.01, reset_flag).start() + if task.is_interruption_requested(): + return res + task.set_partial_result(res) + return res + + def on_partial_result(self, result: Result) -> None: + try: + while True: + queued = result.queue.get_nowait() + self.add_to_model.put_nowait(queued) + except Empty: + pass + self.scores = result.scores + self._update_model() + self.run_state.completed = result.completed_states + self.progressBarSet(self._progress) + + @property + def _progress(self) -> int: + return int(round(self.run_state.completed * 100 / self.total_states)) + + def on_done(self, result: Result) -> None: + self.progressBarFinished() + self.set_run_state(RunState.Done) + self._update_model() + if not self.rank_table.selectedIndexes() \ + and self.rank_table.model().rowCount(): + self.rank_table.selectRow(0) + + def set_run_state(self, state: Any) -> None: + if state != self.run_state.state: + self.run_state.state = state + if state <= RunState.Ready: + self.run_state.completed = 0 + self.emit_run_state_changed() + if state == RunState.Paused: + self.setWindowTitle( + f"{self.captionTitle} (paused at {self._progress}%)") + self.set_button_state() + + def emit_run_state_changed(self): + self.runStateChanged.emit(self.run_state.state, {}) + + def set_button_state(self, + label: Optional[str] = None, + enabled: Optional[bool]=None) -> None: + state = self.run_state.state + self.button.setText( + label if label is not None + else self.button_labels[state]) + self.button.setEnabled( + enabled if enabled is not None + else state not in [RunState.Done, RunState.Invalid]) + + def _update_model(self) -> None: + try: + while True: + queued = self.add_to_model.get_nowait() + row_items = self.row_for_state(queued.score, queued.state) + bar_length = self.bar_length(queued.score) + if bar_length is not None: + row_items[0].setData(bar_length, + gui.TableBarItem.BarRole) + self.rank_model.insertRow(queued.position, row_items) + except Empty: + pass + + def showEvent(self, event: QShowEvent) -> None: + # pylint: disable=protected-access + self.parent()._restore_vizrank_geometry() + super().showEvent(event) + + def hideEvent(self, event: QHideEvent) -> None: + # pylint: disable=protected-access + self.pause_computation(RunState.Hidden) + self.parent()._save_vizrank_geometry() + super().hideEvent(event) + + def state_generator(self) -> Iterable: + """ + Generate all possible states (e.g. attribute combinations) for the + given data. The content of the generated states is specific to the + visualization. + """ + raise NotImplementedError + + def compute_score(self, state: Any) -> Optional[float]: + """ + Abstract method for computing the score for the given state. Smaller + scores are better. + + Args: + state: the state, e.g. the combination of attributes as generated + by :obj:`state_count`. + """ + raise NotImplementedError + + def row_for_state(self, score: float, state: Any) -> List[QStandardItem]: + """ + Return a list of items that are inserted into the table. + + Args: + score: score, computed by :obj:`compute_score` + state: the state, e.g. combination of attributes + """ + raise NotImplementedError + + def auto_select(self, arg: Any) -> None: + """ + Select the row corresponding to the give data. + """ + + def state_count(self) -> int: + """ + Return the total number of states, needed for the progress bar. + """ + return 1 + + def bar_length(self, score: float) -> float: + """ + Return the bar length (between 0 and 1) corresponding to the score. + Return `None` if the score cannot be normalized. + """ + return max(0., -score) + + +# According to PEP-8, names should follow usage, not implementation +# pylint: disable=invalid-name +def VizRankMixin(vizrank_class: Type[VizRankDialog]) -> Type[type]: + """ + A mixin that serves as an interface between the vizrank dialog and the widget. + + Widget should avoid directly access the vizrank dialog. + + This mixin takes care of constructing the vizrank dialog, raising it, + and for closing and shutting the vizrank down when necessary. Data for + vizrank is passed to the vizrank through the mixin, and the mixin forwards + the signals from vizrank dialog (e.g. when the user selects rows in vizrank) + to the widget. + + The mixin is parametrized: it must be given the VizRank class to open, + as in + + ``` + class OWMosaicDisplay(OWWidget, VizRankMixin(MosaicVizRank)): + ``` + + There should therefore be no need to subclass this class. + + Method `vizrank_button` returns a button to be placed into the widget. + + Signals: + - `vizrankSelectionChanged` is connected to VizRank's selectionChanged. + E.g. MosaicVizRank.selectionChanged is forwarded to selectionChanged, + and contains the data sent by the former. + - `virankRunStateChanged` is connected to VizRank's runStateChanged. + This can be used to retrieve the settings from the dialog at appropriate + times. + - If the widget emits a signal `vizrankAutoSelect` when + the user manually changes the variables shown in the plot (e.g. x or y + attribute in the scatter plot), the vizrank will also select this + combination in the list, if found. +""" + + # __VizRankMixin must be derived from OWBaseWidget so that it is + # properly included in MRO and calls to super(), + # e.g. super().onDeleteWidget() are properly called. + # + # Yet PyQt cannot handle diamond-shaped inheritance: signals exist, + # but can't be connected. While PyQt states that "It is not possible to + # define a new Python class that sub-classes from more than one Qt class + # (https://www.riverbankcomputing.com/static/Docs/PyQt5/gotchas.html#multiple-inheritance), + # this is not the case here, because we subclass from QDialog in the + # tip of the diamond, hence it is a single inheritance with multiple + # paths. Hence we define signals in a separate object, instantiate it, + # and forward the signals through it. + class VizrankSignalDelegator(QObject): + vizrankSelectionChanged = Signal(object) + vizrankRunStateChanged = Signal(int, dict) + vizrankAutoSelect = Signal(object) + + class __VizRankMixin(OWBaseWidget, openclass=True): # pylint: disable=invalid_name + __button: Optional[QPushButton] = None + __vizrank: Optional[VizRankDialog] = None + __vizrank_geometry: Optional[bytes] = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__signal_delegator = VizrankSignalDelegator() + + @property + def vizrankSelectionChanged(self): + return self.__signal_delegator.vizrankSelectionChanged + + @property + def vizrankAutoSelect(self): + return self.__signal_delegator.vizrankAutoSelect + + @property + def vizrankRunStateChanged(self): + return self.__signal_delegator.vizrankRunStateChanged + + @property + def vizrank_dialog(self) -> Optional[VizRankDialog]: + """ + The vizrank dialog. This should be used only in tests. + """ + return self.__vizrank + + def vizrank_button(self, button_label: Optional[str] = None) -> QPushButton: + """ + A button that opens/starts the vizrank. + + The label is optional because this function is used for + constructing the button as well as for retrieving it later. + """ + if self.__button is None: + self.__button = QPushButton() + self.__button.pressed.connect(self.start_vizrank) + self.__button.pressed.connect(self.raise_vizrank) + # It's implausible that vizrank_button will be called after + # init_vizrank, we could just disable the button. But let's + # play it safe. + self.__button.setDisabled(self.__vizrank is None) + if button_label is not None: + self.__button.setText(button_label) + return self.__button + + def init_vizrank(self, *args, **kwargs) -> None: + """ + Construct the vizrank dialog + + Any data is given to the constructor. This also enables the button + if it exists. + """ + self.shutdown_vizrank() + self.__vizrank = vizrank_class(self, *args, **kwargs) + self.__vizrank.selectionChanged.connect(self.vizrankSelectionChanged) + self.__vizrank.runStateChanged.connect(self.vizrankRunStateChanged) + self.vizrankAutoSelect.connect(self.__vizrank.auto_select) + # There may be Vizrank without a button ... perhaps. + if self.__button is not None: + self.__button.setEnabled(True) + self.__button.setToolTip("") + + def disable_vizrank(self, reason: str = "") -> None: + """ + Shut down the vizrank thread, closes it, disables the button. + + The method should be called when the widget has no data or cannot + run vizrank on it. The optional `reason` is set as tool tip. + """ + self.shutdown_vizrank() + if self.__button is not None: + self.__button.setEnabled(False) + self.__button.setToolTip(reason) + + def start_vizrank(self) -> None: + """ + Start the ranking. + + There should be no reason to call this directly, + unless the widget has no vizrank button. + """ + self.__vizrank.dialog_reopened() + + def raise_vizrank(self) -> None: + """ + Start the ranking. + + There should be no reason to call this directly, + unless the widget has no vizrank button. + """ + if self.__vizrank is None: + return + self.__vizrank.show() + self.__vizrank.activateWindow() + self.__vizrank.raise_() + + def shutdown_vizrank(self) -> None: + """ + Start the ranking. + + There should be no reason to call this directly: + the method is called + - from init_vizrank (the widget received new data), + - from disable_vizrank (the widget lost data, or data is unsuitable), + - when the widget is deleted. + """ + if self.__vizrank is None: + return + self.__vizrank.shutdown() + self.__vizrank.close() + self.__vizrank.deleteLater() + self.__vizrank = None + + # The following methods ensure that the vizrank dialog is + # closed/hidden/destroyed together with its parent widget. + def closeEvent(self, event: QCloseEvent) -> None: + if self.__vizrank: + self.__vizrank.close() + super().closeEvent(event) + + def hideEvent(self, event: QHideEvent) -> None: + if self.__vizrank: + self.__vizrank.hide() + super().hideEvent(event) + + def onDeleteWidget(self) -> None: + self.shutdown_vizrank() + super().onDeleteWidget() + + def _save_vizrank_geometry(self) -> None: + assert self.__vizrank + self.__vizrank_geometry = self.__vizrank.saveGeometry() + + def _restore_vizrank_geometry(self) -> None: + assert self.__vizrank + if self.__vizrank_geometry is not None: + self.__vizrank.restoreGeometry(self.__vizrank_geometry) + + # Give the returned class a proper name, like MosaicVizRankMixin + return type(f"{vizrank_class.__name__}Mixin", (__VizRankMixin, ), {}) + + +# This is an abstract class, pylint: disable=abstract-method +class VizRankDialogAttrs(VizRankDialog): + """ + Base class for VizRank classes that work over combinations of attributes. + + Constructor accepts + - data (Table), + - attributes (list[Variable]; if omitted, data.domain.variables is used), + - attr_color (Optional[Variable]): the "color" attribute, if applicable. + + The class assumes that `state` is a sequence of indices into a list of + attributes. On this basis, it provides + + - `row_for_state`, that constructs a list containing a single QStandardItem + with names of attributes and with `_AttrRole` data set to a list of + attributes for the given state. + - `on_selection_changed` that emits a `selectionChanged` signal with the + above list. + + Derived classes must still provide + + - `state_generator()`: generates combinations of attribute indices + - `compute_score(state)`: computes the score for the combination + + Derived classes will usually provide + - `score_attribute` that will returned a list of attributes, such as found + in self.attrs, but sorted by importance according to some heuristic. + + Attributes: + - data (Table): data used in ranking + - attrs (list[Variable]): applicable variables + - attr_color (Variable or None): the target attribute + + Class attributes: + - sort_names_in_row (bool): if set to True (default is False), + variables in the view will be sorted alphabetically. + """ + _AttrRole = next(gui.OrangeUserRole) + sort_names_in_row = False + + # Ideally, the only argument would be `data`, with attributes used for + # ranking and class_var would be the "color". This would however require + # that widgets prepare such data when initializing vizrank even though + # vizrank wouldn't necessarily be called at all. We will be able to afford + # that after we migrate Table to pandas. + def __init__(self, parent, + data: "Orange.data.Table", + attributes: Optional[List["Orange.data.Variable"]] = None, + attr_color: Optional["Orange.data.Variable"] = None): + super().__init__(parent) + self.data = data or None + self.attrs = attributes or ( + self.data and self.data.domain.variables) + self.attr_color = attr_color + self._attr_order = None + + self.rank_table.selectionModel().selectionChanged.connect( + self.on_selection_changed) + + @property + def attr_order(self) -> List["Orange.data.Variable"]: + """ + Attributes, sorted according to some heuristic. + + The property is computed by score_attributes when neceessary, and + cached. + """ + if self._attr_order is None: + self._attr_order = self.score_attributes() + return self._attr_order + + def score_attributes(self) -> None: + """ + Return a list of attributes ordered according by some heuristic. + + Default implementation returns the original list `self.attrs`. + """ + return self.attrs + + def on_selection_changed(self, selected, deselected) -> None: + """ + Emit the currently selected combination of variables. + """ + selection = selected.indexes() + if not selection: + return + attrs = selected.indexes()[0].data(self._AttrRole) + self.selectionChanged.emit(attrs) + + def row_for_state(self, score: float, state: List[int] + ) -> List[QStandardItem]: + """ + Return the QStandardItem for the given combination of attributes. + """ + attrs = [self.attr_order[s] for s in state] + if self.sort_names_in_row: + attrs.sort(key=lambda attr: attr.name.lower()) + attr_names = (a.name for a in attrs) + item = QStandardItem(', '.join(attr_names)) + item.setData(attrs, self._AttrRole) + return [item] + + # Renamed from the more general 'arg', pylint: disable=arguments-renamed + def auto_select(self, attrs: List["Orange.data.Variable"]) -> None: + """ + Find the given combination of variables and select it (if it exists) + """ + model = self.rank_model + self.rank_table.selectionModel().clear() + for row in range(model.rowCount()): + index = model.index(row, 0) + row_attrs = model.data(index, self._AttrRole) + if all(x is y for x, y in zip(row_attrs, attrs)): + self.rank_table.selectRow(row) + self.rank_table.scrollTo(index) + return + + +# This is an abstract class, pylint: disable=abstract-method +class VizRankDialogAttrPair(VizRankDialogAttrs): + """ + Base class for VizRanks with combinations of two variables. + + Provides state_generator and state_count; derived classes must provide + compute_score and, possibly, score_attributes. + """ + def __init__(self, parent, data, attributes=None, attr_color=None): + super().__init__(parent, data, attributes, attr_color) + self.resize(320, 512) + + def sizeHint(self) -> QSize: + return QSize(320, 512) + + def state_count(self) -> int: + n_attrs = len(self.attrs) + return n_attrs * (n_attrs - 1) // 2 + + def state_generator(self) -> Iterable: + return ((j, i) for i in range(len(self.attr_order)) for j in range(i)) + + +# This is an abstract class, pylint: disable=abstract-method +class VizRankDialogNAttrs(VizRankDialogAttrs): + """ + Base class for VizRanks with a spin for selecting the number of attributes. + + Constructor requires data, attributes, attr_color and, also, the initial + number of attributes in the spin box. + + - Ranking is stopped if the user interacts with the spin. + - The button label is changed to "Restart with {...} variables" if the + number selected in the spin doesn't match the number of varialbes in the + paused ranking, and reset back to "Continue" when it matches. + - start_computation is overriden to lower the state to Initialized before + calling super, if the number of selected in the spin doesn't match the + previous run. + - The dictionary passed by the signal runStateChanged contains n_attrs + with the number of attributes used in the current/last ranking. + - When closing the dialog, the spin is reset to the number of attributes + used in the last run. + """ + attrsSelected = Signal([]) + + def __init__(self, parent, + data: "Orange.data.Table", + attributes: List["Orange.data.Variable"], + color: "Orange.data.Variable", + n_attrs: int, + *, spin_label: str = "Number of variables: "): + # Add the spin box for a number of attributes to take into account. + self.n_attrs = n_attrs + super().__init__(parent, data, attributes, color) + self._attr_order = None + + box = gui.hBox(self) + self.n_attrs_spin = gui.spin( + box, self, None, 3, 8, label=spin_label, + controlWidth=50, alignment=Qt.AlignRight, + callback=self.on_n_attrs_changed) + + n_cont = self.max_attrs() + self.n_attrs_spin.setValue(min(self.n_attrs, n_cont)) + self.n_attrs_spin.setMaximum(n_cont) + self.n_attrs_spin.parent().setDisabled(not n_cont) + + def max_attrs(self) -> int: + return sum(v is not self.attr_color for v in self.attrs) + + def start_computation(self) -> None: + if self.n_attrs != self.n_attrs_spin.value(): + self.n_attrs = self.n_attrs_spin.value() + self.set_run_state(RunState.Initialized) + self.n_attrs_spin.lineEdit().deselect() + self.rank_table.setFocus(Qt.FocusReason.OtherFocusReason) + super().start_computation() + + def on_n_attrs_changed(self) -> None: + if self.run_state.state == RunState.Running: + self.pause_computation() + + new_attrs = self.n_attrs_spin.value() + if new_attrs == self.n_attrs: + self.set_button_state() + else: + self.set_button_state(label=f"Restart with {new_attrs} variables", + enabled=True) + + def emit_run_state_changed(self) -> None: + self.runStateChanged.emit(self.run_state.state, + {"n_attrs":self.n_attrs}) + + def closeEvent(self, event) -> None: + self.n_attrs_spin.setValue(self.n_attrs) + super().closeEvent(event) diff --git a/Orange/widgets/visualize/utils/widget.py b/Orange/widgets/visualize/utils/widget.py index d7094c85700..34fa623eb7c 100644 --- a/Orange/widgets/visualize/utils/widget.py +++ b/Orange/widgets/visualize/utils/widget.py @@ -24,6 +24,7 @@ ) from Orange.widgets.utils.plot import OWPlotGUI from Orange.widgets.utils.sql import check_sql_input +from Orange.widgets.utils.localization import pl from Orange.widgets.visualize.owscatterplotgraph import ( OWScatterPlotBase, MAX_COLORS ) @@ -138,14 +139,12 @@ def get_column(self, attr, filter_valid=True, needs_merging = attr.is_discrete \ and max_categories is not None \ - and len(attr.values) >= max_categories + and len(attr.values) > max_categories if return_labels and not needs_merging: assert attr.is_discrete return attr.values - all_data = self.data.get_column_view(attr)[0] - if all_data.dtype == object and attr.is_primitive(): - all_data = all_data.astype(float) + all_data = self.data.get_column(attr) if filter_valid and self.valid_data is not None: all_data = all_data[self.valid_data] if not needs_merging: @@ -299,24 +298,24 @@ def shapes_changed(self): # Tooltip def _point_tooltip(self, point_id, skip_attrs=()): - def show_part(_point_data, singular, plural, max_shown, _vars): + def show_part(_point_data, name, max_shown, _vars): cols = [escape('{} = {}'.format(var.name, _point_data[var])) for var in _vars[:max_shown + 2] - if _vars == domain.class_vars + if _vars == dom.class_vars or var not in skip_attrs][:max_shown] if not cols: return "" n_vars = len(_vars) if n_vars > max_shown: - cols[-1] = "... and {} others".format(n_vars - max_shown + 1) - return \ - "{}:
      ".format(singular if n_vars < 2 else plural) \ - + "
      ".join(cols) + over = n_vars - max_shown + 1 + cols[-1] = f"... and {over} {pl(over, 'other')}" + return f"{name}:
      " + "
      ".join(cols) - domain = self.data.domain - parts = (("Class", "Classes", 4, domain.class_vars), - ("Meta", "Metas", 4, domain.metas), - ("Feature", "Features", 10, domain.attributes)) + dom = self.data.domain + parts = ( + (f"{pl(len(dom.class_vars), 'Class|Classes')}", 4, dom.class_vars), + (f"{pl(len(dom.metas), 'Meta')}", 4, dom.metas), + (f"{pl(len(dom.attributes), 'Feature')}", 10, dom.attributes)) point_data = self.data[point_id] return "
      ".join(show_part(point_data, *columns) @@ -339,9 +338,23 @@ def get_tooltip(self, point_ids): text = "
      ".join(self._point_tooltip(point_id) for point_id in point_ids[:MAX_POINTS_IN_TOOLTIP]) if len(point_ids) > MAX_POINTS_IN_TOOLTIP: - text = "{} instances
      {}
      ...".format(len(point_ids), text) + text = f"{len(point_ids)} instances
      {text}
      ..." return text + def get_aggregated_tooltip(self, point_ids): + """ + Return tooltip for aggregate points (e.g. piecharts). + + Default implementation falls back to get_tooltip + + Args: + point_ids (list): indices into 'data' + + Returns: + tooltip (str) + """ + return self.get_tooltip(point_ids) + def keyPressEvent(self, event): """Update the tip about using the modifier keys when selecting""" super().keyPressEvent(event) @@ -388,7 +401,7 @@ class Warning(OWProjectionWidgetBase.Warning): GRAPH_CLASS = OWScatterPlotBase graph = SettingProvider(OWScatterPlotBase) - graph_name = "graph.plot_widget.plotItem" + graph_name = "graph.plot_widget.plotItem" # pg.GraphicsItem (pg.PlotItem) embedding_variables_names = ("proj-x", "proj-y") buttons_area_orientation = Qt.Vertical @@ -455,7 +468,10 @@ def set_data(self, data): self.openContext(self.data) self._invalidated = not ( data_existed and self.data is not None and - array_equal(effective_data.X, self.effective_data.X)) + array_equal(effective_data.X, self.effective_data.X) and + array_equal(effective_data.Y, self.effective_data.Y) and + array_equal(effective_data.metas, self.effective_data.metas) + ) self._domain_invalidated = not ( data_existed and self.data is not None and effective_data.domain.checksum() @@ -484,7 +500,7 @@ def handleNewSignals(self): else: self.graph.update_point_props() self._update_opacity_warning() - self.unconditional_commit() + self.commit.now() def _handle_subset_data(self): self.Warning.subset_independent.clear() @@ -558,9 +574,10 @@ def selection_changed(self): else self.graph.selection self.selection = [(i, x) for i, x in enumerate(sel) if x] \ if sel is not None else None - self.commit() + self.commit.deferred() # Output + @gui.deferred def commit(self): self.send_data() @@ -584,7 +601,9 @@ def _get_projection_data(self): data = self.data.transform(Domain(self.data.domain.attributes, self.data.domain.class_vars, self.data.domain.metas + variables)) - data.metas[:, -2:] = self.get_embedding() + if data.metas.size: + with data.unlocked(data.metas): + data.metas[:, -2:] = self.get_embedding() return data def _get_projection_variables(self): @@ -645,6 +664,7 @@ def sizeHint(self): def clear(self): self.selection = None self.graph.selection = None + self.graph.clear() def onDeleteWidget(self): super().onDeleteWidget() @@ -661,7 +681,7 @@ class OWAnchorProjectionWidget(OWDataProjectionWidget, openclass=True): graph = SettingProvider(OWGraphWithAnchors) class Outputs(OWDataProjectionWidget.Outputs): - components = Output("Components", Table) + components = Output("Components", Table, dynamic=False) class Error(OWDataProjectionWidget.Error): sparse_data = Msg("Sparse data is not supported") @@ -726,7 +746,7 @@ def _manual_move(self, anchor_idx, x, y): def _manual_move_finish(self, anchor_idx, x, y): self._manual_move(anchor_idx, x, y) self.graph.set_sample_size(None) - self.commit() + self.commit.deferred() def _get_projection_data(self): if self.data is None or self.projection is None: @@ -744,6 +764,7 @@ def _get_projection_data(self): self.data.domain.class_vars, self.data.domain.metas + attributes)) + @gui.deferred def commit(self): super().commit() self.send_components() @@ -755,7 +776,7 @@ def send_components(self): comp_name = get_unique_names(proposed, 'component') meta_attrs = [StringVariable(name=comp_name)] domain = Domain(self.effective_variables, metas=meta_attrs) - components = Table(domain, self._send_components_x(), + components = Table(domain, self._send_components_x().copy(), metas=self._send_components_metas()) components.name = "components" self.Outputs.components.send(components) diff --git a/Orange/widgets/widget.py b/Orange/widgets/widget.py index 9afcc31a822..ce55d94a891 100644 --- a/Orange/widgets/widget.py +++ b/Orange/widgets/widget.py @@ -5,7 +5,7 @@ Default, NonDefault, Single, Multiple, Dynamic, Explicit ) from orangewidget.widget import ( - OWBaseWidget, Message, Msg, StateInfo, Input, Output, + OWBaseWidget, Message, Msg, StateInfo, Input, Output, MultiInput ) from Orange.widgets.utils.progressbar import ProgressBarMixin @@ -16,8 +16,8 @@ import Orange.widgets.utils.state_summary # pylint: disable=unused-import __all__ = [ - "OWWidget", "Input", "Output", "AttributeList", "Message", "Msg", - "StateInfo", + "OWWidget", "Input", "Output", "MultiInput", "AttributeList", "Message", + "Msg", "StateInfo", # these are re-exported here for legacy reasons. Use Input/Output instead. "InputSignal", "OutputSignal", diff --git a/Orange/widgets/widgetTemplate.py b/Orange/widgets/widgetTemplate.py deleted file mode 100644 index d035825951f..00000000000 --- a/Orange/widgets/widgetTemplate.py +++ /dev/null @@ -1,26 +0,0 @@ -from Orange.widgets import widget, gui -from Orange.widgets.settings import Setting - -class OWWidgetName(widget.OWWidget): - name = "Widget Name" - id = "orange.widgets.widget_category.widget_name" - description = "" - icon = "icons/Unknown.svg" - priority = 10 - category = "" - keywords = ["list", "of", "keywords"] - outputs = [("Name", type)] - inputs = [("Name", type, "handler")] - - want_main_area = False - - foo = Setting(True) - - def __init__(self): - super().__init__() - - # controls - gui.rubber(self.controlArea) - - def handler(self, obj): - pass diff --git a/README.md b/README.md index 4d0ece549cb..f8af4677806 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ ### Easy installation -For easy installation, [![Download](https://img.shields.io/github/v/release/biolab/orange3?label=download)](https://orange.biolab.si/download) the latest released Orange version from our website. To install an add-on, head to `Options -> Add-ons...` in the menu bar. +For easy installation, [Download](https://orange.biolab.si/download) the latest released Orange version from our website. To install an add-on, head to `Options -> Add-ons...` in the menu bar. ### Installing with Conda @@ -42,8 +42,11 @@ Then, create a new conda environment, and install orange3: # Add conda-forge to your channels for access to the latest release conda config --add channels conda-forge +# Perhaps enforce strict conda-forge priority +conda config --set channel_priority strict + # Create and activate an environment for Orange -conda create python=3 --yes --name orange3 +conda create python=3.12 --yes --name orange3 conda activate orange3 # Install Orange @@ -60,7 +63,9 @@ conda install orange3- ### Installing with pip We recommend using our [standalone installer](https://orange.biolab.si/download) or conda, but Orange is also installable with pip. You will need a C/C++ compiler (on Windows we suggest using Microsoft Visual Studio Build Tools). - +Orange needs PyQt to run. Install either: +- PyQt6 and PyQt6-WebEngine: `pip install PyQt6 PyQt6-WebEngine` (suggested) +- PyQt5 and PyQtWebEngine: `pip install PyQt5 PyQtWebEngine` ### Installing with winget (Windows only) @@ -89,11 +94,11 @@ Starting up for the first time may take a while. Want to write a widget? [Use the Orange3 example add-on template.](https://github.com/biolab/orange3-example-addon) -Want to get involved? Join us on [![Discord](https://img.shields.io/discord/633376992607076354?logo=discord&color=7389D8&logoColor=white&label=Discord)](https://discord.gg/FWrfeXV), introduce yourself in #general! +Want to get involved? Join us on [Discord](https://discord.gg/FWrfeXV), introduce yourself in #general! Take a look at our [contributing guide](https://github.com/irgolic/orange3/blob/README-shields/CONTRIBUTING.md) and [style guidelines](https://github.com/biolab/orange-widget-base/wiki/Widget-UI). -Check out our widget development [![docs](https://readthedocs.org/projects/orange-widget-base/badge/?version=latest)](https://orange-widget-base.readthedocs.io/en/latest/?badge=latest) for a comprehensive guide on writing Orange widgets. +Check out our widget development [docs](https://orange-widget-base.readthedocs.io/en/latest/?badge=latest) for a comprehensive guide on writing Orange widgets. ### The Orange ecosystem @@ -129,15 +134,17 @@ export MY_GITHUB_USERNAME=replaceme create a conda environment, clone your fork, and install it: ```Shell -conda create python=3 --yes --name orange3 +conda create python=3.12 --yes --name orange3 conda activate orange3 git clone ssh://git@github.com/$MY_GITHUB_USERNAME/orange3 +# Install PyQt. This will install PyQt6; you can also use PyQt5 +pip install -r orange3/requirements-pyqt.txt pip install -e orange3 ``` -Now you're ready to work with git. See GitHub's guides on [pull requests](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests), [forks](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/working-with-forks) if you're unfamiliar. If you're having trouble, get in touch on [![Discord](https://img.shields.io/discord/633376992607076354?logo=discord&color=7389D8&logoColor=white&label=Discord)](https://discord.gg/FWrfeXV). +Now you're ready to work with git. See GitHub's guides on [pull requests](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests), [forks](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/working-with-forks) if you're unfamiliar. If you're having trouble, get in touch on [Discord](https://discord.gg/FWrfeXV). #### Running @@ -169,9 +176,12 @@ export MY_GITHUB_USERNAME=replaceme create a conda environment, clone your forks, and install them: ```Shell -conda create python=3 --yes --name orange3 +conda create python=3.12 --yes --name orange3 conda activate orange3 +# Install PyQT and PyQtWebEngine. You can also use PyQt6 +pip install -r requirements-pyqt.txt + git clone ssh://git@github.com/$MY_GITHUB_USERNAME/orange-widget-base pip install -e orange-widget-base diff --git a/benchmark/bench_datadelegate.py b/benchmark/bench_datadelegate.py index 42a9a28dc4e..26741ee2dfd 100644 --- a/benchmark/bench_datadelegate.py +++ b/benchmark/bench_datadelegate.py @@ -6,8 +6,7 @@ from Orange.data import Table from Orange.widgets.data.owtable import RichTableModel, TableBarItemDelegate, \ TableDataDelegate -from Orange.widgets.unsupervised.owdistancematrix import DistanceMatrixModel, \ - TableBorderItem +from Orange.widgets.utils.distmatrixmodel import DistMatrixModel from Orange.widgets.utils.tableview import TableView from .base import benchmark, Benchmark @@ -77,16 +76,13 @@ def setUp(self) -> None: super().setUp() data = Table("iris") dist = Orange.distance.Euclidean(data) - self.model = DistanceMatrixModel() + self.model = DistMatrixModel() self.model.set_data(dist) - self.delegate = TableBorderItem() - self.view.setItemDelegate(self.delegate) self.view.setModel(self.model) def tearDown(self) -> None: super().tearDown() del self.model - del self.delegate @benchmark(number=3, warmup=1, repeat=10) def bench_paint(self): diff --git a/benchmark/bench_preprocess.py b/benchmark/bench_preprocess.py new file mode 100644 index 00000000000..c3b19d814cc --- /dev/null +++ b/benchmark/bench_preprocess.py @@ -0,0 +1,42 @@ +from unittest.mock import patch, MagicMock + +import numpy as np + +from Orange.data import Domain, Table, ContinuousVariable +from Orange.preprocess import Normalize, SklImpute + +from .base import Benchmark, benchmark + + +class SetUpData: + + def setUp(self): + cols = 1000 + rows = 1000 + cont = [ContinuousVariable(str(i)) for i in range(cols)] + self.domain = Domain(cont) + self.single = Domain([ContinuousVariable("0")]) + self.table = Table.from_numpy( + self.domain, + np.random.RandomState(0).randint(0, 2, (rows, len(self.domain.variables)))) + self.normalized_domain = Normalize()(self.table).domain + + +class BenchNormalize(SetUpData, Benchmark): + + @benchmark(number=5) + def bench_normalize_only_transform(self): + self.table.transform(self.normalized_domain) + + @benchmark(number=5) + def bench_normalize_only_parameters(self): + # avoid benchmarking transformation + with patch("Orange.data.Table.transform", MagicMock()): + Normalize()(self.table) + + +class BenchSklImpute(SetUpData, Benchmark): + + @benchmark(number=5) + def bench_sklimpute(self): + SklImpute()(self.table) diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index 9284b297507..4d8d50b1e09 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -1,9 +1,6 @@ -ignore: {% set data = load_setup_py_data() %} -ignore: {% set version = data['version'] %} - package: name: orange3 - version: {{ data['version'] }} + version: {{ GIT_DESCRIBE_TAG }} source: git_url: ../ @@ -16,10 +13,7 @@ build: osx_is_app: True script: - - python setup.py build -j1 bdist_wheel - - if errorlevel 1 exit 1 # [win] - - pip install --no-deps --no-cache --no-index -f dist Orange3=={{ version }} - - if errorlevel 1 exit 1 # [win] + - python -m pip install . -vv requirements: build: @@ -27,45 +21,49 @@ requirements: - {{ compiler('cxx') }} host: - python - - setuptools - - numpy 1.14.* - cython - - pip - - wheel - - sphinx + - numpy >=2 - recommonmark + - setuptools + - sphinx >=4.2.0,<8 + - myst-parser + - wheel + - trubar run: - python - - setuptools >=36.3 - - numpy >=1.16.0 - - scipy >=0.16.1 - - scikit-learn >=0.22.0,!=0.23.0 - - bottleneck >=1.0.0 + # GUI requirements + - orange-canvas-core >=0.2.5,<0.3a + - orange-widget-base >=4.25.0 + - anyqt >=0.2.0 + - pyqt >=5.12,!=5.15.1,<6.0 + - matplotlib-base >=3.2.0 + - pygments >=2.8.0 + - pyqtgraph >=0.13.1 + - qtconsole >=4.7.2 + # core requirements + - baycomp >=1.0.2 + - bottleneck >=1.3.4 - chardet >=3.0.2 - - xlrd >=0.9.2 - - xlsxwriter - - anyqt >=0.0.11 - - pyqt >=5.12,!=5.15.1 - - pyqtgraph >=0.11.1 - - joblib >=0.9.4 + - httpx >=0.21,<1 + - joblib >=1.2.0 - keyring - keyrings.alt - - pip >=9.0 + - networkx + - numpy >=1.21.0,<2.4 + - openpyxl >=3.1.3 + - openTSNE >=0.6.2,!=0.7.0 + - pandas >=2.0.1,<3 + - packaging - python.app # [osx] - - serverfiles - python-louvain >=0.13 - - requests - - matplotlib-base >=2.0.0 - - openTSNE >=0.4.3 - - pandas >=1.0.0 - pyyaml - - orange-canvas-core >=0.1.18,<0.2a - - orange-widget-base >=4.8.1 - - openpyxl - - httpx >=0.12 - - baycomp >=1.0.2 - # cachecontrol (required by canvas core) <0.12.5 is incompatible with msgpack 1.0 - - cachecontrol >=0.12.6 + - requests + - scikit-learn >=1.5.1 + - scipy >=1.9 + - serverfiles + - xgboost >=1.7.4,<2.1 + - xlsxwriter + - xlrd >=1.2.0 test: # Python imports diff --git a/distribute/icon-256.png b/distribute/icon-256.png index 05a666671d1..d2cf9b27deb 100644 Binary files a/distribute/icon-256.png and b/distribute/icon-256.png differ diff --git a/distribute/icon-48.png b/distribute/icon-48.png index f87b6f86dbb..39098322e9f 100644 Binary files a/distribute/icon-48.png and b/distribute/icon-48.png differ diff --git a/distribute/orange-canvas.png b/distribute/orange-canvas.png new file mode 100644 index 00000000000..d2cf9b27deb Binary files /dev/null and b/distribute/orange-canvas.png differ diff --git a/distribute/orange-canvas.svg b/distribute/orange-canvas.svg deleted file mode 100644 index 539a8f20492..00000000000 --- a/distribute/orange-canvas.svg +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/doc/build_doc.sh b/doc/build_doc.sh index 7b1b8924e02..cde39b03b43 100644 --- a/doc/build_doc.sh +++ b/doc/build_doc.sh @@ -29,8 +29,8 @@ make html --directory "$SCRIPT_DIR"/visual-programming # check if the widget catalog in the repository (for orange-hugo is up to date cd "$SCRIPT_DIR" -wget_command="wget -N https://raw.githubusercontent.com/biolab/orange-hugo/master/scripts/create_widget_catalog.py" -run_command="python create_widget_catalog.py --categories Data,Visualize,Model,Evaluate,Unsupervised --doc visual-programming/source/" +wget_command="wget -N https://raw.githubusercontent.com/biolab/orange-web2/main/scripts/create_widget_catalog.py" +run_command="python create_widget_catalog.py --categories Data,Transform,Visualize,Model,Evaluate,Unsupervised --doc visual-programming/source/" eval "$wget_command" eval "$run_command" diff=$(git diff -- widgets.json) diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 00000000000..20009721f95 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,47 @@ +""" +This configuration use sphinx-multiproject which builds multiple +Sphinx projects for the Read the Docs. We publish each project at read-the-docs +as orange3 RTD project's subproject. This config file is only required for the +Read the Docs build. Each documentation project can still be built separately +with sphinx-build (make html). + +To select a documentation project that the RTD will build, set the PROJECT +environment variable in RTD subprojects to the documentation project name +(e.g. PROJECT=data-mining-library) + +To test the documentation build locally run (from doc directory): +``` +PROJECT="" sphinx-build . ./_build +``` +More about shpinx-multiproject: +https://sphinx-multiproject.readthedocs.io/en/latest/index.html +""" + +# pylint: disable=duplicate-code +extensions = [ + "multiproject", + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.mathjax", + "sphinx.ext.ifconfig", + "sphinx.ext.viewcode", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "recommonmark", +] + +# Define the projects that will share this configuration file. +multiproject_projects = { + "data-mining-library": { + "path": "data-mining-library/source/" + }, + "development": { + "path": "development/source/" + }, + "visual-programming": { + "path": "visual-programming/source/" + }, +} diff --git a/doc/data-mining-library/source/conf.py b/doc/data-mining-library/source/conf.py index 66e5ecde25f..a7987bc4505 100644 --- a/doc/data-mining-library/source/conf.py +++ b/doc/data-mining-library/source/conf.py @@ -80,7 +80,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "english" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/doc/data-mining-library/source/reference/data.io.rst b/doc/data-mining-library/source/reference/data.io.rst index 92c7a78d689..29e5a89fc99 100644 --- a/doc/data-mining-library/source/reference/data.io.rst +++ b/doc/data-mining-library/source/reference/data.io.rst @@ -9,7 +9,6 @@ Loading and saving data (``io``) * Comma-separated values (\*.csv) file, * Tab-separated values (\*.tab, \*.tsv) file, * Excel spreadsheet (\*.xls, \*.xlsx), -* Basket file, * Python pickle. In addition, the text-based files (CSV, TSV) can be compressed with gzip, @@ -43,8 +42,6 @@ A three-line header consists of: * ``string`` (or ``s``, or ``text``) — imported as :obj:`Orange.data.StringVariable`, * ``time`` (or ``t``) — imported as :obj:`Orange.data.TimeVariable`, if the values parse as `ISO 8601 `_ date/time formats, - * ``basket`` — used for storing sparse data. More on basket formats in a - dedicated section. 3. **Flags** (optional) on the third header line. Feature's flag can be empty, or it can contain, space-separated, a consistent combination of: @@ -56,7 +53,7 @@ A three-line header consists of: * ``weight`` (or ``w``) — the feature marks the weight of examples (in algorithms that support weighted examples), * ``ignore`` (or ``i``) — feature will not be imported, - * ``=`` custom attributes. + * ``=`` are custom attributes recognized in specific contexts, for instance ``color``, which defines the color palette when the variable is visualized, or ``type=image`` which signals that the variable contains a path to an image. Example of iris dataset in Orange's three-line format (:download:`iris.tab <../../../../Orange/datasets/iris.tab>`). @@ -72,98 +69,14 @@ Single-line header consists of feature names prefixed by an optional "``# string, i.e. flags followed by a hash ('#') sign. The flags can be a consistent combination of: -* ``c`` for class feature, +* ``c`` for class feature (also known as a target variable or dependent variable), * ``i`` for feature to be ignored, * ``m`` for meta attributes (not used in learning), -* ``C`` for features that are continuous, -* ``D`` for features that are discrete, +* ``C`` for features that are continuous (numeric), +* ``D`` for features that are discrete (categorical), * ``T`` for features that represent date and/or time in one of the ISO 8601 formats, * ``S`` for string features. If some (all) names or flags are omitted, the names, types, and flags are discerned automatically, and correctly (most of the time). - - -Baskets -======= - -Baskets can be used for storing sparse data in tab delimited files. They were -specifically designed for text mining needs. If text mining and sparse data is -not your business, you can skip this section. - -Baskets are given as a list of space-separated ``=`` atoms. A -continuous meta attribute named ```` will be created and added to the domain -as optional if it is not already there. A meta value for that variable will be -added to the example. If the value is 1, you can omit the ``=`` part. - -It is not possible to put meta attributes of other types than continuous in the -basket. - -A tab delimited file with a basket can look like this:: - - K Ca b_foo Ba y - c c basket c c - meta i class - 0.06 8.75 a b a c 0 1 - 0.48 b=2 d 0 1 - 0.39 7.78 0 1 - 0.57 8.22 c=13 0 1 - -These are the examples read from such a file:: - - [0.06, 1], {"Ca":8.75, "a":2.000, "b":1.000, "c":1.000} - [0.48, 1], {"Ca":?, "b":2.000, "d":1.000} - [0.39, 1], {"Ca":7.78} - [0.57, 1], {"Ca":8.22, "c":13.000} - -It is recommended to have the basket as the last column, especially if it -contains a lot of data. - -Note a few things. The basket column's name, ``b_foo``, is not used. In the first -example, the value of ``a`` is 2 since it appears twice. The ordinary meta -attribute, ``Ca``, appears in all examples, even in those where its value is -undefined. Meta attributes from the basket appear only where they are defined. -This is due to the different nature of these meta attributes: ``Ca`` is required -while the others are optional. :: - - >>> d.domain.metas() - {-6: FloatVariable 'd', -22: FloatVariable 'Ca', -5: FloatVariable 'c', -4: FloatVariable 'b', -3: FloatVariable 'a'} - -To fully understand all this, you should read the documentation on :ref:`meta -attributes ` in Domain and on the :ref:`basket file format -` (a simple format that is limited to baskets only). - -.. _basket-format: - -Basket Format -------------- - -Basket files (.basket) are suitable for representing sparse data. Each example -is represented by a line in the file. The line is written as a comma-separated -list of name-value pairs. Here's an example of such file. :: - - nobody, expects, the, Spanish, Inquisition=5 - our, chief, weapon, is, surprise=3, surprise=2, and, fear,fear, and, surprise - our, two, weapons, are, fear, and, surprise, and, ruthless, efficiency - to, the, Pope, and, nice, red, uniforms, oh damn - -The file contains four examples. The first examples has five attributes -defined, "nobody", "expects", "the", "Spanish" and "Inquisition"; the first -four have (the default) value of 1.0 and the last has a value of 5.0. - -The attributes that appear in the domain aren't defined in any headers or even -separate files, as with other formats supported by Orange. - -If attribute appears more than once, its values are added. For instance, the -value of attribute "surprise" in the second examples is 6.0 and the value of -"fear" is 2.0; the former appears three times with values of 3.0, 2.0 and 1.0, -and the latter appears twice with value of 1.0. - -All attributes are loaded as optional meta-attributes, so zero values don't -take any memory (unless they are given, but initialized to zero). See also -section on :ref:`meta attributes ` in the reference for domain -descriptors. - -Notice that at the time of writing this reference only association rules can -directly use examples presented in the basket format. diff --git a/doc/data-mining-library/source/reference/data.pandas.rst b/doc/data-mining-library/source/reference/data.pandas.rst new file mode 100644 index 00000000000..6f32d179237 --- /dev/null +++ b/doc/data-mining-library/source/reference/data.pandas.rst @@ -0,0 +1,49 @@ +.. currentmodule:: Orange.data.pandas_compat + +########################################### +Pandas interoperability (``pandas_compat``) +########################################### + +:obj:`Orange.data.pandas_compat` module provides functions to convert between :class:`pandas.DataFrame` and :class:`Orange.data.Table`. These functions enable integration of Orange's data structures with the pandas library, enabling users to shift between the frameworks. + +.. method::`table_from_frame` +.. autofunction:: table_from_frame + +.. method::`table_to_frame` +.. autofunction:: table_to_frame + +Example +======= + +>>> import pandas as pd +>>> from Orange.data import Table +>>> from Orange.data.pandas_compat import table_from_frame, table_to_frame +>>> df = pd.DataFrame({'A': [1, 2, 3], 'B': [4.0, 5.0, 6.0], 'C': ['a', 'b', 'c']}) +>>> df + A B C +0 1 4.0 a +1 2 5.0 b +2 3 6.0 c +>>> table = table_from_frame(df) +>>> table +[[1, 4] {a}, + [2, 5] {b}, + [3, 6] {c} +] + +Note that the non-numeric column 'C' becomes a meta attribute in the resulting table. To set it to a categorical variable, use ``force_nominal=True``: + +>>> table = table_from_frame(df, force_nominal=True) +[[1, 4, a], + [2, 5, b], + [3, 6, c] +] + +To convert back to a pandas DataFrame, use :func:`table_to_frame`: + +>>> frame = table_to_frame(table) +>>> frame + A B +0 1 4.0 +1 2 5.0 +2 3 6.0 diff --git a/doc/data-mining-library/source/reference/data.rst b/doc/data-mining-library/source/reference/data.rst index 19f8321e44c..1e58d16d18a 100644 --- a/doc/data-mining-library/source/reference/data.rst +++ b/doc/data-mining-library/source/reference/data.rst @@ -59,5 +59,6 @@ represented by whole numbers. data.instance data.filters data.io + data.pandas .. index:: Data diff --git a/doc/data-mining-library/source/reference/data.sql.rst b/doc/data-mining-library/source/reference/data.sql.rst index 8accf3cf01e..fd38086afc4 100644 --- a/doc/data-mining-library/source/reference/data.sql.rst +++ b/doc/data-mining-library/source/reference/data.sql.rst @@ -54,7 +54,3 @@ SQL table (``data.sql``) :obj:`Orange.data.sql.filter` contains classes derived from filters in :obj:`Orange.data.filter` with the appropriate implementation of the method. - - -.. autoclass:: Orange.data.sql.table.SqlRowInstance - :members: diff --git a/doc/data-mining-library/source/reference/data.table.rst b/doc/data-mining-library/source/reference/data.table.rst index edf8eac0f0d..00e9d68d82c 100644 --- a/doc/data-mining-library/source/reference/data.table.rst +++ b/doc/data-mining-library/source/reference/data.table.rst @@ -86,8 +86,6 @@ The preferred way to construct a table is to invoke a named constructor. Inspection ---------- -.. automethod:: Table.is_view -.. automethod:: Table.is_copy .. automethod:: Table.ensure_copy .. automethod:: Table.has_missing .. automethod:: Table.has_missing_class diff --git a/doc/data-mining-library/source/reference/data.variable.rst b/doc/data-mining-library/source/reference/data.variable.rst index cc715025368..b745599cc04 100644 --- a/doc/data-mining-library/source/reference/data.variable.rst +++ b/doc/data-mining-library/source/reference/data.variable.rst @@ -127,7 +127,20 @@ Time variables Time variables are continuous variables with value 0 on the Unix epoch, 1 January 1970 00:00:00.0 UTC. Positive numbers are dates beyond this date, and negative dates before. Due to limitation of Python :py:mod:`datetime` module, -only dates in 1 A.D. or later are supported. +only dates in 1 A.D. or later are supported. Note that Orange's Table stores datetime +values as UNIX epoch (seconds from 1970-01-01), thus :obj:`Table.from_numpy` expects values in this format. + +Orange's `TimeVariable` supports storing either date, time, or a combination of both: + +- `TimeVariable("Timestamp", have_date=True)` stores only date information -- it is analogous to `datetime.date` + +- `TimeVariable("Timestamp", have_time=True)` stores only time information (without date) -- it is analogous to `datetime.time`` + +- `TimeVariable("Timestamp", have_time=True, have_date=True)` stores date and time -- it is analogous to `datetime.datetime` + +When the `parse` method is used to parse datetimes from a string, it is not necessary +to set the `have_time` and `have_date` attributes since they will be inferred from +from datetimes. .. autoclass:: TimeVariable diff --git a/doc/data-mining-library/source/reference/distance.rst b/doc/data-mining-library/source/reference/distance.rst index 91dbb654b48..91a1ba43cd8 100644 --- a/doc/data-mining-library/source/reference/distance.rst +++ b/doc/data-mining-library/source/reference/distance.rst @@ -134,10 +134,7 @@ Cosine similarity is the dot product divided by the product of lengths (where the length is the square of dot product of a row/column with itself). Cosine distance is computed by subtracting the similarity from one. -In calculation of dot products, missing values are replaced by means. In -calculation of lengths, the contribution of a missing value equals the square -of the mean plus the variance. (The difference comes from the fact that in -the former case the missing values are independent.) +Missing values are replaced by means. Non-zero discrete values are replaced by 1. This introduces the notion of a "base value", which is the first in the list of possible values. In most cases, diff --git a/doc/data-mining-library/source/reference/evaluation.cd.rst b/doc/data-mining-library/source/reference/evaluation.cd.rst index 017c201d68b..b0d8e41e7af 100644 --- a/doc/data-mining-library/source/reference/evaluation.cd.rst +++ b/doc/data-mining-library/source/reference/evaluation.cd.rst @@ -72,27 +72,3 @@ R2 .. index:: R2 .. autofunction:: Orange.evaluation.R2 - - -CD diagram ----------- - -.. index:: CD diagram - -.. autofunction:: Orange.evaluation.compute_CD -.. autofunction:: Orange.evaluation.graph_ranks - -Example -======= - - >>> import Orange - >>> import matplotlib.pyplot as plt - >>> names = ["first", "third", "second", "fourth" ] - >>> avranks = [1.9, 3.2, 2.8, 3.3 ] - >>> cd = Orange.evaluation.compute_CD(avranks, 30) #tested on 30 datasets - >>> Orange.evaluation.graph_ranks(avranks, names, cd=cd, width=6, textspace=1.5) - >>> plt.show() - -The code produces the following graph: - -.. image:: images/statExamples-graph_ranks1.png diff --git a/doc/data-mining-library/source/reference/images/statExamples-graph_ranks1.png b/doc/data-mining-library/source/reference/images/statExamples-graph_ranks1.png deleted file mode 100644 index 1b9b1ecfcca..00000000000 Binary files a/doc/data-mining-library/source/reference/images/statExamples-graph_ranks1.png and /dev/null differ diff --git a/doc/data-mining-library/source/reference/regression.rst b/doc/data-mining-library/source/reference/regression.rst index 45c1d34d1c3..bfcc9953b81 100644 --- a/doc/data-mining-library/source/reference/regression.rst +++ b/doc/data-mining-library/source/reference/regression.rst @@ -148,3 +148,12 @@ Gradient Boosted Trees .. autoclass:: XGBRFRegressor :members: + + +Curve Fit +---------------------- + +.. automodule:: Orange.regression.curvefit + +.. autoclass:: CurveFitLearner + :members: diff --git a/doc/data-mining-library/source/tutorial/data.rst b/doc/data-mining-library/source/tutorial/data.rst index 3360af10403..c62eb6beef1 100644 --- a/doc/data-mining-library/source/tutorial/data.rst +++ b/doc/data-mining-library/source/tutorial/data.rst @@ -11,7 +11,7 @@ Data Input .. index:: single: data; input -Orange can read files in native tab-delimited format, or can load data from any of the major standard spreadsheet file types, like CSV and Excel. Native format starts with a header row with feature (column) names. The second header row gives the attribute type, which can be continuous, discrete, time, or string. The third header line contains meta information to identify dependent features (class), irrelevant features (ignore) or meta features (meta). +Orange can read files in proprietary tab-delimited format, or can load data from any of the major standard spreadsheet file types, like CSV and Excel. Native format starts with a header row with feature (column) names. The second header row gives the attribute type, which can be numeric, categorical, time, or string. The third header line contains meta information to identify dependent features (class), irrelevant features (ignore) or meta features (meta). More detailed specification is available in :doc:`../reference/data.io`. Here are the first few lines from a dataset :download:`lenses.tab `:: @@ -60,6 +60,61 @@ The following script wraps-up everything we have done so far and lists first 5 d Note that data is an object that holds both the data and information on the domain. We show above how to access attribute and class names, but there is much more information there, including that on feature type, set of values for categorical features, and other. +Creating a Data Table +--------------------- + +To create a data table from scratch, one needs two things, a `domain <../reference/data.domain.html>`_ and the data. The domain is the description of the variables, i.e. column names, types, roles, etc. + +First, we create the said domain. We will create three types of variables, numeric (ContiniousVariable), categorical (DiscreteVariable) and text (StringVariable). Numeric and categorical variables will be used a features (also known as X), while the text variable will be used as a meta variable. + + >>> from Orange.data import Domain, ContinuousVariable, + DiscreteVariable, StringVariable + >>> + >>> domain = Domain([ContinuousVariable("col1"), + DiscreteVariable("col2", values=["red", "blue"])], + metas=[StringVariable("col3")]) + +Now, we will build the data with numpy. + + >>> import numpy as np + >>> + >>> column1 = np.array([1.2, 1.4, 1.5, 1.1, 1.2]) + >>> column2 = np.array([0, 1, 1, 1, 0]) + >>> column3 = np.array(["U13", "U14", "U15", "U16", "U17"], dtype=object) + +Two things to note here. column2 has values 0 and 1, even though we specified it will be a categorical variable with values "red" and "blue". X (features in the data) can only be numbers, so the numpy matrix will contain numbers, while Orange will handle the categorical representation internally. 0 will be mapped to the value "red" and 1 to "blue" (in the order, specified in the domain). + +Text variable requires ``dtype=object`` for numpy to handle it correctly. + +Next, variables have to be transformed to a matrix. + + >>> X = np.column_stack((column1, column2)) + >>> M = column3.reshape(-1, 1) + +Finally, we create a table. We need a domain and variables, which can be passed as X (features), Y (class variable) or metas. + + >>> table = Table.from_numpy(domain, X=X, metas=M) + >>> print(table) + >>> [[1.2, red] {U13}, + [1.4, blue] {U14}, + [1.5, blue] {U15}, + [1.1, blue] {U16}, + [1.2, red] {U17}] + +To add a class variable to the table, the procedure would be the same, with the class variable passed as Y (e.g. ``table = Table.from_numpy(domain, X=X, Y=Y, metas=M)``). + +To add a single column to the table, one can use the ``Table.add_column()`` method. + + >>> new_var = DiscreteVariable("var4", values=["one", "two"]) + >>> var4 = np.array([0, 1, 0, 0, 1]) # no reshaping necessary + >>> table = table.add_column(new_var, var4) + >>> print(table) + >>> [[1.2, red, one] {U13}, + [1.4, blue, two] {U14}, + [1.5, blue, one] {U15}, + [1.1, blue, one] {U16}, + [1.2, red, two] {U17}] + Saving the Data --------------- diff --git a/doc/development/source/concurrent.rst b/doc/development/source/concurrent.rst deleted file mode 100644 index 1c7406818e9..00000000000 --- a/doc/development/source/concurrent.rst +++ /dev/null @@ -1,53 +0,0 @@ -.. currentmodule:: Orange.widgets.utils.concurrent - -:mod:`Orange.widgets.utils.concurrent` --------------------------------------- - -.. automodule:: Orange.widgets.utils.concurrent - -.. autoclass:: ThreadExecutor - :show-inheritance: - :members: - -.. autoclass:: FutureWatcher - :show-inheritance: - :members: - :exclude-members: - done, finished, cancelled, resultReady, exceptionReady - - .. autoattribute:: done(future: Future) - - .. autoattribute:: finished(future: Future) - - .. autoattribute:: cancelled(future: Future) - - .. autoattribute:: resultReady(result: Any) - - .. autoattribute:: exceptionReady(exception: BaseException) - - -.. autoclass:: FutureSetWatcher - :show-inheritance: - :members: - :exclude-members: - doneAt, finishedAt, cancelledAt, resultReadyAt, exceptionReadyAt, - progressChanged, doneAll - - .. autoattribute:: doneAt(index: int, future: Future) - - .. autoattribute:: finishedAt(index: int, future: Future) - - .. autoattribute:: cancelledAt(index: int, future: Future) - - .. autoattribute:: resultReadyAt(index: int, result: Any) - - .. autoattribute:: exceptionReadyAt(index: int, exception: BaseException) - - .. autoattribute:: progressChanged(donecount: int, count: int) - - .. autoattribute:: doneAll() - - -.. autoclass:: methodinvoke - :members: - diff --git a/doc/development/source/conf.py b/doc/development/source/conf.py index 9c52bc1e2b7..5a3002d423c 100644 --- a/doc/development/source/conf.py +++ b/doc/development/source/conf.py @@ -79,7 +79,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "english" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/doc/development/source/gui.rst b/doc/development/source/gui.rst index e5af9e513a1..779f766bfdb 100644 --- a/doc/development/source/gui.rst +++ b/doc/development/source/gui.rst @@ -174,14 +174,6 @@ This part of documentation describes some classes and functions that are used internally. The classes will likely maintain compatibility in the future, while the functions may be changed. -Wrappers for Qt classes -======================= - -.. autoclass:: SpinBoxWFocusOut -.. autoclass:: DoubleSpinBoxWFocusOut -.. autoclass:: LineEditWFocusOut -.. autoclass:: OrangeListBox - Wrappers for Python classes =========================== diff --git a/doc/development/source/index.rst b/doc/development/source/index.rst index dcb93bf0f2a..e438ba59651 100644 --- a/doc/development/source/index.rst +++ b/doc/development/source/index.rst @@ -5,7 +5,6 @@ Widget Development :maxdepth: 2 tutorial - tutorial-cont tutorial-settings tutorial-channels tutorial-responsive-gui @@ -13,8 +12,3 @@ Widget Development widget gui testing - -API ---- -.. toctree:: - concurrent diff --git a/doc/development/source/tutorial-cont.rst b/doc/development/source/tutorial-cont.rst deleted file mode 100644 index cae31faec59..00000000000 --- a/doc/development/source/tutorial-cont.rst +++ /dev/null @@ -1,155 +0,0 @@ - -Getting Started (Continued) -########################### - -After learning what an Orange Widget is and how to define them on -a toy example, we will build an semi-useful widgets that can -work together with the existing Orange Widgets. - -We will start with a very simple one, that will receive a dataset -on the input and will output a dataset with 10% of the data instances. -We will call this widget `OWDataSamplerA` (OW for Orange Widget, -DataSampler since this is what widget will be doing, and A since we -prototype a number of this widgets in our tutorial). - - -A 'Demo' package ----------------- - -First in order to include our new widgets in the Orange Canvas's -toolbox we will create a dummy `python project -`_ -named *orange-demo* - -The layout should be:: - - orange-demo/ - setup.py - orangedemo/ - __init__.py - OWDataSamplerA.py - -and the :download:`orange-demo/setup.py` should contain - -.. literalinclude:: orange-demo/setup.py - -Note that we declare our *orangedemo* package as containing widgets -from an ad hoc defined category *Demo*. - -.. seealso:: - https://github.com/biolab/orange3/wiki/Add-Ons - -.. - TODO: Additional tutorial for Add-on declaration - -Following the previous examples, our module defining the OWDataSamplerA -widget starts out as: - -.. literalinclude:: orange-demo/orangedemo/OWDataSamplerA.py - :start-after: start-snippet-1 - :end-before: end-snippet-1 - -The widget defines an input channel "Data" and an output channel called -"Sampled Data". Both will carry tokens of the type :class:`Orange.data.Table`. -In the code, we will refer to the signals as `Inputs.data` and `Outputs.sample`. - -Channels can carry tokens of arbitrary types. However, the purpose of widgets -is to talk with other widgets, so as one of the main design principles we try -to maximize the flexibility of widgets by minimizing the number of different -channel types. Do not invent new signal types before checking whether you cannot -reuse the existing. - -As our widget won't display anything apart from some info, we will -place the two labels in the control area and surround it with the box -"Info". - -The next four lines specify the GUI of our widget. This will be -simple, and will include only two lines of text of which, if nothing -will happen, the first line will report on "no data yet", and second -line will be empty. - - -In order to complete our widget, we now need to define a method that will -handle the input data. We will call it :func:`set_data`; the name is arbitrary, -but calling the method `set_` seems like a good practice. -To designate it as the method that accepts the signal defined in `Inputs.data`, -we decorate it with `@Inputs.data`. - -.. literalinclude:: orange-demo/orangedemo/OWDataSamplerA.py - :start-after: start-snippet-2 - :end-before: end-snippet-2 - -The :obj:`dataset` argument is the token sent through the input -channel which our method needs to handle. - -To handle a non-empty token, the widget updates the interface -reporting on number of data items on the input, then does the data -sampling using Orange's routines for these, and updates the -interface reporting on the number of sampled instances. Finally, the -sampled data is sent as a token to the output channel defined as -`Output.sample`. - -Although our widget is now ready to test, for a final touch, let's -design an icon for our widget. As specified in the widget header, we -will call it -:download:`DataSamplerA.svg ` -and put it in `icons` subdirectory of `orangedemo` directory. - - -With this we can now go ahead and install the orangedemo package. We -will do this by running ``pip install -e .`` command from -within the `orange-demo` directory. - -.. note:: - Depending on your python installation you might need - administrator/superuser privileges. - -For a test, we now open Orange Canvas. There should be a new pane in a -widget toolbox called Demo. If we click on this pane, it displays an -icon of our widget. Try to hover on it to see if the header and channel -info was processed correctly: - -.. image:: images/samplewidgetontoolbox.png - -Now for the real test. We put the File widget on the schema (from -Data pane) and load the iris.tab dataset. We also put our Data -Sampler widget on the scheme and open it (double click on the icon, -or right-click and choose Open): - -.. image:: images/datasamplerAempty.png - -Now connect the File and Data Sampler widget (click on an output -connector of the File widget, and drag the line to the input connector -of the Data Sampler). If everything is ok, as soon as you release the -mouse, the connection is established and, the token that was waiting -on the output of the file widget was sent to the Data Sampler widget, -which in turn updated its window: - -.. image:: images/datasamplerAupdated.png - -To see if the Data Sampler indeed sent some data to the output, -connect it to the Data Table widget: - -.. image:: images/schemawithdatatable.png - -Try opening different data files (the change should propagate -through your widgets and with Data Table window open, you should -immediately see the result of sampling). Try also removing the -connection between File and Data Sampler (right click on the -connection, choose Remove). What happens to the data displayed in the -Data Table? - - -***************************************** -Testing Your Widget Outside Orange Canvas -***************************************** - -For debugging purposes, we want to be able to run widgets standalone: if the -file with the widget code is executed as a main script, it should show the -widget and feed it some suitable data. The simplest way to do so is to use -:obj:`Orange.widgets.utils.WidgetPreview` and pass it the data for the -default signal. - -.. literalinclude:: orange-demo/orangedemo/OWDataSamplerA.py - :start-after: start-snippet-3 - :end-before: end-snippet-3 diff --git a/doc/development/source/tutorial.rst b/doc/development/source/tutorial.rst index 58c5b356a9e..381ca8daa44 100644 --- a/doc/development/source/tutorial.rst +++ b/doc/development/source/tutorial.rst @@ -4,7 +4,6 @@ Getting Started ############### - Orange Widgets are components in Orange Canvas, a visual programming environment of Orange. They represent some self contained functionalities and provide a graphical user interface (GUI). Widgets communicate with each other and @@ -15,7 +14,6 @@ On this page, we will start with some simple essentials, and then show you how to build a simple widget that will be ready to run within Orange Canvas. - Prerequisites ************* @@ -29,14 +27,12 @@ a toolbox on the left: Each widget has a name description and a set of input/outputs (referred to as the widget's meta description). - This meta data is discovered at Orange Canvas application startup leveraging setuptools/distribute and its `entry points`_ protocol. Orange Canvas looks for widgets using an ``orange.widgets`` entry point. .. _`entry points`: https://pythonhosted.org/setuptools/setuptools.html#dynamic-discovery-of-services-and-plugins - Defining a widget ***************** @@ -104,7 +100,6 @@ special property/member in the widget's class definition like so: number = Setting(42) - And finally the actual code to define the GUI and the associated widget functionality: @@ -216,3 +211,150 @@ One more: self.Outputs.sum.send(None) .. seealso:: :func:`~Orange.widgets.widget.OWWidget.handleNewSignals` + +A 'Demo' package +**************** + +After learning what an Orange Widget is and how to define them on +a toy example, we will build an semi-useful widgets that can +work together with the existing Orange Widgets. + +We will start with a very simple one, that will receive a dataset +on the input and will output a dataset with 10% of the data instances. +We will call this widget `OWDataSamplerA` (OW for Orange Widget, +DataSampler since this is what widget will be doing, and A since we +prototype a number of this widgets in our tutorial). + +First in order to include our new widgets in the Orange Canvas's +toolbox we will create a dummy `python project +`_ +named *orange-demo* + +The layout should be:: + + orange-demo/ + setup.py + orangedemo/ + __init__.py + OWDataSamplerA.py + +and the :download:`orange-demo/setup.py` should contain + +.. literalinclude:: orange-demo/setup.py + +Note that we declare our *orangedemo* package as containing widgets +from an ad hoc defined category *Demo*. + +.. seealso:: + https://github.com/biolab/orange3/wiki/Add-Ons + +.. + TODO: Additional tutorial for Add-on declaration + +Following the previous examples, our module defining the OWDataSamplerA +widget starts out as: + +.. literalinclude:: orange-demo/orangedemo/OWDataSamplerA.py + :start-after: start-snippet-1 + :end-before: end-snippet-1 + +The widget defines an input channel "Data" and an output channel called +"Sampled Data". Both will carry tokens of the type :class:`Orange.data.Table`. +In the code, we will refer to the signals as `Inputs.data` and `Outputs.sample`. + +Channels can carry tokens of arbitrary types. However, the purpose of widgets +is to talk with other widgets, so as one of the main design principles we try +to maximize the flexibility of widgets by minimizing the number of different +channel types. Do not invent new signal types before checking whether you cannot +reuse the existing. + +As our widget won't display anything apart from some info, we will +place the two labels in the control area and surround it with the box +"Info". + +The next four lines specify the GUI of our widget. This will be +simple, and will include only two lines of text of which, if nothing +will happen, the first line will report on "no data yet", and second +line will be empty. + +In order to complete our widget, we now need to define a method that will +handle the input data. We will call it :func:`set_data`; the name is arbitrary, +but calling the method `set_` seems like a good practice. +To designate it as the method that accepts the signal defined in `Inputs.data`, +we decorate it with `@Inputs.data`. + +.. literalinclude:: orange-demo/orangedemo/OWDataSamplerA.py + :start-after: start-snippet-2 + :end-before: end-snippet-2 + +The :obj:`dataset` argument is the token sent through the input +channel which our method needs to handle. + +To handle a non-empty token, the widget updates the interface +reporting on number of data items on the input, then does the data +sampling using Orange's routines for these, and updates the +interface reporting on the number of sampled instances. Finally, the +sampled data is sent as a token to the output channel defined as +`Output.sample`. + +Although our widget is now ready to test, for a final touch, let's +design an icon for our widget. As specified in the widget header, we +will call it +:download:`DataSamplerA.svg ` +and put it in `icons` subdirectory of `orangedemo` directory. + +With this we can now go ahead and install the orangedemo package. We +will do this by running ``pip install -e .`` command from +within the `orange-demo` directory. + +.. note:: + Depending on your python installation you might need + administrator/superuser privileges. + +For a test, we now open Orange Canvas. There should be a new pane in a +widget toolbox called Demo. If we click on this pane, it displays an +icon of our widget. Try to hover on it to see if the header and channel +info was processed correctly: + +.. image:: images/samplewidgetontoolbox.png + +Now for the real test. We put the File widget on the schema (from +Data pane) and load the iris.tab dataset. We also put our Data +Sampler widget on the scheme and open it (double click on the icon, +or right-click and choose Open): + +.. image:: images/datasamplerAempty.png + +Now connect the File and Data Sampler widget (click on an output +connector of the File widget, and drag the line to the input connector +of the Data Sampler). If everything is ok, as soon as you release the +mouse, the connection is established and, the token that was waiting +on the output of the file widget was sent to the Data Sampler widget, +which in turn updated its window: + +.. image:: images/datasamplerAupdated.png + +To see if the Data Sampler indeed sent some data to the output, +connect it to the Data Table widget: + +.. image:: images/schemawithdatatable.png + +Try opening different data files (the change should propagate +through your widgets and with Data Table window open, you should +immediately see the result of sampling). Try also removing the +connection between File and Data Sampler (right click on the +connection, choose Remove). What happens to the data displayed in the +Data Table? + +Testing Your Widget Outside Orange Canvas +***************************************** + +For debugging purposes, we want to be able to run widgets standalone: if the +file with the widget code is executed as a main script, it should show the +widget and feed it some suitable data. The simplest way to do so is to use +:obj:`Orange.widgets.utils.WidgetPreview` and pass it the data for the +default signal. + +.. literalinclude:: orange-demo/orangedemo/OWDataSamplerA.py + :start-after: start-snippet-3 + :end-before: end-snippet-3 diff --git a/doc/visual-programming b/doc/visual-programming new file mode 160000 index 00000000000..bc5f33cae07 --- /dev/null +++ b/doc/visual-programming @@ -0,0 +1 @@ +Subproject commit bc5f33cae07a72ad5a787fbf99b6194e6664acfd diff --git a/doc/visual-programming/Makefile b/doc/visual-programming/Makefile deleted file mode 100644 index 25039dfc369..00000000000 --- a/doc/visual-programming/Makefile +++ /dev/null @@ -1,192 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/OrangeVisualProgramming.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/OrangeVisualProgramming.qhc" - -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/OrangeVisualProgramming" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/OrangeVisualProgramming" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/doc/visual-programming/make.bat b/doc/visual-programming/make.bat deleted file mode 100644 index 51e25089dba..00000000000 --- a/doc/visual-programming/make.bat +++ /dev/null @@ -1,263 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source -set I18NSPHINXOPTS=%SPHINXOPTS% source -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 2> nul -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\OrangeVisualProgramming.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\OrangeVisualProgramming.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -:end diff --git a/doc/visual-programming/source/_static/style.css b/doc/visual-programming/source/_static/style.css deleted file mode 100644 index 6dddc8f9df0..00000000000 --- a/doc/visual-programming/source/_static/style.css +++ /dev/null @@ -1,37 +0,0 @@ -p + dl { - border-top: 2px solid gray; - border-bottom: 2px solid gray; - padding: 12px; -} - -p + dl dd:last-of-type { - margin-bottom: 0; -} - -p + dl dt { - font-weight: bold; -} - -p + dl dt::after { - content: ":"; -} - -dd dt { - font-weight: bold; - display: inline-block; -} - -dd dt::after { - content: ":"; -} - -dd dd { - display: inline; - margin: 0; - } - -dd dd:after{ - display: block; - content: ''; - } - diff --git a/doc/visual-programming/source/building-workflows/DataTable-wrong.png b/doc/visual-programming/source/building-workflows/DataTable-wrong.png deleted file mode 100644 index 7bbaed507c4..00000000000 Binary files a/doc/visual-programming/source/building-workflows/DataTable-wrong.png and /dev/null differ diff --git a/doc/visual-programming/source/building-workflows/file-datatable.gif b/doc/visual-programming/source/building-workflows/file-datatable.gif deleted file mode 100644 index 603858248f4..00000000000 Binary files a/doc/visual-programming/source/building-workflows/file-datatable.gif and /dev/null differ diff --git a/doc/visual-programming/source/building-workflows/file.gif b/doc/visual-programming/source/building-workflows/file.gif deleted file mode 100644 index bb37a8d3ee9..00000000000 Binary files a/doc/visual-programming/source/building-workflows/file.gif and /dev/null differ diff --git a/doc/visual-programming/source/building-workflows/index.md b/doc/visual-programming/source/building-workflows/index.md deleted file mode 100644 index 168bdb63baf..00000000000 --- a/doc/visual-programming/source/building-workflows/index.md +++ /dev/null @@ -1,43 +0,0 @@ -# Building Workflows - -The core principle of Orange is visual programming, which means each analytical step in contained within a widget. Widgets are placed on the canvas and connected into an analytical workflow, which is executed from left to right. Orange never passes data backwards. - -## Simple workflow - -Let us start with a simple workflow. We will load the data with the File widget, say the famous *Iris* data set. Right-click on the canvas. A menu will appear. Start typing "File", then press Enter to confirm the selection. [File](../widgets/data/file.md) widget will be placed on the canvas. - -![](file.gif) - -**File** widget has an "ear" on its right side – this is the output of the widget. Click on the "ear" and drag a connection out of it. Upon releasing the connection, a menu will appear. Start typing the name of the widget to connect with the File widget, say Data Table. Select the widget and press enter. The widget is added to the canvas. - -![](file-datatable.gif) - -This is a simple workflow. The File widget loads the data and sends it to the output. Data Table receives the data and displays it in a table. Please note that Data Table is a viewer and passes onwards only the selection. The data is always available at the source - in the File widget. - -![](DataTable-wrong.png) - -## Workflows with subsets - -Visualizations in Orange are interactive, which means the user can select data instances from the plot and pass them downstream. Let us look at two examples with subsets. - -### Selecting subsets - -Place **File** widget on the canvas. Then connect [Scatter Plot](../widgets/visualize/scatterplot.md) to it. Click and drag a rectangle around a subset of points. Connect [Data Table](../widgets/data/datatable.md) to Scatter Plot. Data Table will show selected points. - -![](subset-selection.gif) - -### Highlighting workflows - -Place **File** widget on the canvas. Then connect **Scatter Plot** to it and a **Data Table**. Connect Data Table to Scatter Plot. Select a subset of points from the Data Table. Scatter Plot will highlight selected points. - -![](subset-highlight.gif) - -## Workflows with models - -Predictive models are evaluated in [Test and Score](../widgets/evaluate/testandscore.md) widget, while predictions on new data are done in [Predictions](../widgets/evaluate/predictions.md). Test and Score accepts several inputs: data (data set for evaluating models), learners (algorithms to use for training the model), and an optional preprocessor (for normalization or feature selection). - -![](prediction-workflow.png) - -For prediction, the training data is first passed to the model. Once the model is trained, it is passed to **Predictions**. The Predictions widget also needs data to predict on, which are passed as a second input. - -![](prediction-workflow2.png) diff --git a/doc/visual-programming/source/building-workflows/prediction-workflow.png b/doc/visual-programming/source/building-workflows/prediction-workflow.png deleted file mode 100644 index 19e8e19a578..00000000000 Binary files a/doc/visual-programming/source/building-workflows/prediction-workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/building-workflows/prediction-workflow2.png b/doc/visual-programming/source/building-workflows/prediction-workflow2.png deleted file mode 100644 index 081c2462cc9..00000000000 Binary files a/doc/visual-programming/source/building-workflows/prediction-workflow2.png and /dev/null differ diff --git a/doc/visual-programming/source/building-workflows/subset-highlight.gif b/doc/visual-programming/source/building-workflows/subset-highlight.gif deleted file mode 100644 index 33ee691948d..00000000000 Binary files a/doc/visual-programming/source/building-workflows/subset-highlight.gif and /dev/null differ diff --git a/doc/visual-programming/source/building-workflows/subset-selection.gif b/doc/visual-programming/source/building-workflows/subset-selection.gif deleted file mode 100644 index caafa31a079..00000000000 Binary files a/doc/visual-programming/source/building-workflows/subset-selection.gif and /dev/null differ diff --git a/doc/visual-programming/source/conf.py b/doc/visual-programming/source/conf.py deleted file mode 100644 index 8d88cc320f4..00000000000 --- a/doc/visual-programming/source/conf.py +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# Orange Visual Programming documentation build configuration file, created by -# sphinx-quickstart on Fri Nov 27 12:05:51 2015. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys -import os - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.insert(0, os.path.abspath('.')) - -sys.path.append(os.path.abspath("../../..")) -sys.path.append(os.path.abspath(".")) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.intersphinx", - "sphinx.ext.todo", - "sphinx.ext.imgmath", - "sphinx.ext.ifconfig", - "sphinx.ext.viewcode", - "recommonmark", -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -source_suffix = [".md", ".rst"] - -# The encoding of source files. -# source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = "index" - -# General information about the project. -project = "Orange Visual Programming" -copyright = "2015, Orange Data Mining" -author = "Orange Data Mining" - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = "3" -# The full version, including alpha/beta/rc tags. -release = "3" - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = "alabaster" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -# html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_domain_indices = True - -# If false, no index is generated. -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = "OrangeVisualProgrammingdoc" - -# -- Options for LaTeX output --------------------------------------------- - -# latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', -# } - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ( - master_doc, - "OrangeVisualProgramming.tex", - "Orange Visual Programming Documentation", - "Orange Data Mining", - "manual", - ) -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ( - master_doc, - "orangevisualprogramming", - "Orange Visual Programming Documentation", - [author], - 1, - ) -] - -# If true, show URL addresses after external links. -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "OrangeVisualProgramming", - "Orange Visual Programming Documentation", - author, - "OrangeVisualProgramming", - "One line description of project.", - "Miscellaneous", - ) -] - -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# texinfo_no_detailmenu = False - - -# -- Options for Epub output ---------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = project -epub_author = author -epub_publisher = author -epub_copyright = copyright - -# The basename for the epub file. It defaults to the project name. -# epub_basename = project - -# The HTML theme for the epub output. Since the default themes are not optimized -# for small screen space, using the same theme for HTML and epub output is -# usually not wise. This defaults to 'epub', a theme designed to save visual -# space. -# epub_theme = 'epub' - -# The language of the text. It defaults to the language option -# or 'en' if the language is not set. -# epub_language = '' - -# The scheme of the identifier. Typical schemes are ISBN or URL. -# epub_scheme = '' - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# epub_identifier = '' - -# A unique identification for the text. -# epub_uid = '' - -# A tuple containing the cover image and cover page html template filenames. -# epub_cover = () - -# A sequence of (type, uri, title) tuples for the guide element of content.opf. -# epub_guide = () - -# HTML files that should be inserted before the pages created by sphinx. -# The format is a list of tuples containing the path and title. -# epub_pre_files = [] - -# HTML files shat should be inserted after the pages created by sphinx. -# The format is a list of tuples containing the path and title. -# epub_post_files = [] - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ["search.html"] - -# The depth of the table of contents in toc.ncx. -# epub_tocdepth = 3 - -# Allow duplicate toc entries. -# epub_tocdup = True - -# Choose between 'default' and 'includehidden'. -# epub_tocscope = 'default' - -# Fix unsupported image types using the Pillow. -# epub_fix_images = False - -# Scale large images. -# epub_max_image_width = 0 - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# epub_show_urls = 'inline' - -# If false, no index is generated. -# epub_use_index = True - - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"https://docs.python.org/": None} - diff --git a/doc/visual-programming/source/exporting-models/index.md b/doc/visual-programming/source/exporting-models/index.md deleted file mode 100644 index cbc7ff76137..00000000000 --- a/doc/visual-programming/source/exporting-models/index.md +++ /dev/null @@ -1,33 +0,0 @@ -# Exporting Models - -Predictive models can be saved and re-used. Models are saved in Python [pickle](https://docs.python.org/3/library/pickle.html) format. - -![](load-save-model.png) - -## Save model - -Models first require data for training. They output a trained model, which can be saved with [Save Model](../widgets/model/savemodel.md) widget in the pickle format. - -## Load model - -Models can be reused in different Orange workflows. [Load Model](../widgets/model/loadmodel.md) loads a trained model, which can be used in [Predictions](../widgets/evaluate/predictions.md) and elsewhere. - -## Load in Python - -Models can also be imported directly into Python and used in a script. - -```python -import pickle - -with open('model.pkcls', 'rb') as model: - lr = pickle.loads(model) - -lr ->> LogisticRegressionClassifier(skl_model=LogisticRegression(C=1, - class_weight=None, dual=False, - fit_intercept=True, intercept_scaling=1.0, - l1_ratio=None, max_iter=10000, - multi_class='auto', n_jobs=1, penalty='l2', - random_state=0, solver='lbfgs', tol=0.0001, - verbose=0, warm_start=False)) -``` diff --git a/doc/visual-programming/source/exporting-models/load-save-model.png b/doc/visual-programming/source/exporting-models/load-save-model.png deleted file mode 100644 index 592e1286c86..00000000000 Binary files a/doc/visual-programming/source/exporting-models/load-save-model.png and /dev/null differ diff --git a/doc/visual-programming/source/exporting-visualizations/index.md b/doc/visual-programming/source/exporting-visualizations/index.md deleted file mode 100644 index 771b89cc498..00000000000 --- a/doc/visual-programming/source/exporting-visualizations/index.md +++ /dev/null @@ -1,47 +0,0 @@ -# Exporting Visualizations - -Visualizations are an essential part of data science, and analytical reports are incomplete without them. Orange provides a couple of options for saving and modifying visualizations. - -At the bottom of each widget, there is a status bar. Visualization widgets have a Save icon (second from the left) and a Palette icon (fourth from the left). Save icon saves the plot to the computer. Palette icon opens a dialogue for modifying visualizations. - -![](statusbar-viz.png) - -## Saving a plot - -Visualizations in Orange can be saved in several formats, namely .png, .svg, .pdf, .pdf from matplotlib and as a matplotlib Python code. A common option is saving in .svg (scalable vector graphic), which you can edit with a vector graphics software such as [Inkscape](https://inkscape.org/). Ctrl+C (cmd+C) will copy a .png plot, which you can import with ctrl+V (cmd+V) into Word, PowerPoint, or other software tools. - -![](plot-format.png) - -[Matplotlib](https://matplotlib.org/) Python code is ideal for detailed editing and a high customization level. Below is an example of the Python code. It is possible to adjust the colors, size of the symbols, markers, etc. - -```python -import matplotlib.pyplot as plt -from numpy import array - -plt.clf() - -# data -x = array([1.4, 1.4, 1.3, 1.5, 1.4]) -y = array([0.2, 0.7, 0.9, 0.2, 0.1]) -# style -sizes = 13.5 -edgecolors = ['#3a9ed0ff', '#c53a27ff'] -edgecolors_index = array([0, 0, 1, 1, 1], dtype='int') -facecolors = ['#46befa80', '#ed462f80'] -facecolors_index = array([0, 0, 1, 1, 1], dtype='int') -linewidths = 1.5 -plt.scatter(x=x, y=y, s=sizes**2/4, marker='o', - facecolors=array(facecolors)[facecolors_index], - edgecolors=array(edgecolors)[edgecolors_index], - linewidths=linewidths) -plt.xlabel('petal length') -plt.ylabel('petal width') - -plt.show() -``` - -## Modifying a plot - -It is possible to modify certain parameters of a plot without digging into the code. Click on the Palette icon to open visual settings. One can change various attributes of the plot, such as fonts, font sizes, titles and so on. - -![](plot-options.png) diff --git a/doc/visual-programming/source/exporting-visualizations/plot-format.png b/doc/visual-programming/source/exporting-visualizations/plot-format.png deleted file mode 100644 index 8b18bb8b7da..00000000000 Binary files a/doc/visual-programming/source/exporting-visualizations/plot-format.png and /dev/null differ diff --git a/doc/visual-programming/source/exporting-visualizations/plot-options.png b/doc/visual-programming/source/exporting-visualizations/plot-options.png deleted file mode 100644 index 4c62c0d426d..00000000000 Binary files a/doc/visual-programming/source/exporting-visualizations/plot-options.png and /dev/null differ diff --git a/doc/visual-programming/source/exporting-visualizations/statusbar-viz.png b/doc/visual-programming/source/exporting-visualizations/statusbar-viz.png deleted file mode 100644 index c40e6ce757b..00000000000 Binary files a/doc/visual-programming/source/exporting-visualizations/statusbar-viz.png and /dev/null differ diff --git a/doc/visual-programming/source/index.rst b/doc/visual-programming/source/index.rst deleted file mode 100644 index 2b02b4ba376..00000000000 --- a/doc/visual-programming/source/index.rst +++ /dev/null @@ -1,159 +0,0 @@ -========================= -Orange Visual Programming -========================= - -Getting Started -=============== - -Here we need to copy the getting started guide. - -.. toctree:: - :maxdepth: 1 - - loading-your-data/index - building-workflows/index - exporting-models/index - exporting-visualizations/index - report/index - -Widgets -======= - -Data ----- - -.. toctree:: - :maxdepth: 1 - - widgets/data/file - widgets/data/csvfileimport - widgets/data/datasets - widgets/data/sqltable - widgets/data/save - widgets/data/datainfo - widgets/data/aggregatecolumns - widgets/data/datatable - widgets/data/selectcolumns - widgets/data/selectrows - widgets/data/datasampler - widgets/data/transpose - widgets/data/discretize - widgets/data/continuize - widgets/data/createinstance - widgets/data/createclass - widgets/data/randomize - widgets/data/concatenate - widgets/data/select-by-data-index - widgets/data/paintdata - widgets/data/pivot - widgets/data/pythonscript - widgets/data/featureconstructor - widgets/data/editdomain - widgets/data/impute - widgets/data/mergedata - widgets/data/outliers - widgets/data/preprocess - widgets/data/applydomain - widgets/data/purgedomain - widgets/data/rank - widgets/data/correlations - widgets/data/color - widgets/data/featurestatistics - widgets/data/melt - widgets/data/neighbors - widgets/data/unique - - -Visualize ---------- - -.. toctree:: - :maxdepth: 1 - - widgets/visualize/boxplot - widgets/visualize/violinplot - widgets/visualize/distributions - widgets/visualize/heatmap - widgets/visualize/scatterplot - widgets/visualize/lineplot - widgets/visualize/barplot - widgets/visualize/venndiagram - widgets/visualize/linearprojection - widgets/visualize/sievediagram - widgets/visualize/pythagoreantree - widgets/visualize/pythagoreanforest - widgets/visualize/cn2ruleviewer - widgets/visualize/mosaicdisplay - widgets/visualize/silhouetteplot - widgets/visualize/treeviewer - widgets/visualize/nomogram - widgets/visualize/freeviz - widgets/visualize/radviz - - -Model ------ - -.. toctree:: - :maxdepth: 1 - - widgets/model/constant - widgets/model/cn2ruleinduction - widgets/model/calibratedlearner - widgets/model/knn - widgets/model/tree - widgets/model/randomforest - widgets/model/gradientboosting - widgets/model/svm - widgets/model/linearregression - widgets/model/logisticregression - widgets/model/naivebayes - widgets/model/adaboost - widgets/model/neuralnetwork - widgets/model/stochasticgradient - widgets/model/stacking - widgets/model/loadmodel - widgets/model/savemodel - - -Evaluate --------- - -.. toctree:: - :maxdepth: 1 - - widgets/evaluate/calibrationplot - widgets/evaluate/confusionmatrix - widgets/evaluate/liftcurve - widgets/evaluate/predictions - widgets/evaluate/rocanalysis - widgets/evaluate/testandscore - - -.. toctree:: - :maxdepth: 1 - - -Unsupervised ------------- - -.. toctree:: - :maxdepth: 1 - - widgets/unsupervised/PCA - widgets/unsupervised/correspondenceanalysis - widgets/unsupervised/distancemap - widgets/unsupervised/distances - widgets/unsupervised/distancematrix - widgets/unsupervised/distancetransformation - widgets/unsupervised/distancefile - widgets/unsupervised/savedistancematrix - widgets/unsupervised/hierarchicalclustering - widgets/unsupervised/kmeans - widgets/unsupervised/louvainclustering - widgets/unsupervised/DBSCAN - widgets/unsupervised/mds - widgets/unsupervised/tsne - widgets/unsupervised/manifoldlearning - widgets/unsupervised/selforganizingmap - diff --git a/doc/visual-programming/source/loading-your-data/File-Google-Sheet.png b/doc/visual-programming/source/loading-your-data/File-Google-Sheet.png deleted file mode 100644 index 33ef1c13f4a..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/File-Google-Sheet.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/File-set-feature-kind.png b/doc/visual-programming/source/loading-your-data/File-set-feature-kind.png deleted file mode 100644 index b17c293a80c..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/File-set-feature-kind.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/File.png b/doc/visual-programming/source/loading-your-data/File.png deleted file mode 100644 index 74ed28e13ff..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/File.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/data-table-for-select-columns.png b/doc/visual-programming/source/loading-your-data/data-table-for-select-columns.png deleted file mode 100644 index cdcd70afef8..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/data-table-for-select-columns.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/data-table-regression1.png b/doc/visual-programming/source/loading-your-data/data-table-regression1.png deleted file mode 100644 index e6c29d6e5ea..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/data-table-regression1.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/data-table-with-class1.png b/doc/visual-programming/source/loading-your-data/data-table-with-class1.png deleted file mode 100644 index b054840ccb0..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/data-table-with-class1.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/excel-with-tab1.png b/doc/visual-programming/source/loading-your-data/excel-with-tab1.png deleted file mode 100644 index f0da3cd1ce3..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/excel-with-tab1.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/file-browse.png b/doc/visual-programming/source/loading-your-data/file-browse.png deleted file mode 100644 index efe1b766b11..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/file-browse.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/file-browser-icon.png b/doc/visual-programming/source/loading-your-data/file-browser-icon.png deleted file mode 100644 index 36d3aac5e3b..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/file-browser-icon.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/file-data-table-workflow.png b/doc/visual-programming/source/loading-your-data/file-data-table-workflow.png deleted file mode 100644 index 991c02e38fe..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/file-data-table-workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/index.md b/doc/visual-programming/source/loading-your-data/index.md deleted file mode 100644 index a98ff4fcb65..00000000000 --- a/doc/visual-programming/source/loading-your-data/index.md +++ /dev/null @@ -1,121 +0,0 @@ -# Loading your Data - -Orange comes with its [own data format](https://docs.biolab.si/3/data-mining-library/tutorial/data.html#data-input), but can also handle native Excel, comma- or tab-delimited data files. The input data set is usually a table, with data instances (samples) in rows and data attributes in columns. Attributes can be of different *types* (numeric, categorical, datetime, and text) and have assigned *roles* (input features, meta attributes, and class). Data attribute type and role can be provided in the data table header. They can also be changed in the [File](../widgets/data/file.md) widget, while data role can also be modified with [Select Columns](../widgets/data/selectcolumns.md) widget. - -### In a Nutshell - -- Orange can import any comma- or tab-delimited data file, or Excel's native files or Google Sheets document. Use [File](../widgets/data/file.md) widget to load the data and, if needed, define the class and meta attributes. -- Types and roles can be set in the File widget. -- Attribute names in the column header can be preceded with a label followed by a hash. Use c for class and m for meta attribute, i to ignore a column, w for weights column, and C, D, T, S for continuous, discrete, time, and string attribute types. Examples: C\#mph, mS\#name, i\#dummy. -- An alternative to the hash notation is Orange's native format with three header rows: the first with attribute names, the second specifying the type (**continuous**, **discrete**, **time**, or **string**), and the third proving information on the attribute role (**class**, **meta**, **weight** or **ignore**). - -## Data from Excel - -Here is an example dataset ([sample.xlsx](http://file.biolab.si/datasets/sample.xlsx)) as entered in Excel: - -![](spreadsheet1.png) - -The file contains a header row, eight data instances (rows) and seven data attributes (columns). Empty cells in the table denote missing data entries. Rows represent genes; their function (class) is provided in the first column and their name in the second. The remaining columns store measurements that characterize each gene. With this data, we could, say, develop a classifier that would predict gene function from its characteristic measurements. - -Let us start with a simple workflow that reads the data and displays it in a table: - -![](file-data-table-workflow.png) - -To load the data, open the File widget (double click on the icon of the widget), click on the file browser icon ("...") and locate the downloaded file (called [sample.xlsx](http://file.biolab.si/datasets/sample.xlsx)) on your disk: - -![](File.png) - -### File Widget: Setting the Attribute Type and Role - -The **File** widget sends the data to the **Data Table**. Double click the **Data Table** to see its contents: - -![](table-widget.png) - -Orange correctly assumed that a column with gene names is meta information, which is displayed in the **Data Table** in columns shaded with light-brown. It has not guessed that *function*, the first non-meta column in our data file, is a class column. To correct this in Orange, we can adjust attribute role in the column display of File widget (below). Double-click the *feature* label in the *function* row and select *target* instead. This will set *function* attribute as our target (class) variable. - -![](File-set-feature-kind.png) - -You can also change attribute type from nominal to numeric, from string to datetime, and so on. Naturally, data values have to suit the specified attribute type. Datetime accepts only values in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format, e.g. 2016-01-01 16:16:01. Orange would also assume the attribute is numeric if it has several different values, else it would be considered nominal. All other types are considered strings and are as such automatically categorized as meta attributes. - -Change of attribute roles and types should be confirmed by clicking the **Apply** button. - -### Select Columns: Setting the Attribute Role - -Another way to set the data role is to feed the data to the [Select Columns](../widgets/data/selectcolumns.md) widget: - -![](select-columns-schema.png) - -Opening [Select Columns](../widgets/data/selectcolumns.md) reveals Orange's classification of attributes. We would like all of our continuous attributes to be data features, gene function to be our target variable and gene names considered as meta attributes. We can obtain this by dragging the attribute names around the boxes in **Select Columns**: - -![](select-columns-start.png) - -To correctly reassign attribute types, drag attribute named *function* to a **Class** box, and attribute named *gene* to a **Meta Attribute** box. The [Select Columns](../widgets/data/selectcolumns.md) widget should now look like this: - -![](select-columns-reassigned.png) - -Change of attribute types in *Select Columns* widget should be confirmed by clicking the **Apply** button. The data from this widget is fed into [Data Table](../widgets/data/datatable.md) that now renders the data just the way we intended: - -![](data-table-with-class1.png) - -We could also define the domain for this dataset in a different way. Say, we could make the dataset ready for regression, and use *heat 0* as a continuous class variable, keep gene function and name as meta variables, and remove *heat 10* and *heat 20* from the dataset: - -![](select-columns-regression.png) - -By setting the attributes as above, the rendering of the data in the -Data Table widget gives the following output: - -![](data-table-regression1.png) - -## Header with Attribute Type Information - -Consider again the [sample.xlsx](http://file.biolab.si/datasets/sample.xlsx) dataset. This time we will augment the names of the attributes with prefixes that define attribute type (continuous, discrete, time, string) and role (class or meta attribute). Prefixes are separated from the attribute name with a hash sign ("\#"). Prefixes for attribute roles are: - -- c: class attribute -- m: meta attribute -- i: ignore the attribute -- w: instance weights - -and for the type: - -- C: Continuous -- D: Discrete -- T: Time -- S: String - -This is how the header with augmented attribute names looks like in Excel ([sample-head.xlsx](http://file.biolab.si/datasets/sample-head.xlsx)): - -![](spreadsheet-simple-head1.png) - -We can again use a **File** widget to load this dataset and then render it in the **Data Table**: - -![](select-cols-simplified-header.png) - -Notice that the attributes we have ignored (label "i" in the attribute name) are not present in the dataset. - -## Three-Row Header Format - -Orange's legacy native data format is a tab-delimited text file with three header rows. The first row lists the attribute names, the second row defines their type (continuous, discrete, time and string, or abbreviated c, d, t, and s), and the third row an optional role (class, meta, weight, or ignore). Here is an example: - -![](excel-with-tab1.png) - -Data from Google Sheets ------------------------ - -Orange can read data from Google Sheets, as long as it conforms to the data presentation rules we have presented above. In Google Sheets, copy the shareable link (Share button, then Get shareable link) and paste it in the *Data File / URL* box of the File widget. For a taste, here's one such link you can use: [http://bit.ly/1J12Tdp](http://bit.ly/1J12Tdp), and the way we have entered it in the **File** widget: - -![](File-Google-Sheet.png) - -## Data from LibreOffice - -If you are using LibreOffice, simply save your files in Excel (.xlsx) format (available from the drop-down menu under *Save As Type*). - -![](saving-tab-delimited-files.png) - -## Datetime Format - -To avoid ambiguity, Orange supports date and/or time formatted in one of the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formats. For example, the following values are all valid: - - 2016 - 2016-12-27 - 2016-12-27 14:20:51 - 16:20 diff --git a/doc/visual-programming/source/loading-your-data/sample-head.xlsx b/doc/visual-programming/source/loading-your-data/sample-head.xlsx deleted file mode 100644 index cab83c7df1f..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/sample-head.xlsx and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/sample.csv b/doc/visual-programming/source/loading-your-data/sample.csv deleted file mode 100644 index ba64e664545..00000000000 --- a/doc/visual-programming/source/loading-your-data/sample.csv +++ /dev/null @@ -1 +0,0 @@ -function,gene,spo-early,spo-mid,heat 0,heat 10,heat 20 Proteas,YDR427W,0.301,0.546,,-0.009,0.024 Proteas,YGL048C,0.208,,-0.061,-0.039,0.003 Resp,YBR039W,-0.179,-0.219,-0.097,,-0.011 Ribo,YKL180W,-0.085,-0.161,-0.061,-0.265,-0.419 Ribo,YHR021C,-0.216,-0.253,-0.228,-0.168,-0.228 Resp,YDR178W,0.017,0.07,0.058,0.286,0.205 Resp,YLL041C,0.115,,0.033,0.262,0.054 Resp,YOR065W,0.005,-0.023,-0.038,0.222,0.088 \ No newline at end of file diff --git a/doc/visual-programming/source/loading-your-data/sample.xlsx b/doc/visual-programming/source/loading-your-data/sample.xlsx deleted file mode 100644 index 598473a54d2..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/sample.xlsx and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/saving-tab-delimited-files.png b/doc/visual-programming/source/loading-your-data/saving-tab-delimited-files.png deleted file mode 100644 index 5a28fd29e8d..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/saving-tab-delimited-files.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/select-attributes-schema.png b/doc/visual-programming/source/loading-your-data/select-attributes-schema.png deleted file mode 100644 index 98a1ff3654e..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/select-attributes-schema.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/select-cols-simplified-header.png b/doc/visual-programming/source/loading-your-data/select-cols-simplified-header.png deleted file mode 100644 index c448b706493..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/select-cols-simplified-header.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/select-columns-reassigned.png b/doc/visual-programming/source/loading-your-data/select-columns-reassigned.png deleted file mode 100644 index 1fc5eddad1f..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/select-columns-reassigned.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/select-columns-regression.png b/doc/visual-programming/source/loading-your-data/select-columns-regression.png deleted file mode 100644 index f5fde3e5e1f..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/select-columns-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/select-columns-schema.png b/doc/visual-programming/source/loading-your-data/select-columns-schema.png deleted file mode 100644 index ecaeca29cb0..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/select-columns-schema.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/select-columns-start.png b/doc/visual-programming/source/loading-your-data/select-columns-start.png deleted file mode 100644 index b267e8c312d..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/select-columns-start.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/spreadsheet-simple-head1.png b/doc/visual-programming/source/loading-your-data/spreadsheet-simple-head1.png deleted file mode 100644 index e9bc80ce557..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/spreadsheet-simple-head1.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/spreadsheet1.png b/doc/visual-programming/source/loading-your-data/spreadsheet1.png deleted file mode 100644 index 53fd39dffc6..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/spreadsheet1.png and /dev/null differ diff --git a/doc/visual-programming/source/loading-your-data/table-widget.png b/doc/visual-programming/source/loading-your-data/table-widget.png deleted file mode 100644 index d56979ad62b..00000000000 Binary files a/doc/visual-programming/source/loading-your-data/table-widget.png and /dev/null differ diff --git a/doc/visual-programming/source/report/index.md b/doc/visual-programming/source/report/index.md deleted file mode 100644 index 1722ef8fab6..00000000000 --- a/doc/visual-programming/source/report/index.md +++ /dev/null @@ -1,17 +0,0 @@ -# Report - -It is possible to compile a report in Orange. We can save the report in .html, .pdf or .report format. Reports allow us to trace back analytical steps as it saves the workflow at which each report segment was created. - -Each widget has a report button in the status bar at the bottom. Pressing on the the File icon adds a new section to the report. - -![](report-button.png) - -Report can be examined with View - Show report. - -## Simple example - -We built a simple workflow with File and Scatter Plot, adding a section to the report at each step. Widgets report parameters, visualizations, and other settings. Each section includes a comment for extra explanation. - -![](report.png) - -To remove a report section, hover on the section in the list on the left. A Trash and an Orange icon will appear. The trash icon removes the section from the report list. Orange icon loads the workflow as it was at the time of creating the section. This is very handy if a colleague wishes to inspect the results. This option is available only if the report is saved in .report format. diff --git a/doc/visual-programming/source/report/report-button.png b/doc/visual-programming/source/report/report-button.png deleted file mode 100644 index c73586728f7..00000000000 Binary files a/doc/visual-programming/source/report/report-button.png and /dev/null differ diff --git a/doc/visual-programming/source/report/report.png b/doc/visual-programming/source/report/report.png deleted file mode 100644 index 20d59846e7e..00000000000 Binary files a/doc/visual-programming/source/report/report.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/aggregatecolumns.md b/doc/visual-programming/source/widgets/data/aggregatecolumns.md deleted file mode 100644 index a04cc9515c6..00000000000 --- a/doc/visual-programming/source/widgets/data/aggregatecolumns.md +++ /dev/null @@ -1,37 +0,0 @@ -Aggregate Columns -================= - -Compute a sum, max, min ... of selected columns. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: extended dataset - -**Aggregate Columns** outputs an aggregation of selected columns, for example a sum, min, max, etc. - -![](images/AggregateColumns.png) - -1. Selected attributes. -2. Operator for aggregation: - - sum - - product - - min - - max - - mean - - variance - - median -3. Set the name of the computed attribute. -4. If *Apply automatically* is ticked, changes will be communicated automatically. Alternatively, click *Apply*. - -Example -------- - -We will use iris data from the [File](../data/file.md) widget for this example and connect it to **Aggregate Columns**. - -Say we wish to compute a sum of *sepal_length* and *sepal_width* attributes. We select the two attributes from the list. - -![](images/AggregateColumns-Example.png) diff --git a/doc/visual-programming/source/widgets/data/applydomain.md b/doc/visual-programming/source/widgets/data/applydomain.md deleted file mode 100644 index c0c23a9c9a7..00000000000 --- a/doc/visual-programming/source/widgets/data/applydomain.md +++ /dev/null @@ -1,32 +0,0 @@ -Apply Domain -============ - -Given dataset and template transforms the dataset. - -**Inputs** - -- Data: input dataset -- Template Data: template for transforming the dataset - -**Outputs** - -- Transformed Data: transformed dataset - -**Apply Domain** maps new data into a transformed space. For example, if we transform some data with PCA and wish to observe new data in the same space, we can use Apply Domain to map the new data into the PCA space created from the original data. - -![](images/ApplyDomain.png) - -The widget receives a dataset and a template dataset used to transform the dataset. - -Example -------- - -We will use iris data from the [File](../data/file.md) widget for this example. To create two separate data sets, we will use [Select Rows](../data/selectrows.md) and set the condition to *iris is one of iris-setosa, iris-versicolor*. This will output a data set with a 100 rows, half of them belonging to iris-setosa class and the other half to iris-versicolor. - -We will transform the data with [PCA](../unsupervised/PCA.md) and select the first two components, which explain 96% of variance. Now, we would like to apply the same preprocessing on the 'new' data, that is the remaining 50 iris virginicas. Send the unused data from **Select Rows** to **Apply Domain**. Make sure to use the *Unmatched Data* output from **Select Rows** widget. Then add the *Transformed data* output from **PCA**. - -**Apply Domain** will apply the preprocessor to the new data and output it. To add the new data to the old data, use [Concatenate](../data/concatenate.md). Use *Transformed Data* output from **PCA** as *Primary Data* and *Transformed Data* from **Apply Domain** as *Additional Data*. - -Observe the results in a [Data Table](../data/datatable.md) or in a [Scatter Plot](../visualize/scatterplot.md) to see the new data in relation to the old one. - -![](images/ApplyDomain-Example.png) diff --git a/doc/visual-programming/source/widgets/data/color.md b/doc/visual-programming/source/widgets/data/color.md deleted file mode 100644 index c6b21ee677a..00000000000 --- a/doc/visual-programming/source/widgets/data/color.md +++ /dev/null @@ -1,46 +0,0 @@ -Color -===== - -Set color legend for variables. - -**Inputs** - -- Data: input data set - -**Outputs** - -- Data: data set with a new color legend - -The **Color** widget sets the color legend for visualizations. - -![](images/Color-stamped.png) - -1. A list of discrete variables. Set the color of each variable by double-clicking on it. The widget also enables renaming variables by clicking on their names. -2. A list of continuous variables. Click on the color strip to choose a different palette. To use the same palette for all variables, change it for one variable and click *Copy to all* that appears on the right. The widget also enables renaming variables by clicking on their names. -3. Produce a report. -4. Apply changes. If *Apply automatically* is ticked, changes will be communicated automatically. Alternatively, just click *Apply*. - -![](images/Color-Continuous_unindexed.png) - -Palettes for numeric variables are grouped and tagged by their properties. - -- Diverging palettes have two colors on its ends and a central color (white or black) in the middle. Such palettes are particularly useful when the the values can be positive or negative, as some widgets (for instance the Heat map) will put the 0 at the middle point in the palette. - -- Linear palettes are constructed so that human perception of the color change is linear with the change of the value. - -- Color blind palettes cover different types of color blindness, and can also be linear or diverging. - -- In isoluminant palettes, all colors have equal brightness. - -- Rainbow palettes are particularly nice in widgets that bin numeric values in visualizations. - -Example -------- - -We chose to work with the *heart_disease* data set. We opened the color palette and selected two new colors for diameter narrowing variable. Then we opened the [Scatter Plot](../visualize/scatterplot.md) widget and viewed the changes made to the scatter plot. - -![](images/Color-Example-Discrete.png) - -To see the effect of color palettes for numeric variables, we color the points in the scatter plot by cholesterol and change the palette for this attribute in the Color widget. - -![](images/Color-Example-Continuous.png) diff --git a/doc/visual-programming/source/widgets/data/concatenate.md b/doc/visual-programming/source/widgets/data/concatenate.md deleted file mode 100644 index d7ff191ced5..00000000000 --- a/doc/visual-programming/source/widgets/data/concatenate.md +++ /dev/null @@ -1,33 +0,0 @@ -Concatenate -=========== - -Concatenates data from multiple sources. - -**Inputs** - -- Primary Data: data set that defines the attribute set -- Additional Data: additional data set - -**Outputs** - -- Data: concatenated data - -The widget concatenates multiple sets of instances (data sets). The merge is “vertical”, in a sense that two sets of 10 and 5 instances yield a new set of 15 instances. - -![](images/Concatenate-stamped.png) - -1. Set the attribute merging method. -2. Add the identification of source data sets to the output data set. -3. Produce a report. -4. If *Apply automatically* is ticked, changes are communicated automatically. Otherwise, click *Apply*. - -If one of the tables is connected to the widget as the primary table, the resulting table will contain its own attributes. If there is no primary table, the attributes can be either a union of all attributes that appear in the tables specified as *Additional Tables*, or their intersection, that is, a list of attributes common to all the connected tables. - -Example -------- - -As shown below, the widget can be used for merging data from two separate files. Let's say we have two data sets with the same attributes, one containing instances from the first experiment and the other instances from the second experiment and we wish to join the two data tables together. We use the **Concatenate** widget to merge the data sets by attributes (appending new rows under existing attributes). - -Below, we used a modified *Zoo* data set. In the [first](http://file.biolab.si/datasets/zoo-first.tab) [File](../data/file.md) widget, we loaded only the animals beginning with the letters A and B and in the [second](http://file.biolab.si/datasets/zoo-second.tab) one only the animals beginning with the letter C. Upon concatenation, we observe the new data in the [Data Table](../data/datatable.md) widget, where we see the complete table with animals from A to C. - -![](images/Concatenate-Example.png) diff --git a/doc/visual-programming/source/widgets/data/continuize.md b/doc/visual-programming/source/widgets/data/continuize.md deleted file mode 100644 index 2097189f127..00000000000 --- a/doc/visual-programming/source/widgets/data/continuize.md +++ /dev/null @@ -1,55 +0,0 @@ -Continuize -========== - -Turns discrete variables (attributes) into numeric ("continuous") dummy variables. - -**Inputs** - -- Data: input data set - -**Outputs** - -- Data: transformed data set - -The **Continuize** widget receives a data set in the input and outputs the same data set in which the discrete variables (including binary variables) are replaced with continuous ones. - -![](images/Continuize-stamped.png) - -1. Define the treatment of non-binary categorical variables. - - Examples in this section will assume that we have a discrete attribute status with the values low, middle and high, listed in that order. Options for their transformation are: - - - **First value as base**: a N-valued categorical variable will be transformed into N-1 numeric variables, each serving as an indicator for one of the original values except for the base value. The base value is the first value in the list. By default, the values are ordered alphabetically; their order can be changed in [Edit Domain](../data/editdomain). - - In the above case, the three-valued variable *status* is transformed into two numeric variables, *status=middle* with values 0 or 1 indicating whether the original variable had value *middle* on a particular example, and similarly, *status=high*. - - - **Most frequent value as base**: similar to the above, except that the most frequent value is used as a base. So, if the most frequent value in the above example is *middle*, then *middle* is considered as the base and the two newly constructed variables are *status=low* and *status=high*. - - - **One attribute per value**: this option constructs one numeric variable per each value of the original variable. In the above case, we would get variables *status=low*, *status=middle* and *status=high*. - - - **Ignore multinomial attributes**: removes non-binary categorical variables from the data. - - - **Treat as ordinal**: converts the variable into a single numeric variable enumerating the original values. In the above case, the new variable would have the value of 0 for *low*, 1 for *middle* and 2 for *high*. Again note that the order of values can be set in [Edit Domain](../data/editdomain). - - - **Divide by number of values**: same as above, except that values are normalized into range 0-1. In our example, the values of the new variable would be 0, 0.5 and 1. - -2. Define the treatment of continuous attributes. Besised the option to *Leave them as they are*, we can *Normalize by span*, which will subtract the lowest value found in the data and divide by the span, so all values will fit into [0, 1]. Option *Normalize by standard deviation* subtracts the average and divides by the standard deviation. - -3. Define the treatment of class attributes (outcomes, targets). Besides leaving it as it is, the available options mirror those for multinomial attributes, except for those that would split the outcome into multiple outcome variables. - -4. This option defines the ranges of new variables. In the above text, we supposed the range *from 0 to 1*. - -5. Produce a report. - -6. If *Apply automatically* is ticked, changes are committed automatically. Otherwise, you have to press *Apply* after each change. - -Examples --------- - -First, let's see what is the output of the **Continuize** widget. We feed the original data (the *Heart disease* data set) into the [Data Table](../data/datatable) and see how they look like. Then we continuize the discrete values and observe them in another [Data Table](../data/datatable). - -![](images/Continuize-Example1.png) - -In the second example, we show a typical use of this widget - in order to properly plot the linear projection of the data, discrete attributes need to be converted to continuous ones and that is why we put the data through the **Continuize** widget before drawing it. The attribute "*chest pain*" originally had four values and was transformed into three continuous attributes; similar happened to gender, which was transformed into a single attribute "*gender=female*". - -![](images/Continuize-Example2.png) diff --git a/doc/visual-programming/source/widgets/data/correlations.md b/doc/visual-programming/source/widgets/data/correlations.md deleted file mode 100644 index 18263b7eeec..00000000000 --- a/doc/visual-programming/source/widgets/data/correlations.md +++ /dev/null @@ -1,36 +0,0 @@ -Correlations -============ - -Compute all pairwise attribute correlations. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: input dataset -- Features: selected pair of features -- Correlations: data table with correlation scores - -**Correlations** computes Pearson or Spearman correlation scores for all pairs of features in a dataset. These methods can only detect monotonic relationship. - -![](images/Correlations-stamped.png) - -1. Correlation measure: - - Pairwise [Pearson](https://en.wikipedia.org/wiki/Pearson_correlation_coefficient) correlation. - - Pairwise [Spearman](https://en.wikipedia.org/wiki/Spearman%27s_rank_correlation_coefficient) correlation. -2. Filter for finding attribute pairs. -3. A list of attribute pairs with correlation coefficient. Press *Finished* to stop computation for large datasets. -4. Access widget help and produce report. - -Example -------- - -Correlations can be computed only for numeric (continuous) features, so we will use *housing* as an example data set. Load it in the [File](file.md) widget and connect it to **Correlations**. Positively correlated feature pairs will be at the top of the list and negatively correlated will be at the bottom. - -![](images/Correlations-links.png) - -Go to the most negatively correlated pair, DIS-NOX. Now connect [Scatter Plot](../visualize/scatterplot.md) to **Correlations** and set two outputs, Data to Data and Features to Features. Observe how the feature pair is immediately set in the scatter plot. Looks like the two features are indeed negatively correlated. - -![](images/Correlations-Example.png) diff --git a/doc/visual-programming/source/widgets/data/createclass.md b/doc/visual-programming/source/widgets/data/createclass.md deleted file mode 100644 index 4bce32ab9c8..00000000000 --- a/doc/visual-programming/source/widgets/data/createclass.md +++ /dev/null @@ -1,38 +0,0 @@ -Create Class -============ - -Create class attribute from a string attribute. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: dataset with a new class variable - -**Create Class** creates a new class attribute from an existing discrete or string attribute. The widget matches the string value of the selected attribute and constructs a new user-defined value for matching instances. - -![](images/CreateClass-stamped.png) - -1. The attribute the new class is constructed from. -2. Matching: - - Name: the name of the new class value - - Substring: regex-defined substring that will match the values from the above-defined attribute - - Instances: the number of instances matching the substring - - Press '+' to add a new class value -3. Name of the new class column. -4. Match only at the beginning will begin matching from the beginning of the string. Case sensitive will match by case, too. -5. Produce a report. -6. Press *Apply* to commit the results. - -Example -------- - -Here is a simple example with the *auto-mpg* dataset. Pass the data to **Create Class**. Select *car_name* as a column to create the new class from. Here, we wish to create new values that match the car brand. First, we type *ford* as the new value for the matching strings. Then we define the substring that will match the data instances. This means that all instances containing *ford* in their *car_name*, will now have a value *ford* in the new class column. Next, we define the same for *honda* and *fiat*. The widget will tell us how many instance are yet unmatched (remaining instances). We will name them *other*, but you can continue creating new values by adding a condition with '+'. - -We named our new class column *car_brand* and we matched at the beginning of the string. - -![](images/CreateClass-example.png) - -Finally, we can observe the new column in a [Data Table](../data/datatable.md) or use the value as color in the [Scatter Plot](../visualize/scatterplot.md). diff --git a/doc/visual-programming/source/widgets/data/createinstance.md b/doc/visual-programming/source/widgets/data/createinstance.md deleted file mode 100644 index 16725371f2a..00000000000 --- a/doc/visual-programming/source/widgets/data/createinstance.md +++ /dev/null @@ -1,44 +0,0 @@ -Create Instance -=============== - -Interactively creates an instance from a sample dataset. - -**Inputs** - -- Data: input dataset -- Reference: refrence dataset - -**Outputs** - -- Data: input dataset appended the created instance - -The **Create Instance** widget creates a new instance, based on the input data. The widget displays all variables of the input dataset in a table of two columns. The column *Variable* represents the variable's name, meanwhile the column *Value* enables setting the variable's value. Each value is initially set to median value of the variable. The values can be manually set to *Median*, *Mean*, *Random* or *Input* by clicking the corresponding button. For easier searching through the variables, the table has filter attached. When clicking upon one of the mentioned buttons, only filtered variables are considered. One can also set the value by right-clicking a row and selecting an option in a context menu. - -![](images/CreateInstance-stamped.png) - -1. Filter table by variable name. -2. The column represents a variable's name and type. The table can be sorted by clicking the columns header. -3. Provides controls for value editing. -4. Set filtered variables' values to: - - *Median*: median value of variable in the input dataset - - *Mean*: mean value of variable in the input dataset - - *Random*: random value in a range of variable in the input dataset - - *Input*: median value of variable in the reference dataset -5. If *Append this instance to input data* is ticked, the created instance is appended to the input dataset. Otherwise, a single instance appears on the output. To distinguish between created and original data, *Source ID* variable is added. -5. If *Apply automatically* is ticked, changes are committed automatically. Otherwise, you have to press *Apply* after each change. -6. Produce a report. -7. Information on input and reference dataset. -8. Information on output dataset. - -Example -------- - -The **Create Instance** is usually used to examine a model performance on some arbitrary data. The basic usage is shown in the following workflow, where a (*Housing*) dataset is used to fit a [Linear Regression](../model/linearregression.md) model, which is than used to [predict](../evaluate/predictions.md) a target value for data, created by the *Create Instance* widget. Inserting a [Rank](../data/rank.md) widget between [File](../data/file.md) and *Create Instance* enables outputting (and therefore making predictions on) the most important features. -A [Select Column](../data/selectcolumns.md) widget is inserted to omit the actual target value. - -![](images/CreateInstance-example.png) - -The next example shows how to check whether the created instance is some kind of outlier. The creates instance is feed to [PCA](../unsupervised/PCA.md) whose first and second componens are then examined in a [Scatter Plot](../visualize/scatterplot.md). The created instance is colored red in the plot and it could be considered as an outlier if it appears far from the original data (blue). - -![](images/CreateInstance-example2.png) - diff --git a/doc/visual-programming/source/widgets/data/csvfileimport.md b/doc/visual-programming/source/widgets/data/csvfileimport.md deleted file mode 100644 index f9564eab947..00000000000 --- a/doc/visual-programming/source/widgets/data/csvfileimport.md +++ /dev/null @@ -1,65 +0,0 @@ -CSV File Import -=============== - -Import a data table from a CSV formatted file. - -**Outputs** - -- Data: dataset from the .csv file -- Data Frame: pandas DataFrame object - -The **CSV File Import** widget reads comma-separated files and sends the dataset to its output channel. File separators can be commas, semicolons, spaces, tabs or manually-defined delimiters. The history of most recently opened files is maintained in the widget. - -*Data Frame* output can be used in the [Python Script](../data/pythonscript.md) widget by connecting it to the `in_object` input (e.g. `df = in_object`). Then it can be used a regular DataFrame. - -### Import Options - -The import window where the user sets the import parameters. Can be re-opened by pressing *Import Options* in the widget. - -Right click on the column name to set the column type. Right click on the row index (on the left) to mark a row as a header, skipped or a normal data row. - -![](images/CSVFileImport-ImportOptions-stamped.png) - -1. File encoding. Default is UTF-8. See Encoding subchapter for details. -2. Import settings: - - *Cell delimiter*: - - Tab - - Comma - - Semicolon - - Space - - Other (set the delimiter in the field to the right) - - *Quote character*: either " or '. Defines what is considered a text. - - *Number separators*: - - Grouping: delimiters for thousands, e.g. 1,000 - - Decimal: delimiters for decimals, e.g. 1.234 -3. Column type: select the column in the preview and set its type. Column type can be set also by right-clicking on the selected column. - - *Auto*: Orange will automatically try to determine column type. (default) - - *Numeric*: for continuous data types, e.g. (1.23, 1.32, 1.42, 1.32) - - *Categorical*: for discrete data types, e.g. (brown, green, blue) - - *Text*: for string data types, e.g. (John, Olivia, Mike, Jane) - - *Datetime*: for time variables, e.g. (1970-01-01) - - *Ignore*: do not output the column. -4. Pressing *Reset* will return the settings to the previously set state (saved by pressing OK in the Import Options dialogue). *Restore Defaults* will set the settings to their default values. *Cancel* aborts the import, while *OK* imports the data and saves the settings. - -### Widget - -The widget once the data is successfully imported. - -![](images/CSVFileImport-widget-stamped.png) - -1. The folder icon opens the dialogue for import the local .csv file. It can be used to either load the first file or change the existing file (load new data). The *File* dropdown stores paths to previously loaded data sets. -2. Information on the imported data set. Reports on the number of instances (rows), variables (features or columns) and meta variables (special columns). -3. *Import Options* re-opens the import dialogue where the user can set delimiters, encodings, text fields and so on. *Cancel* aborts data import. *Reload* imports the file once again, adding to the data any changes made in the original file. - -### Encoding - -The dialogue for settings custom encodings list in the Import Options - Encoding dropdown. Select *Customize Encodings List...* to change which encodings appear in the list. To save the changes, simply close the dialogue. Closing and reopening Orange (even with Reset widget settings) will not re-set the list. To do this, press *Restore Defaults*. To have all the available encodings in the list, press *Select all*. - -![](images/CSVFileImport-encodings.png) - -Example -------- - -**CSV File Import** works almost exactly like the [File](../data/file.md) widget, with the added options for importing different types of .csv files. In this workflow, the widget read the data from the file and sends it to the [Data Table](../data/datatable.md) for inspection. - -![](images/CSVFileImport-Example.png) diff --git a/doc/visual-programming/source/widgets/data/datainfo.md b/doc/visual-programming/source/widgets/data/datainfo.md deleted file mode 100644 index 669cbcab82c..00000000000 --- a/doc/visual-programming/source/widgets/data/datainfo.md +++ /dev/null @@ -1,27 +0,0 @@ -Data Info -========= - -Displays information on a selected dataset. - -**Inputs** - -- Data: input dataset - -A simple widget that presents information on dataset size, features, -targets, meta attributes, and location. - -![](images/data-info-stamped.png) - -1. Information on dataset size -2. Information on discrete and continuous features -3. Information on targets -4. Information on meta attributes -5. Information on where the data is stored -6. Produce a report. - -Example -------- - -Below, we compare the basic statistics of two **Data Info** widgets - one with information on the entire dataset and the other with information on the (manually) selected subset from the [Scatter Plot](../visualize/scatterplot.md) widget. We used the *Iris* dataset. - -![](images/DataInfo-Example.png) diff --git a/doc/visual-programming/source/widgets/data/datasampler.md b/doc/visual-programming/source/widgets/data/datasampler.md deleted file mode 100644 index 533ba5f8ada..00000000000 --- a/doc/visual-programming/source/widgets/data/datasampler.md +++ /dev/null @@ -1,54 +0,0 @@ -Data Sampler -============ - -Selects a subset of data instances from an input dataset. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data Sample: sampled data instances -- Remaining Data: out-of-sample data - -The **Data Sampler** widget implements several data sampling methods. It outputs a sampled and a complementary dataset (with instances from the input set that are not included in the sampled dataset). The output is processed after the input dataset is provided and *Sample Data* is pressed. - -![](images/DataSampler-stamped.png) - -1. Information on the input and output dataset. -2. The desired sampling method: - - **Fixed proportion of data** returns a selected percentage of the entire data (e.g. 70% of all the data) - - **Fixed sample size** returns a selected number of data instances with a chance to set *Sample with replacement*, which always samples from the entire dataset (does not subtract instances already in the subset). With replacement, you can generate more instances than available in the input dataset. - - [Cross Validation](https://en.wikipedia.org/wiki/Cross-validation_(statistics)) partitions data instances into the specified number of complementary subsets. Following a typical validation schema, all subsets except the one selected by the user are output as Data Sample, and the selected subset goes to Remaining Data. (Note: In older versions, the outputs were swapped. If the widget is loaded from an older workflow, it switches to compatibility mode.) - - [Bootstrap](https://en.wikipedia.org/wiki/Bootstrapping_(statistics)) infers the sample from the population statistic. -3. *Replicable sampling* maintains sampling patterns that can be carried - across users, while *stratify sample* mimics the composition of the - input dataset. -4. Press *Sample Data* to output the data sample. - -If all data instances are selected (by setting the proportion to 100 % or setting the fixed sample size to the entire data size), output instances are still shuffled. - -Examples --------- - -First, let's see how the **Data Sampler** works. We will use the *iris* data from the [File](../data/file.md) widget. We see there are 150 instances in the data. We sampled the data with the **Data Sampler** widget and we chose to go with a fixed sample size of 5 instances for simplicity. We can observe the sampled data in the [Data Table](../data/datatable.md) widget (Data Table (in-sample)). The second [Data Table](../data/datatable.md) (Data Table (out-of-sample)) shows the remaining 145 instances that weren't in the sample. To output the out-of-sample data, double-click the connection between the widgets and rewire the output to *Remaining Data --> Data*. - -![](images/DataSampler-Example1.png) - -Now, we will use the **Data Sampler** to split the data into training and testing part. We are using the *iris* data, which we loaded with the [File](../data/file.md) widget. In **Data Sampler**, we split the data with *Fixed proportion of data*, keeping 70% of data instances in the sample. - -Then we connected two outputs to the [Test & Score](../evaluate/testandscore.md) widget, *Data Sample --> Data* and *Remaining Data --> Test Data*. Finally, we added [Logistic Regression](../model/logisticregression.md) as the learner. This runs logistic regression on the Data input and evaluates the results on the Test Data. - -![](images/DataSampler-Example2.png) - -Over/Undersampling ------------------- - -**Data Sampler** can also be used to oversample a minority class or undersample majority class in the data. Let us show an example for oversampling. First, separate the minority class using a [Select Rows](../data/selectrows.md) widget. We are using the *iris* data from the [File](../data/file.md) widget. The data set has 150 data instances, 50 of each class. Let us oversample, say, *iris-setosa*. - -In **Select Rows**, set the condition to *iris is iris-setosa*. This will output 50 instances of the *iris-setosa* class. Now, connect *Matching Data* into the **Data Sampler**, select *Fixed sample size*, set it to, say, 100 and select *Sample with replacement*. Upon pressing *Sample Data*, the widget will output 100 instances of *iris-setosa* class, some of which will be duplicated (because we used *Sample with replacement*). - -Finally, use [Concatenate](../data/concatenate) to join the oversampled instances and the *Unmatched Data* output of the **Select Rows** widget. This outputs a data set with 200 instances. We can observe the final results in the [Distributions](../visualize/distributions). - -![](images/DataSampler-Example-OverUnderSampling.png) diff --git a/doc/visual-programming/source/widgets/data/datasets.md b/doc/visual-programming/source/widgets/data/datasets.md deleted file mode 100644 index 9943af26d30..00000000000 --- a/doc/visual-programming/source/widgets/data/datasets.md +++ /dev/null @@ -1,24 +0,0 @@ -Datasets -======== - -Load a dataset from an online repository. - -**Outputs** - -- Data: output dataset - -**Datasets** widget retrieves selected dataset from the server and sends it to the output. File is downloaded to the local memory and thus instantly available even without the internet connection. Each dataset is provided with a description and information on the data size, number of instances, number of variables, target and tags. - -![](images/Datasets-stamped.png) - -1. Information on the number of datasets available and the number of them downloaded to the local memory. -2. Content of available datasets. Each dataset is described with the size, number of instances and variables, type of the target variable and tags. -3. Formal description of the selected dataset. -4. If *Send Data Automatically* is ticked, selected dataset is communicated automatically. Alternatively, press *Send Data*. - -Example -------- - -Orange workflows can start with **Datasets** widget instead of **File** widget. In the example below, the widget retrieves a dataset from an online repository (Kickstarter data), which is subsequently sent to both the [Data Table](../data/datatable) and the [Distributions](../visualize/distributions). - -![](images/Datasets-Workflow.png) diff --git a/doc/visual-programming/source/widgets/data/datatable.md b/doc/visual-programming/source/widgets/data/datatable.md deleted file mode 100644 index 7baca5e96aa..00000000000 --- a/doc/visual-programming/source/widgets/data/datatable.md +++ /dev/null @@ -1,41 +0,0 @@ -Data Table -========== - -Displays attribute-value data in a spreadsheet. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Selected Data: instances selected from the table - -The **Data Table** widget receives one or more datasets in its input and presents them as a spreadsheet. Data instances may be sorted by attribute values. The widget also supports manual selection of data instances. - -![](images/DataTable-stamped.png) - -1. The name of the dataset (usually the input data file). Data - instances are in rows and their attribute values in columns. In this - example, the dataset is sorted by the attribute "sepal length". -2. Info on current dataset size and number and types of attributes -3. Values of continuous attributes can be visualized with bars; colors - can be attributed to different classes. -4. Data instances (rows) can be selected and sent to the widget's output - channel. -5. Use the *Restore Original Order* button to reorder data instances after - attribute-based sorting. -6. Produce a report. -7. While auto-send is on, all changes will be automatically communicated - to other widgets. Otherwise, press *Send Selected Rows*. - -Example -------- - -We used two [File](../data/file.md) widgets to read the *Iris* and *Glass* dataset (provided in Orange distribution), and send them to the **Data Table** widget. - -![](images/DataTable-Schema.png) - -Selected data instances in the first **Data Table** are passed to the second **Data Table**. Notice that we can select which dataset to view (iris or glass). Changing from one dataset to another alters the communicated selection of data instances if *Commit on any change* is selected. - -![](images/DataTable-Example.png) diff --git a/doc/visual-programming/source/widgets/data/discretize.md b/doc/visual-programming/source/widgets/data/discretize.md deleted file mode 100644 index a019c7c046a..00000000000 --- a/doc/visual-programming/source/widgets/data/discretize.md +++ /dev/null @@ -1,34 +0,0 @@ -Discretize -========== - -Discretizes continuous attributes from an input dataset. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: dataset with discretized values - -The **Discretize** widget [discretizes](https://en.wikipedia.org/wiki/Discretization) continuous attributes with a selected method. - -![](images/Discretize-All-stamped.png) - -1. The basic version of the widget is rather simple. It allows choosing between three different discretizations. - - [Entropy-MDL](http://ijcai.org/Past%20Proceedings/IJCAI-93-VOL2/PDF/022.pdf), invented by Fayyad and Irani is a top-down discretization, which recursively splits the attribute at a cut maximizing information gain, until the gain is lower than the minimal description length of the cut. This discretization can result in an arbitrary number of intervals, including a single interval, in which case the attribute is discarded as useless (removed). - - [Equal-frequency](http://www.saedsayad.com/unsupervised_binning.htm) splits the attribute into a given number of intervals, so that they each contain approximately the same number of instances. - - [Equal-width](https://en.wikipedia.org/wiki/Data_binning) evenly splits the range between the smallest and the largest observed value. The *Number of intervals* can be set manually. - - The widget can also be set to leave the attributes continuous or to remove them. -2. To treat attributes individually, go to **Individual Attribute Settings**. They show a specific discretization of each attribute and allow changes. First, the top left list shows the cut-off points for each attribute. In the snapshot, we used the entropy-MDL discretization, which determines the optimal number of intervals automatically; we can see it discretized the age into seven intervals with cut-offs at 21.50, 23.50, 27.50, 35.50, 43.50, 54.50 and 61.50, respectively, while the capital-gain got split into many intervals with several cut-offs. The final weight (fnlwgt), for instance, was left with a single interval and thus removed. -On the right, we can select a specific discretization method for each attribute. Attribute *“fnlwgt”* would be removed by the MDL-based discretization, so to prevent its removal, we select the attribute and choose, for instance, **Equal-frequency discretization**. We could also choose to leave the attribute continuous. -3. Produce a report. -4. Tick *Apply automatically* for the widget to automatically commit changes. Alternatively, press *Apply*. - -Example -------- - -In the schema below, we show the *Iris* dataset with continuous attributes -(as in the original data file) and with discretized attributes. - -![](images/Discretize-Example.png) diff --git a/doc/visual-programming/source/widgets/data/editdomain.md b/doc/visual-programming/source/widgets/data/editdomain.md deleted file mode 100644 index 7f501ddb16c..00000000000 --- a/doc/visual-programming/source/widgets/data/editdomain.md +++ /dev/null @@ -1,44 +0,0 @@ -Edit Domain -=========== - -Rename features and their values. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: dataset with edited domain - -This widget can be used to edit/change a dataset's domain - rename features, rename or merge values of categorical features, add a categorical value, and assign labels. - -![](images/EditDomain-stamped.png) - -1. All features (including meta attributes) from the input dataset are listed in the *Variables* list. Selecting one feature displays an editor on the right. -2. Editing options: - - Change the name of the feature. - - Change the type of the feature. For example, convert a string variable to categorical. - - *Unlink variable from its source variable*. This option removes existing computation for a variable (say for Cluster how clustering was computed), making it 'plain'. This enables merging variables with same names in [Merge Data](../data/mergedata.md). - - Change the value names for discrete features in the *Values* list box. Double-click to edit the name. - - Add, remove or edit additional feature annotations in the *Labels* box. Add a new label with the + button and add the *Key* and *Value* for the new entry. Key will be displayed in the top left corner of the [Data Table](../data/datatable.md), while values will appear below the specified column. Remove an existing label with the - button. -3. Reorder or merge values of categorical features. To reorder the values (for example, to display them in [Distributions](../visualize/distributions.md), use the up and down keys at the bottom of the box. To add or remove a value, use + and - buttons. Select two or more variables and click = to merge them into a single value. Use the M button to merge variables on condition. -4. Rename the output table. Useful for displaying table names in [Venn Diagram](../visualize/venndiagram.md). -5. To revert the changes made to the selected feature, press the *Reset Selected* button while the feature is selected in the *Variables* list. Pressing *Reset All* will remove all the changes to the domain. Press *Apply* to send the new domain to the output. - -**Merging options** - -![](images/EditDomain-merge.png) - -- *Group selected values*: selected cateogorical values become a single variable. -- *Group values with less than N occurrences*: values which appear less than N times in the data, will be grouped into a single value. -- *Group values with less than % occurrences*: values which appear less then X % of the time in the data, will be grouped into a single value. -- *Group all except N most frequent values*: all values but the N most frequent will be grouped into a single variable. -- *New value name*: the name of the grouped value. - -Example -------- - -Below, we demonstrate how to simply edit an existing domain. We selected the *heart_disease.tab* dataset and edited the *gender* attribute. Where in the original we had the values *female* and *male*, we changed it into *F* for female and *M* for male. Then we used the down key to switch the order of the variables. Finally, we added a label to mark that the attribute is binary. We can observe the edited data in the [Data Table](../data/datatable.md) widget. - -![](images/EditDomain-Example.png) diff --git a/doc/visual-programming/source/widgets/data/featureconstructor.md b/doc/visual-programming/source/widgets/data/featureconstructor.md deleted file mode 100644 index c4510f8943e..00000000000 --- a/doc/visual-programming/source/widgets/data/featureconstructor.md +++ /dev/null @@ -1,67 +0,0 @@ -Feature Constructor -=================== - -Add new features to your dataset. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: dataset with additional features - -The **Feature Constructor** allows you to manually add features (columns) into your dataset. The new feature can be a computation of an existing one or a combination of several (addition, subtraction, etc.). You can choose what type of feature it will be (discrete, continuous or string) and what its parameters are (name, value, expression). For continuous variables you only have to construct an expression in Python. - -![](images/feature-constructor1-stamped.png) - -1. List of constructed variables -2. Add or remove variables -3. New feature name -4. Expression in Python -5. Select a feature -6. Select a function -7. Produce a report -8. Press *Send* to communicate changes - -For discrete variables, however, there's a bit more work. First add or remove the values you want for the new feature. Then select the base value and the expression. In the example below, we have constructed an expression with 'if lower than' and defined three conditions; the program ascribes 0 (which we renamed to lower) if the original value is lower than 6, 1 (mid) if it is lower than 7 and 2 (higher) for all the other values. Notice that we use an underscore for the feature name (e.g. petal\_length). - -![](images/feature-constructor2-stamped.png) - -1. List of variable definitions -2. Add or remove variables -3. New feature name -4. Expression in Python -5. Select a feature -6. Select a function -7. Assign values -8. Produce a report -9. Press *Send* to communicate changes - -Example -------- - -With the **Feature Constructor** you can easily adjust or combine existing features into new ones. Below, we added one new discrete feature to the *Titanic* dataset. We created a new attribute called *Financial status* and set the values to be *rich* if the person belongs to the first class (status = first) and *not rich* for everybody else. We can see the new dataset with [Data Table](../data/datatable.md) widget. - -![](images/FeatureConstructor-Example.png) - -Hints ------ - -If you are unfamiliar with Python math language, here's a quick introduction. - -- +, - to add, subtract -- \* to multiply -- / to divide -- % to divide and return the remainder -- \*\* for exponent (for square root square by 0.5) -- // for floor division -- <, >, <=, >= less than, greater than, less or equal, greater or equal -- == for equal -- != for not equal - -As in the example: (*value*) if (*feature name*) < (*value*), else (*value*) if (*feature name*) < (*value*), else (*value*) - -[Use value 1 if feature is less than specified value, else use value 2 if feature is less than specified value 2, else use value 3.] - -See more [here](http://www.tutorialspoint.com/python/python_basic_operators.htm). diff --git a/doc/visual-programming/source/widgets/data/featurestatistics.md b/doc/visual-programming/source/widgets/data/featurestatistics.md deleted file mode 100644 index 97acb04b937..00000000000 --- a/doc/visual-programming/source/widgets/data/featurestatistics.md +++ /dev/null @@ -1,47 +0,0 @@ -Feature Statistics -================== - -Show basic statistics for data features. - -**Inputs** - -- Data: input data - -**Outputs** - -- Reduced data: table containing only selected features -- Statistics: table containing statistics of the selected features - -The **Feature Statistics** widget provides a quick way to inspect and find interesting features in a given data set. - -![](images/feature_statistics-stamped.png) - -The Feature Statistics widget on the *heart-disease* data set. The feature *exerc ind ang* was manually changed to a meta variable for illustration purposes. - -1. Info on the current data set size and number and types of features -2. The histograms on the right can be colored by any feature. If the selected feature is categorical, a discrete color palette is used (as shown in the example). If the selected feature is numerical, a continuous color palette is used. The table on the right contains statistics about each feature in the data set. The features can be sorted by each statistic, which we now describe. -3. The feature type - can be one of categorical, numeric, time and string. -4. The name of the feature. -5. A histogram of feature values. If the feature is numeric, we appropriately discretize the values into bins. If the feature is categorical, each value is assigned its own bar in the histogram. -6. The central tendency of the feature values. For categorical features, this is the [mode](https://en.wikipedia.org/wiki/Mode_(statistics)). For numeric features, this is [mean](https://en.wikipedia.org/wiki/Mean) value. -7. The dispersion of the feature values. For categorical features, this is the [entropy](https://en.wikipedia.org/wiki/Entropy_(information_theory)) of the value distribution. For numeric features, this is the [coefficient of variation](https://en.wikipedia.org/wiki/Coefficient_of_variation). -8. The minimum value. This is computed for numerical and ordinal categorical features. -9. The maximum value. This is computed for numerical and ordinal categorical features. -10. The number of missing values in the data. - -Notice also that some rows are colored differently. White rows indicate regular features, gray rows indicate class variables and the lighter gray indicates meta variables. - -Example -------- - -The Feature Statistics widget is most often used after the [File](../data/file.md) widget to inspect and find potentially interesting features in the given data set. In the following examples, we use the *heart-disease* data set. - -![](images/feature_statistics_workflow.png) - -Once we have found a subset of potentially interesting features, or we have found features that we would like to exclude, we can simply select the features we want to keep. The widget outputs a new data set with only these features. - -![](images/feature_statistics_example1.png) - -Alternatively, if we want to store feature statistics, we can use the *Statistics* output and manipulate those values as needed. In this example, we simply select all the features and display the statistics in a table. - -![](images/feature_statistics_example2.png) diff --git a/doc/visual-programming/source/widgets/data/file.md b/doc/visual-programming/source/widgets/data/file.md deleted file mode 100644 index 1bf9dbc4392..00000000000 --- a/doc/visual-programming/source/widgets/data/file.md +++ /dev/null @@ -1,53 +0,0 @@ - -File -==== - -Reads attribute-value data from an input file. - -**Outputs** - -- Data: dataset from the file - -The **File** widget [reads the input data file](../../loading-your-data/index.md) (data table with data instances) and sends the dataset to its output channel. The history of most recently opened files is maintained in the widget. The widget also includes a directory with sample datasets that come pre-installed with Orange. - -The widget reads data from Excel (**.xlsx**), simple tab-delimited (**.txt**), comma-separated files (**.csv**) or URLs. For other formats see Other Formats section below. - -![](images/File-stamped.png) - -1. Browse through previously opened data files, or load any of the sample ones. -2. Browse for a data file. -3. Reloads currently selected data file. -4. Insert data from URL addresses, including data from Google Sheets. -5. Information on the loaded dataset: dataset size, number and types of data features. -6. Additional information on the features in the dataset. Features can be edited by double-clicking on them. The user can change the attribute names, select the type of variable per each attribute (*Continuous*, *Nominal*, *String*, *Datetime*), and choose how to further define the attributes (as *Features*, *Targets* or *Meta*). The user can also decide to ignore an attribute. -7. Browse documentation datasets. -8. Produce a report. - -Example -------- - -Most Orange workflows would probably start with the **File** widget. In the schema below, the widget is used to read the data that is sent to both the [Data Table](../data/datatable.md) and the [Box Plot](../visualize/boxplot.md) widget. - -![](images/File-Workflow.png) - -### Loading your data - -- Orange can import any comma, .xlsx or tab-delimited data file or URL. Use the **File** widget and then, if needed, select class and meta attributes. -- To specify the domain and the type of the attribute, attribute names can be preceded with a label followed by a hash. Use c for class and m for meta attribute, i to ignore a column, and C, D, S for continuous, discrete and string attribute types. Examples: C#mpg, mS#name, i#dummy. -- Orange's native format is a tab-delimited text file with three header rows. The first row contains attribute names, the second the type (*continuous*, *discrete* or *string*), and the third the optional element (*class*, *meta* or *time*). - -![](images/spreadsheet-simple-head1.png) - -Read more on loading your data [here](../../loading-your-data/index.md). - -### Other Formats - -Supported formats and the widgets to load them: - -- distance matrix: [Distance File](../unsupervised/distancefile.md) -- predictive model: [Load Model](../model/loadmodel.md) -- network: Network File from Network add-on -- images: Import Images from Image Analytics add-on -- text/corpus: Corpus or Import Documents from Text add-on -- single cell data: Load Data from Single Cell add-on -- several spectroscopy files: Multifile from Spectroscopy add-on diff --git a/doc/visual-programming/source/widgets/data/icons/color.png b/doc/visual-programming/source/widgets/data/icons/color.png deleted file mode 100644 index 192f39716eb..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/color.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/concatenate.png b/doc/visual-programming/source/widgets/data/icons/concatenate.png deleted file mode 100644 index f24562ed627..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/concatenate.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/continuize.png b/doc/visual-programming/source/widgets/data/icons/continuize.png deleted file mode 100644 index ffc3437818a..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/continuize.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/correlations.png b/doc/visual-programming/source/widgets/data/icons/correlations.png deleted file mode 100644 index 2c54c163ea4..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/correlations.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/create-class.png b/doc/visual-programming/source/widgets/data/icons/create-class.png deleted file mode 100755 index f2972f7eb03..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/create-class.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/csvfileimport.png b/doc/visual-programming/source/widgets/data/icons/csvfileimport.png deleted file mode 100644 index 4ed280ef10b..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/csvfileimport.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/data-info.png b/doc/visual-programming/source/widgets/data/icons/data-info.png deleted file mode 100644 index 826df8baedd..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/data-info.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/data-sampler.png b/doc/visual-programming/source/widgets/data/icons/data-sampler.png deleted file mode 100644 index 7a4f7bf1dc5..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/data-sampler.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/data-table.png b/doc/visual-programming/source/widgets/data/icons/data-table.png deleted file mode 100644 index e3bbdcc63a8..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/data-table.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/datasets.png b/doc/visual-programming/source/widgets/data/icons/datasets.png deleted file mode 100755 index f093bce6ba3..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/datasets.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/discretize.png b/doc/visual-programming/source/widgets/data/icons/discretize.png deleted file mode 100644 index 3a9ac1b6bb2..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/discretize.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/edit-domain.png b/doc/visual-programming/source/widgets/data/icons/edit-domain.png deleted file mode 100644 index e8a4a2f1701..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/edit-domain.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/feature-constructor.png b/doc/visual-programming/source/widgets/data/icons/feature-constructor.png deleted file mode 100644 index 5d1770d544f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/feature-constructor.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/file.png b/doc/visual-programming/source/widgets/data/icons/file.png deleted file mode 100644 index 269ff71b388..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/file.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/impute.png b/doc/visual-programming/source/widgets/data/icons/impute.png deleted file mode 100644 index 483acb25bcd..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/impute.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/merge-data.png b/doc/visual-programming/source/widgets/data/icons/merge-data.png deleted file mode 100644 index ef386f8f47d..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/merge-data.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/neighbors.png b/doc/visual-programming/source/widgets/data/icons/neighbors.png deleted file mode 100644 index 69d1ab1d0ba..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/neighbors.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/outliers.png b/doc/visual-programming/source/widgets/data/icons/outliers.png deleted file mode 100644 index 864355c7a6e..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/outliers.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/paint-data.png b/doc/visual-programming/source/widgets/data/icons/paint-data.png deleted file mode 100644 index f50ab440806..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/paint-data.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/pivot.png b/doc/visual-programming/source/widgets/data/icons/pivot.png deleted file mode 100644 index 3249deee4f1..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/pivot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/preprocess.png b/doc/visual-programming/source/widgets/data/icons/preprocess.png deleted file mode 100644 index da4355ba09c..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/preprocess.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/purge-domain.png b/doc/visual-programming/source/widgets/data/icons/purge-domain.png deleted file mode 100644 index 88df4d94ed8..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/purge-domain.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/python-script.png b/doc/visual-programming/source/widgets/data/icons/python-script.png deleted file mode 100644 index 89980ef4d34..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/python-script.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/randomize.png b/doc/visual-programming/source/widgets/data/icons/randomize.png deleted file mode 100755 index 6f4e461b62e..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/randomize.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/rank.png b/doc/visual-programming/source/widgets/data/icons/rank.png deleted file mode 100644 index 4a6512235c5..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/rank.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/save.png b/doc/visual-programming/source/widgets/data/icons/save.png deleted file mode 100644 index ad8bb786168..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/save.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/select-by-data-index.png b/doc/visual-programming/source/widgets/data/icons/select-by-data-index.png deleted file mode 100644 index 8159dcf0891..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/select-by-data-index.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/select-columns.png b/doc/visual-programming/source/widgets/data/icons/select-columns.png deleted file mode 100644 index 461dc450d47..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/select-columns.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/select-rows.png b/doc/visual-programming/source/widgets/data/icons/select-rows.png deleted file mode 100644 index 523873a9371..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/select-rows.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/sql-table.png b/doc/visual-programming/source/widgets/data/icons/sql-table.png deleted file mode 100644 index 109d6910296..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/sql-table.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/icons/transpose.png b/doc/visual-programming/source/widgets/data/icons/transpose.png deleted file mode 100644 index f4e37b5192a..00000000000 Binary files a/doc/visual-programming/source/widgets/data/icons/transpose.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/AggregateColumns-Example.png b/doc/visual-programming/source/widgets/data/images/AggregateColumns-Example.png deleted file mode 100644 index 28856d98818..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/AggregateColumns-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/AggregateColumns.png b/doc/visual-programming/source/widgets/data/images/AggregateColumns.png deleted file mode 100644 index 9abe1b9451c..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/AggregateColumns.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/ApplyDomain-Example.png b/doc/visual-programming/source/widgets/data/images/ApplyDomain-Example.png deleted file mode 100644 index b6d6ce4751f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/ApplyDomain-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/ApplyDomain.png b/doc/visual-programming/source/widgets/data/images/ApplyDomain.png deleted file mode 100644 index 5f6596ffb25..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/ApplyDomain.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CSVFileImport-Example.png b/doc/visual-programming/source/widgets/data/images/CSVFileImport-Example.png deleted file mode 100644 index c2dc4ae5816..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CSVFileImport-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CSVFileImport-ImportOptions-stamped.png b/doc/visual-programming/source/widgets/data/images/CSVFileImport-ImportOptions-stamped.png deleted file mode 100644 index e7d6a6fb4cd..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CSVFileImport-ImportOptions-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CSVFileImport-encodings.png b/doc/visual-programming/source/widgets/data/images/CSVFileImport-encodings.png deleted file mode 100644 index 2b30fa5a186..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CSVFileImport-encodings.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CSVFileImport-widget-stamped.png b/doc/visual-programming/source/widgets/data/images/CSVFileImport-widget-stamped.png deleted file mode 100644 index 0ea67c50ab8..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CSVFileImport-widget-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-Continuous_unindexed.png b/doc/visual-programming/source/widgets/data/images/Color-Continuous_unindexed.png deleted file mode 100644 index 21dc6fa04d9..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-Continuous_unindexed.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-Example-Continuous.png b/doc/visual-programming/source/widgets/data/images/Color-Example-Continuous.png deleted file mode 100644 index d4aeec31ef8..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-Example-Continuous.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-Example-Discrete.png b/doc/visual-programming/source/widgets/data/images/Color-Example-Discrete.png deleted file mode 100644 index a68a69db838..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-Example-Discrete.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-stamped.png b/doc/visual-programming/source/widgets/data/images/Color-stamped.png deleted file mode 100644 index 295c8be7b9e..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Concatenate-Example.png b/doc/visual-programming/source/widgets/data/images/Concatenate-Example.png deleted file mode 100644 index bfef25b8a18..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Concatenate-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Concatenate-stamped.png b/doc/visual-programming/source/widgets/data/images/Concatenate-stamped.png deleted file mode 100644 index 4f8831f5401..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Concatenate-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Concatenate.png b/doc/visual-programming/source/widgets/data/images/Concatenate.png deleted file mode 100644 index f0850cba9c4..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Concatenate.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Continuize-Example1.png b/doc/visual-programming/source/widgets/data/images/Continuize-Example1.png deleted file mode 100644 index 6b4d376060f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Continuize-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Continuize-Example2.png b/doc/visual-programming/source/widgets/data/images/Continuize-Example2.png deleted file mode 100644 index e33032ccae2..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Continuize-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Continuize-stamped.png b/doc/visual-programming/source/widgets/data/images/Continuize-stamped.png deleted file mode 100644 index 1fb7ae34e61..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Continuize-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Correlations-Example.png b/doc/visual-programming/source/widgets/data/images/Correlations-Example.png deleted file mode 100644 index 7babef0cb28..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Correlations-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Correlations-links.png b/doc/visual-programming/source/widgets/data/images/Correlations-links.png deleted file mode 100644 index cce0c9fcb04..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Correlations-links.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Correlations-stamped.png b/doc/visual-programming/source/widgets/data/images/Correlations-stamped.png deleted file mode 100644 index 30cc12b598e..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Correlations-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CreateClass-example.png b/doc/visual-programming/source/widgets/data/images/CreateClass-example.png deleted file mode 100644 index ff41b5367cb..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CreateClass-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CreateClass-stamped.png b/doc/visual-programming/source/widgets/data/images/CreateClass-stamped.png deleted file mode 100644 index 734652ce7a2..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CreateClass-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CreateInstance-example.png b/doc/visual-programming/source/widgets/data/images/CreateInstance-example.png deleted file mode 100644 index 5b78b9d0729..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CreateInstance-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CreateInstance-example2.png b/doc/visual-programming/source/widgets/data/images/CreateInstance-example2.png deleted file mode 100644 index 164f2a66276..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CreateInstance-example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/CreateInstance-stamped.png b/doc/visual-programming/source/widgets/data/images/CreateInstance-stamped.png deleted file mode 100644 index c39e7985813..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/CreateInstance-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/DataInfo-Example.png b/doc/visual-programming/source/widgets/data/images/DataInfo-Example.png deleted file mode 100644 index 02bd55366b0..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/DataInfo-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/DataSampler-Example-OverUnderSampling.png b/doc/visual-programming/source/widgets/data/images/DataSampler-Example-OverUnderSampling.png deleted file mode 100644 index 6bce502c095..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/DataSampler-Example-OverUnderSampling.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/DataSampler-Example1.png b/doc/visual-programming/source/widgets/data/images/DataSampler-Example1.png deleted file mode 100644 index ce84863eeee..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/DataSampler-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/DataSampler-Example2.png b/doc/visual-programming/source/widgets/data/images/DataSampler-Example2.png deleted file mode 100644 index 0fefceeea69..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/DataSampler-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/DataSampler-stamped.png b/doc/visual-programming/source/widgets/data/images/DataSampler-stamped.png deleted file mode 100644 index 8b22141ab8a..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/DataSampler-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/DataTable-Example.png b/doc/visual-programming/source/widgets/data/images/DataTable-Example.png deleted file mode 100644 index e3aed14b44e..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/DataTable-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/DataTable-Schema.png b/doc/visual-programming/source/widgets/data/images/DataTable-Schema.png deleted file mode 100644 index 8f5d85ae4d8..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/DataTable-Schema.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/DataTable-stamped.png b/doc/visual-programming/source/widgets/data/images/DataTable-stamped.png deleted file mode 100644 index 580d94877b1..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/DataTable-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Datasets-Workflow.png b/doc/visual-programming/source/widgets/data/images/Datasets-Workflow.png deleted file mode 100644 index b79878f7987..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Datasets-Workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Datasets-stamped.png b/doc/visual-programming/source/widgets/data/images/Datasets-stamped.png deleted file mode 100644 index 20025ddba9d..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Datasets-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Discretize-All-stamped.png b/doc/visual-programming/source/widgets/data/images/Discretize-All-stamped.png deleted file mode 100644 index a504dbe4698..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Discretize-All-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Discretize-All.png b/doc/visual-programming/source/widgets/data/images/Discretize-All.png deleted file mode 100644 index 51f224d5d6f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Discretize-All.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Discretize-Example.png b/doc/visual-programming/source/widgets/data/images/Discretize-Example.png deleted file mode 100644 index 67dc0540deb..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Discretize-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/EditDomain-Example.png b/doc/visual-programming/source/widgets/data/images/EditDomain-Example.png deleted file mode 100644 index 636d6572518..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/EditDomain-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/EditDomain-merge.png b/doc/visual-programming/source/widgets/data/images/EditDomain-merge.png deleted file mode 100644 index 4440a5d3f4a..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/EditDomain-merge.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/EditDomain-stamped.png b/doc/visual-programming/source/widgets/data/images/EditDomain-stamped.png deleted file mode 100644 index 295ebfe92d1..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/EditDomain-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/FeatureConstructor-Example.png b/doc/visual-programming/source/widgets/data/images/FeatureConstructor-Example.png deleted file mode 100644 index 04e26f20172..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/FeatureConstructor-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/File-Workflow.png b/doc/visual-programming/source/widgets/data/images/File-Workflow.png deleted file mode 100644 index 70c5905448b..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/File-Workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/File-stamped.png b/doc/visual-programming/source/widgets/data/images/File-stamped.png deleted file mode 100644 index 9af594dce83..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/File-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/File.png b/doc/visual-programming/source/widgets/data/images/File.png deleted file mode 100644 index f8a375d03a9..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/File.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Impute-Example.png b/doc/visual-programming/source/widgets/data/images/Impute-Example.png deleted file mode 100644 index fc6aa01bea3..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Impute-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Impute.png b/doc/visual-programming/source/widgets/data/images/Impute.png deleted file mode 100644 index 6db0b1c660f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Impute.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Melt-Default-stamped.png b/doc/visual-programming/source/widgets/data/images/Melt-Default-stamped.png deleted file mode 100644 index 58f1e0e0b3b..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Melt-Default-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Melt-Distribution.png b/doc/visual-programming/source/widgets/data/images/Melt-Distribution.png deleted file mode 100644 index 5af5c8773b4..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Melt-Distribution.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Melt-Workflow.png b/doc/visual-programming/source/widgets/data/images/Melt-Workflow.png deleted file mode 100644 index 5ace5665916..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Melt-Workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Merge-Data-stamped.png b/doc/visual-programming/source/widgets/data/images/Merge-Data-stamped.png deleted file mode 100644 index a2772453720..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Merge-Data-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Merge-Data_Example.png b/doc/visual-programming/source/widgets/data/images/Merge-Data_Example.png deleted file mode 100644 index 83d5f94caeb..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Merge-Data_Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Merge-Data_excel.png b/doc/visual-programming/source/widgets/data/images/Merge-Data_excel.png deleted file mode 100644 index 9e4425fefaa..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Merge-Data_excel.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData-Example.png b/doc/visual-programming/source/widgets/data/images/MergeData-Example.png deleted file mode 100644 index 0a80d816d41..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData-Example2.png b/doc/visual-programming/source/widgets/data/images/MergeData-Example2.png deleted file mode 100644 index 4e8f9f7fbe7..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData-Example3.png b/doc/visual-programming/source/widgets/data/images/MergeData-Example3.png deleted file mode 100644 index fef3b1286f0..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData-Example3.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData-InstanceID.png b/doc/visual-programming/source/widgets/data/images/MergeData-InstanceID.png deleted file mode 100644 index 9b2040357ef..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData-InstanceID.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData-multiple.png b/doc/visual-programming/source/widgets/data/images/MergeData-multiple.png deleted file mode 100644 index 4b6d5434daf..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData-multiple.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData-multiple2.png b/doc/visual-programming/source/widgets/data/images/MergeData-multiple2.png deleted file mode 100644 index 8752ce6bc69..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData-multiple2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData-stamped.png b/doc/visual-programming/source/widgets/data/images/MergeData-stamped.png deleted file mode 100644 index fb51525ded6..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData_Append.png b/doc/visual-programming/source/widgets/data/images/MergeData_Append.png deleted file mode 100644 index 4afec48547f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData_Append.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData_Concatenate.png b/doc/visual-programming/source/widgets/data/images/MergeData_Concatenate.png deleted file mode 100644 index 55db366c4c2..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData_Concatenate.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/MergeData_Intersection.png b/doc/visual-programming/source/widgets/data/images/MergeData_Intersection.png deleted file mode 100644 index 2a9323c6d5c..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/MergeData_Intersection.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Outliers-Example.png b/doc/visual-programming/source/widgets/data/images/Outliers-Example.png deleted file mode 100644 index 0271a494fef..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Outliers-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Outliers-stamped.png b/doc/visual-programming/source/widgets/data/images/Outliers-stamped.png deleted file mode 100644 index 3acaa491f06..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Outliers-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Outliers.png b/doc/visual-programming/source/widgets/data/images/Outliers.png deleted file mode 100644 index 4956da6670f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Outliers.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PaintData-Example.png b/doc/visual-programming/source/widgets/data/images/PaintData-Example.png deleted file mode 100644 index 1238d933560..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PaintData-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PaintData-stamped.png b/doc/visual-programming/source/widgets/data/images/PaintData-stamped.png deleted file mode 100644 index e98f6946ac0..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PaintData-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PaintData.png b/doc/visual-programming/source/widgets/data/images/PaintData.png deleted file mode 100644 index dbf49ec6363..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PaintData.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Pivot-continuous.png b/doc/visual-programming/source/widgets/data/images/Pivot-continuous.png deleted file mode 100644 index 1c7be5202d0..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Pivot-continuous.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Pivot-discrete.png b/doc/visual-programming/source/widgets/data/images/Pivot-discrete.png deleted file mode 100644 index 0df3a4bb6a8..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Pivot-discrete.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Pivot-example.png b/doc/visual-programming/source/widgets/data/images/Pivot-example.png deleted file mode 100644 index 6b51cf2f956..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Pivot-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Pivot-stamped.png b/doc/visual-programming/source/widgets/data/images/Pivot-stamped.png deleted file mode 100644 index 2113158d1d6..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Pivot-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Preprocess-Example1.png b/doc/visual-programming/source/widgets/data/images/Preprocess-Example1.png deleted file mode 100644 index 99b71b48e71..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Preprocess-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Preprocess-Example2.png b/doc/visual-programming/source/widgets/data/images/Preprocess-Example2.png deleted file mode 100644 index 7cb03aa5b0a..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Preprocess-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Preprocess1.png b/doc/visual-programming/source/widgets/data/images/Preprocess1.png deleted file mode 100644 index 427dd6719a7..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Preprocess1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Preprocess2.png b/doc/visual-programming/source/widgets/data/images/Preprocess2.png deleted file mode 100644 index ce690749153..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Preprocess2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PurgeDomain-example.png b/doc/visual-programming/source/widgets/data/images/PurgeDomain-example.png deleted file mode 100644 index cdd40b50e38..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PurgeDomain-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PurgeDomain-stamped.png b/doc/visual-programming/source/widgets/data/images/PurgeDomain-stamped.png deleted file mode 100644 index 319b6c074c5..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PurgeDomain-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PythonScript-Example3.png b/doc/visual-programming/source/widgets/data/images/PythonScript-Example3.png deleted file mode 100644 index febef3ecb37..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PythonScript-Example3.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PythonScript-filtering.png b/doc/visual-programming/source/widgets/data/images/PythonScript-filtering.png deleted file mode 100644 index 19699249faa..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PythonScript-filtering.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PythonScript-gauss.png b/doc/visual-programming/source/widgets/data/images/PythonScript-gauss.png deleted file mode 100644 index 3f4456a7a5a..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PythonScript-gauss.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PythonScript-round.png b/doc/visual-programming/source/widgets/data/images/PythonScript-round.png deleted file mode 100644 index 0e7c08882d5..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PythonScript-round.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PythonScript-stamped.png b/doc/visual-programming/source/widgets/data/images/PythonScript-stamped.png deleted file mode 100644 index 5b5906db656..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PythonScript-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/PythonScript.png b/doc/visual-programming/source/widgets/data/images/PythonScript.png deleted file mode 100644 index 2dc4db920a9..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/PythonScript.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Randomize-Default.png b/doc/visual-programming/source/widgets/data/images/Randomize-Default.png deleted file mode 100644 index e091e530e85..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Randomize-Default.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Randomize-Example1.png b/doc/visual-programming/source/widgets/data/images/Randomize-Example1.png deleted file mode 100644 index f71d9138795..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Randomize-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Randomize-Example2.png b/doc/visual-programming/source/widgets/data/images/Randomize-Example2.png deleted file mode 100644 index f1d956c3f3c..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Randomize-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Rank-Select-Schema.png b/doc/visual-programming/source/widgets/data/images/Rank-Select-Schema.png deleted file mode 100644 index 253b4203d78..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Rank-Select-Schema.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Rank-Select-Widgets.png b/doc/visual-programming/source/widgets/data/images/Rank-Select-Widgets.png deleted file mode 100644 index 500bfef99a6..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Rank-Select-Widgets.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Rank-and-Test.png b/doc/visual-programming/source/widgets/data/images/Rank-and-Test.png deleted file mode 100644 index 6ab41010ea5..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Rank-and-Test.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Rank-stamped.png b/doc/visual-programming/source/widgets/data/images/Rank-stamped.png deleted file mode 100644 index 834ec26fe2a..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Rank-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SQLTable-Example.png b/doc/visual-programming/source/widgets/data/images/SQLTable-Example.png deleted file mode 100644 index 7fad076a1a4..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SQLTable-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SQLTable-stamped.png b/doc/visual-programming/source/widgets/data/images/SQLTable-stamped.png deleted file mode 100644 index 123f3171cd9..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SQLTable-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Save-Workflow.png b/doc/visual-programming/source/widgets/data/images/Save-Workflow.png deleted file mode 100644 index c565596b2df..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Save-Workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SaveData.png b/doc/visual-programming/source/widgets/data/images/SaveData.png deleted file mode 100644 index 6ed2933dcf1..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SaveData.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Select-by-Data-Index-Example1.png b/doc/visual-programming/source/widgets/data/images/Select-by-Data-Index-Example1.png deleted file mode 100644 index 0c7ad29b3f1..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Select-by-Data-Index-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Select-by-Data-Index-stamped.png b/doc/visual-programming/source/widgets/data/images/Select-by-Data-Index-stamped.png deleted file mode 100644 index 9de3a6b358f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Select-by-Data-Index-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SelectColumns-Example1.png b/doc/visual-programming/source/widgets/data/images/SelectColumns-Example1.png deleted file mode 100644 index 53d3d89b8e4..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SelectColumns-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SelectColumns-Example2.png b/doc/visual-programming/source/widgets/data/images/SelectColumns-Example2.png deleted file mode 100644 index 00ef1928805..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SelectColumns-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SelectColumns-stamped.png b/doc/visual-programming/source/widgets/data/images/SelectColumns-stamped.png deleted file mode 100644 index 91751808b74..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SelectColumns-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SelectColumns2-Workflow.png b/doc/visual-programming/source/widgets/data/images/SelectColumns2-Workflow.png deleted file mode 100644 index 1d0f2d188ff..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SelectColumns2-Workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SelectRows-Example.png b/doc/visual-programming/source/widgets/data/images/SelectRows-Example.png deleted file mode 100644 index ab8065f57fe..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SelectRows-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SelectRows-Workflow.png b/doc/visual-programming/source/widgets/data/images/SelectRows-Workflow.png deleted file mode 100644 index 037c0a50b1c..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SelectRows-Workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SelectRows-schema.png b/doc/visual-programming/source/widgets/data/images/SelectRows-schema.png deleted file mode 100644 index 595a5b3f906..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SelectRows-schema.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/SelectRows-stamped.png b/doc/visual-programming/source/widgets/data/images/SelectRows-stamped.png deleted file mode 100644 index eceaf43993a..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/SelectRows-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Unique-Example.png b/doc/visual-programming/source/widgets/data/images/Unique-Example.png deleted file mode 100644 index 4b6e71bcb7c..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Unique-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Unique-stamped.png b/doc/visual-programming/source/widgets/data/images/Unique-stamped.png deleted file mode 100644 index 58d4fe8f7a8..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Unique-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/data-info-stamped.png b/doc/visual-programming/source/widgets/data/images/data-info-stamped.png deleted file mode 100644 index e36a825ea0d..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/data-info-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/feature-constructor1-stamped.png b/doc/visual-programming/source/widgets/data/images/feature-constructor1-stamped.png deleted file mode 100644 index f3e8672380d..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/feature-constructor1-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/feature-constructor1.png b/doc/visual-programming/source/widgets/data/images/feature-constructor1.png deleted file mode 100644 index feccd3dbf64..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/feature-constructor1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/feature-constructor2-stamped.png b/doc/visual-programming/source/widgets/data/images/feature-constructor2-stamped.png deleted file mode 100644 index 01434327000..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/feature-constructor2-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/feature-constructor2.png b/doc/visual-programming/source/widgets/data/images/feature-constructor2.png deleted file mode 100644 index b6bf99cbc5e..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/feature-constructor2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/feature_statistics-stamped.png b/doc/visual-programming/source/widgets/data/images/feature_statistics-stamped.png deleted file mode 100644 index 209f276a53f..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/feature_statistics-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/feature_statistics_example1.png b/doc/visual-programming/source/widgets/data/images/feature_statistics_example1.png deleted file mode 100644 index c55073b00a1..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/feature_statistics_example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/feature_statistics_example2.png b/doc/visual-programming/source/widgets/data/images/feature_statistics_example2.png deleted file mode 100644 index a3aa56f2994..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/feature_statistics_example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/feature_statistics_workflow.png b/doc/visual-programming/source/widgets/data/images/feature_statistics_workflow.png deleted file mode 100644 index aa04253e2a6..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/feature_statistics_workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/impute-stamped.png b/doc/visual-programming/source/widgets/data/images/impute-stamped.png deleted file mode 100644 index 717f8264bc4..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/impute-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/neighbours-example-multiple.png b/doc/visual-programming/source/widgets/data/images/neighbours-example-multiple.png deleted file mode 100644 index 09e6a8b5119..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/neighbours-example-multiple.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/neighbours-example1.png b/doc/visual-programming/source/widgets/data/images/neighbours-example1.png deleted file mode 100644 index 3a0465d77cc..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/neighbours-example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/neighbours-example2.png b/doc/visual-programming/source/widgets/data/images/neighbours-example2.png deleted file mode 100644 index 0a654d2eba7..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/neighbours-example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/neighbours-stamped.png b/doc/visual-programming/source/widgets/data/images/neighbours-stamped.png deleted file mode 100644 index a8ec4f1f524..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/neighbours-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/preprocess-stamped.png b/doc/visual-programming/source/widgets/data/images/preprocess-stamped.png deleted file mode 100644 index dc8840214f5..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/preprocess-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/spreadsheet-simple-head1.png b/doc/visual-programming/source/widgets/data/images/spreadsheet-simple-head1.png deleted file mode 100644 index e9bc80ce557..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/spreadsheet-simple-head1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/transpose-example.png b/doc/visual-programming/source/widgets/data/images/transpose-example.png deleted file mode 100644 index 9782461b6cb..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/transpose-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/transpose-stamped.png b/doc/visual-programming/source/widgets/data/images/transpose-stamped.png deleted file mode 100644 index 8387e54049e..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/transpose-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/impute.md b/doc/visual-programming/source/widgets/data/impute.md deleted file mode 100644 index fa850850718..00000000000 --- a/doc/visual-programming/source/widgets/data/impute.md +++ /dev/null @@ -1,38 +0,0 @@ -Impute -====== - -Replaces unknown values in the data. - -**Inputs** - -- Data: input dataset -- Learner: learning algorithm for imputation - -**Outputs** - -- Data: dataset with imputed values - -Some Orange's algorithms and visualizations cannot handle unknown values in the data. This widget does what statisticians call imputation: it substitutes missing values by values either computed from the data or set by the user. The default imputation is (1-NN). - -![](images/impute-stamped.png) - -1. In the top-most box, *Default method*, the user can specify a general imputation technique for all attributes. - - **Don't Impute** does nothing with the missing values. - - **Average/Most-frequent** uses the average value (for continuous attributes) or the most common value (for discrete attributes). - - **As a distinct value** creates new values to substitute the missing ones. - - **Model-based imputer** constructs a model for predicting the missing value, based on values of other attributes; a separate model is constructed for each attribute. The default model is 1-NN learner, which takes the value from the most similar example (this is sometimes referred to as hot deck imputation). This algorithm can be substituted by one that the user connects to the input signal Learner for Imputation. Note, however, that if there are discrete and continuous attributes in the data, the algorithm needs to be capable of handling them both; at the moment only 1-NN learner can do that. (In the future, when Orange has more regressors, the Impute widget may have separate input signals for discrete and continuous models.) - - **Random values** computes the distributions of values for each attribute and then imputes by picking random values from them. - - **Remove examples with missing values** removes the example containing missing values. This check also applies to the class attribute if *Impute class values* is checked. - -2. It is possible to specify individual treatment for each attribute, which overrides the default treatment set. One can also specify a manually defined value used for imputation. In the screenshot, we decided not to impute the values of "*normalized-losses*" and "*make*", the missing values of "*aspiration*" will be replaced by random values, while the missing values of "*body-style*" and "*drive-wheels*" are replaced by "*hatchback*" and "*fwd*",respectively. If the values of "*length*", "*width*" or "*height*" are missing, the example is discarded. Values of all other attributes use the default method set above (model-based imputer, in our case). -3. The imputation methods for individual attributes are the same as default methods. -4. *Restore All to Default* resets the individual attribute treatments to default. -5. Produce a report. -6. All changes are committed immediately if *Apply automatically* is checked. Otherwise, *Apply* needs to be ticked to apply any new settings. - -Example -------- - -To demonstrate how the **Impute** widget works, we played around with the *Iris* dataset and deleted some of the data. We used the **Impute** widget and selected the *Model-based imputer* to impute the missing values. In another [Data Table](../data/datatable.md), we see how the question marks turned into distinct values ("Iris-setosa, "Iris-versicolor"). - -![](images/Impute-Example.png) diff --git a/doc/visual-programming/source/widgets/data/melt.md b/doc/visual-programming/source/widgets/data/melt.md deleted file mode 100644 index eaa43346c57..00000000000 --- a/doc/visual-programming/source/widgets/data/melt.md +++ /dev/null @@ -1,36 +0,0 @@ -Melt -========= - -Transform [wide data to narrow](https://en.wikipedia.org/wiki/Wide_and_narrow_data). - -**Inputs** - -- Data: wide data table - -**Outputs** - -- Data: narrow data table - -The **Melt** widget receives a dataset in the more common wide format and outputs a table of (row_id, variable, value) triplets. - - -![](images/Melt-Default-stamped.png) - -1. Select the variable used as id. The widget offers only columns without duplicated values. Alternatively, row number can be used as id. -2. Select whether to include non-numeric variables, and whether to exclude zero values. -3. Set the names of the columns with name of the variable ("item") and the corresponding value. - -Example -------- - -In the following workflow we play with the Zoo data set, in which we convert all variables to numeric by treating them as ordinal. All variables except the number of legs boolean (e.g. the animal lays or does not lay eggs), so a value of 1 will correspond to an animal having a particular feature. In data table we select all rows (Ctrl-A or Cmd-A) and deselect the duplicate description of the frog in order to avoid duplicate values in the "name" column. - -We pass it to Melt, where we designate the name as the row id, and discard zero values. The resulting table has multiple rows for each animal: one for each of animals features. - -An interesting immediate use for this is to pass this data to Distributions and see what are the most and the least common features of animals. - -![](images/Melt-Workflow.png) - -In the next example we show how shuffling class values influences model performance on the same dataset as above. - -![](images/Melt-Distribution.png) diff --git a/doc/visual-programming/source/widgets/data/mergedata.md b/doc/visual-programming/source/widgets/data/mergedata.md deleted file mode 100644 index d73415abeff..00000000000 --- a/doc/visual-programming/source/widgets/data/mergedata.md +++ /dev/null @@ -1,97 +0,0 @@ -Merge Data -========== - -Merges two datasets, based on values of selected attributes. - -**Inputs** - -- Data: input dataset -- Extra Data: additional dataset - -**Outputs** - -- Data: dataset with features added from extra data - -The **Merge Data** widget is used to horizontally merge two datasets, based on the values of selected attributes (columns). In the input, two datasets are required, data and extra data. Rows from the two data sets are matched by the values of pairs of attributes, chosen by the user. The widget produces one output. It corresponds to the instances from the input data to which attributes (columns) from input extra data are appended. - -If the selected attribute pair does not contain unique values (in other words, the attributes have duplicate values), the widget will give a warning. Instead, one can match by more than one attribute. Click on the plus icon to add the attribute to merge on. The final result has to be a unique combination for each individual row. - -![](images/Merge-Data-stamped.png) - -1. Information on main data. -2. Information on data to append. -3. Merging type: - - **Append columns from Extra Data** outputs all rows from the Data, augmented by the columns in the Extra Data. Rows without matches are retained, even where the data in the extra columns are missing. - - **Find matching pairs of rows** outputs rows from the Data, augmented by the columns in the Extra Data. Rows without matches are removed from the output. - - **Concatenate tables** treats both data sources symmetrically. The output is similar to the first option, except that non-matched values from Extra Data are appended at the end. -4. List of attributes from Data input. -5. List of attributes from Extra Data input. -6. Produce a report. - -Merging Types -------------- - -#####Append Columns from Extra Data (left join) - -Columns from the Extra Data are added to the Data. Instances with no matching rows will have missing values added. - -For example, the first table may contain city names and the second would be a list of cities and their coordinates. Columns with coordinates would then be appended to the data with city names. Where city names cannot be matched, missing values will appear. - -In our example, the first Data input contained 6 cities, but the Extra Data did not provide Lat and Lon values for Bratislava, so the fields will be empty. - -![](images/MergeData_Append.png) - -#####Find matching pairs of rows (inner join) - -Only those rows that are matched will be present on the output, with the Extra Data columns appended. Rows without matches are removed. - -In our example, Bratislava from the Data input did not have Lat and Lon values, while Belgrade from the Extra Data could not be found in the City column we were merging on. Hence both instances are remove - only the intersection of instances is sent to the output. - -![](images/MergeData_Intersection.png) - -#####Concatenate tables (outer join) - -The rows from both the Data and the Extra Data will be present on the output. Where rows cannot be matched, missing values will appear. - -In our example, both Bratislava and Belgrade are now present. Bratislava will have missing Lat and Lon values, while Belgrade will have a missing Population value. - -![](images/MergeData_Concatenate.png) - -#####Row index - -Data will be merged in the same order as they appear in the table. Row number 1 from the Data input will be joined with row number 1 from the Extra Data input. Row numbers are assigned by Orange based on the original order of the data instances. - -#####Instance ID - -This is a more complex option. Sometimes, data in transformed in the analysis and the domain is no longer the same. Nevertheless, the original row indices are still present in the background (Orange remembers them). In this case one can merge on instance ID. For example if you transformed the data with PCA, visualized it in the Scatter Plot, selected some data instances and now you wish to see the original information of the selected subset. Connect the output of Scatter Plot to Merge Data, add the original data set as Extra Data and merge by Instance ID. - -![](images/MergeData-InstanceID.png) - -#####Merge by two or more attributes - -Sometimes our data instances are unique with respect to a combination of columns, not a single column. To merge by more than a single column, add the *Row matching* condition by pressing plus next to the matching condition. To remove it, press the x. - -In the below example, we are merging by *student* column and *class* column. - -![](images/MergeData-multiple.png) - -Say we have two data sets with student names and the class they're in. The first data set has students' grades and the second on the elective course they have chosen. Unfortunately, there are two Jacks in our data, one from class A and the other from class B. Same for Jane. - -To distinguish between the two, we can match rows on both, the student's name and her class. - -![](images/MergeData-multiple2.png) - -Examples --------- - -Merging two datasets results in appending new attributes to the original file, based on a selected common attribute. In the example below, we wanted to merge the **zoo.tab** file containing only factual data with [zoo-with-images.tab](http://file.biolab.si/datasets/zoo-with-images.tab) containing images. Both files share a common string attribute *names*. Now, we create a workflow connecting the two files. The *zoo.tab* data is connected to **Data** input of the **Merge Data** widget, and the *zoo-with-images.tab* data to the **Extra Data** input. Outputs of the **Merge Data** widget is then connected to the [Data Table](../data/datatable.md) widget. In the latter, the **Merged Data** channels are shown, where image attributes are added to the original data. - -![](images/MergeData-Example.png) - -The case where we want to include all instances in the output, even those where no match by attribute *names* was found, is shown in the following workflow. - -![](images/MergeData-Example2.png) - -The third type of merging is shown in the next workflow. The output consists of both inputs, with unknown values assigned where no match was found. - -![](images/MergeData-Example3.png) diff --git a/doc/visual-programming/source/widgets/data/neighbors.md b/doc/visual-programming/source/widgets/data/neighbors.md deleted file mode 100644 index 423c64ee66c..00000000000 --- a/doc/visual-programming/source/widgets/data/neighbors.md +++ /dev/null @@ -1,42 +0,0 @@ -Neighbors -========= - -Compute nearest neighbors in data according to reference. - -**Inputs** - -- Data: An input data set. -- Reference: A reference data for neighbor computation. - -**Outputs** - -- Neighbors: A data table of nearest neighbors according to reference. - -The **Neighbors** widget computes nearest neighbors for a given reference and for a given distance measure. The reference can be either one instance or more instances. In the case with one reference widget outputs closest `n` instances from data where `n` is set by the **Number of neighbors** option in the widget. When reference contains more instances widget computes the combined distance for each data instance as a minimum of distances to each reference. Widget outputs `n` data instances with lowest combined distance. - -![](images/neighbours-stamped.png) - -1. Distance measure for computing neighbors. Supported measures are: Euclidean, Manhattan, Mahalanobis, Cosine, Jaccard, Spearman, absolute Spearman, Pearson, absolute Pearson. -2. Number of neighbors on the output. -3. If *Exclude rows (equal to) references* is ticked, data instances that are highly similar to the reference (distance < 1e-5), will be excluded. -4. Click *Apply* to commit the changes. To communicate changes automatically tick *Apply Automatically*. -5. Status bar with access to widget help and information on the input and output data. - -Examples --------- - -In the first example, we used *iris* data and passed it to **Neighbors** and to [Data Table](../data/datatable.md). In **Data Table**, we selected an instance of iris, that will serve as our reference, meaning we wish to retrieve 10 closest examples to the select data instance. We connect **Data Table** to **Neighbors** as well. - -We can observe the results of neighbor computation in **Data Table (1)**, where we can see 10 closest images to our selected iris flower. - -![](images/neighbours-example1.png) - -Now change the selection **Data Table** to multiple examples. As a result, we get instances with closest combined distances to the references. The method computes the combined distance as a minimum of distances to each reference. - -![](images/neighbours-example-multiple.png) - -Another example requires the installation of Image Analytics add-on. We loaded 15 paintings from famous painters with **Import Images** widget and passed them to **Image Embedding**, where we selected *Painters* embedder. - -Then the procedure is the same as above. We passed embedded images to **Image Viewer** and selected a painting from Monet to serve as our reference image. We passed the image to **Neighbors**, where we set the distance measure to *cosine*, ticked off *Exclude reference* and set the neighbors to 2. This allows us to find the actual closest neighbor to a reference painting and observe them side by side in **Image Viewer (1)**. - -![](images/neighbours-example2.png) diff --git a/doc/visual-programming/source/widgets/data/outliers.md b/doc/visual-programming/source/widgets/data/outliers.md deleted file mode 100644 index 580b3e2509b..00000000000 --- a/doc/visual-programming/source/widgets/data/outliers.md +++ /dev/null @@ -1,49 +0,0 @@ -Outliers -======== - -Outlier detection widget. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Outliers: instances scored as outliers -- Inliers: instances not scored as outliers -- Data: input dataset appended *Outlier* variable - -The **Outliers** widget applies one of the four methods for outlier detection. All methods apply classification to the dataset. *One-class SVM with non-linear kernels (RBF)* performs well with non-Gaussian distributions, while *Covariance estimator* works only for data with Gaussian distribution. One efficient way to perform outlier detection on moderately high dimensional datasets is to use the *Local Outlier Factor* algorithm. The algorithm computes a score reflecting the degree of abnormality of the observations. It measures the local density deviation of a given data point with respect to its neighbors. Another efficient way of performing outlier detection in high-dimensional datasets is to use random forests (*Isolation Forest*). - -![](images/Outliers-stamped.png) - -1. Method for outlier detection: - - [One Class SVM](http://scikit-learn.org/stable/modules/generated/sklearn.svm.OneClassSVM.html) - - [Covariance Estimator](http://scikit-learn.org/stable/modules/generated/sklearn.covariance.EllipticEnvelope.html) - - [Local Outlier Factor](http://scikit-learn.org/stable/modules/generated/sklearn.neighbors.LocalOutlierFactor.html) - - [Isolation Forest](http://scikit-learn.org/stable/modules/generated/sklearn.ensemble.IsolationForest.html) -2. Set parameters for the method: - - **One class SVM with non-linear kernel (RBF)**: classifies data as similar or different from the core class: - - *Nu* is a parameter for the upper bound on the fraction of training errors and a lower bound of the fraction of support vectors - - *Kernel coefficient* is a gamma parameter, which specifies how much influence a single data instance has - - **Covariance estimator**: fits ellipsis to central points with Mahalanobis distance metric: - - *Contamination* is the proportion of outliers in the dataset - - *Support fraction* specifies the proportion of points included in the estimate - - **Local Outlier Factor**: obtains local density from the k-nearest neighbors: - - *Contamination* is the proportion of outliers in the dataset - - *Neighbors* represents number of neighbors - - *Metric* is the distance measure - - **Isolation Forest**: isolates observations by randomly selecting a feature and then randomly selecting a split value between the maximum and minimum values of the selected feature: - - *Contamination* is the proportion of outliers in the dataset - - *Replicabe training* fixes random seed -3. If *Apply automatically* is ticked, changes will be propagated automatically. Alternatively, click *Apply*. -4. Produce a report. -5. Number of instances on the input, followed by number of instances scored as inliers. - - -Example -------- - -Below is an example of how to use this widget. We used subset (*versicolor* and *virginica* instances) of the *Iris* dataset to detect the outliers. We chose the *Local Outlier Factor* method, with *Euclidean* distance. Then we observed the annotated instances in the [Scatter Plot](../visualize/scatterplot.md) widget. In the next step we used the *setosa* instances to demonstrate novelty detection using [Apply Domain](../data/applydomain.md) widget. After concatenating both outputs we examined the outliers in the *Scatter Plot (1)*. - -![](images/Outliers-Example.png) diff --git a/doc/visual-programming/source/widgets/data/paintdata.md b/doc/visual-programming/source/widgets/data/paintdata.md deleted file mode 100644 index ca8dc87b2e0..00000000000 --- a/doc/visual-programming/source/widgets/data/paintdata.md +++ /dev/null @@ -1,26 +0,0 @@ -Paint Data -========== - -Paints data on a 2D plane. You can place individual data points or use a brush to paint larger datasets. - -**Outputs** - -- Data: dataset as painted in the plot - -The widget supports the creation of a new dataset by visually placing data points on a two-dimension plane. Data points can be placed on the plane individually (*Put*) or in a larger number by brushing (*Brush*). Data points can belong to classes if the data is intended to be used in supervised learning. - -![](images/PaintData-stamped.png) - -1. Name the axes and select a class to paint data instances. You can add or remove classes. Use only one class to create classless, unsupervised datasets. -2. Drawing tools. Paint data points with *Brush* (multiple data instances) or *Put* (individual data instance). Select data points with *Select* and remove them with the Delete/Backspace key. Reposition data points with [Jitter](https://en.wikipedia.org/wiki/Jitter) (spread) and *Magnet* (focus). Use *Zoom* and scroll to zoom in or out. Below, set the radius and intensity for Brush, Put, Jitter and Magnet tools. -3. Reset to Input Data. -4. *Save Image* saves the image to your computer in a .svg or .png format. -5. Produce a report. -6. Tick the box on the left to automatically commit changes to other widgets. Alternatively, press *Send* to apply them. - -Example -------- - -In the example below, we have painted a dataset with 4 classes. Such dataset is great for demonstrating k-means and hierarchical clustering methods. In the screenshot, we see that [k-Means](../unsupervised/kmeans.md), overall, recognizes clusters better than [Hierarchical Clustering](../unsupervised/hierarchicalclustering.md). It returns a score rank, where the best score (the one with the highest value) means the most likely number of clusters. Hierarchical clustering, however, doesn’t group the right classes together. This is a great tool for learning and exploring statistical concepts. - -![](images/PaintData-Example.png) diff --git a/doc/visual-programming/source/widgets/data/pivot.md b/doc/visual-programming/source/widgets/data/pivot.md deleted file mode 100644 index 297d133b8c2..00000000000 --- a/doc/visual-programming/source/widgets/data/pivot.md +++ /dev/null @@ -1,68 +0,0 @@ -Pivot Table -=========== - -Reshape data table based on column values. - -**Inputs** - -- Data: input data set - -**Outputs** - -- Pivot Table: contingency matrix as shown in the widget -- Filtered Data: subset selected from the plot -- Grouped Data: aggregates over groups defined by row values - -**Pivot Table** summarizes the data of a more extensive table into a table of statistics. The statistics can include sums, averages, counts, etc. The widget also allows selecting a subset from the table and grouping by row values, which have to be a discrete variable. Data with only numeric variables cannot be displayed in the table. - -![](images/Pivot-stamped.png) - -1. Discrete or numeric variable used for row values. Numeric variables are considered as integers. -2. Discrete variable used for column values. Variable values will appear as columns in the table. -3. Values used for aggregation. Aggregated values will appear as cells in the table. -4. Aggregation methods: - - For any variable type: - - *Count*: number of instances with the given row and column value. - - *Count defined*: number of instances where the aggregation value is defined. - - For numeric variables: - - *Sum*: sum of values. - - *Mean*: average of values. - - *Mode*: most frequent value of the subset. - - *Min*: smallest value. - - *Max*: highest value. - - *Median*: middle value. - - *Var*: variance of the subset. - - For discrete variables: - - *Majority*: most frequent value of the subset. -5. Tick the box on the left to automatically output any changes. Alternatively, press *Apply* . - -Discrete variables ------------------- - -![](images/Pivot-discrete.png) - -Example of a pivot table with only discrete variables selected. We are using *heart-disease* data set for this example. Rows correspond to values of *diameter narrowing* variable. Our columns are values of *gender*, namely female and male. We are using *thal* as values in our cells. - -We have selected *Count* and *Majority* as aggregation methods. In the pivot table, we can see the number of instances that do not have diameter narrowing and are female. There are 72 such patients. Concurrently, there are 92 male patients that don't have diameter narrowing. Thal values don't have any effect here, we are just counting occurrences in the data. - -The second row shows majority. This means most female patients that don't have diameter narrowing have normal thal results. Conversely, female patients that have diameter narrowing most often have reversable defect. - -Numeric variables ------------------ - -![](images/Pivot-continuous.png) - -Example of a pivot table with numeric variables. We are using *heart-disease* data set for this example. Rows correspond to values of *diameter narrowing* variable. Our columns are values of *gender*, namely female and male. We are using *rest SBP* as values in our cells. - -We have selected *Count*, *Sum* and *Median* as aggregation methods. Under *Count*, we see there are 72 female patients that don't have diameter narrowing, same as before for discrete values. What is different are the sum and median aggregations. We see that the sum of resting systolic blood pressure for female patients that don't have diameter narrowing is 9269 and the median value is 130. - -Example -------- - -We are using *Forest Fires* for this example. The data is loaded in the [Datasets](../data/datasets.md) widget and passed to **Pivot Table**. *Forest Fires* datasets reports forest fires by the month and day they happened. We can aggregate all occurrences of forest fires by selecting *Count* as aggregation method and using *month* as row and *day* as column values. Since we are using *Count*, *Values* variable will have no effect. - -We can plot the counts in [Line Plot](../visualize/lineplot.md). But first, let us organize our data a bit. With [Edit Domain](../data/editdomain.md), we will reorder rows values so that months will appear in the correct order, namely from January to December. To do the same for columns, we will use [Select Columns](../data/selectcolumns.md) and reorder day to go from Monday to Sunday. - -Finally, our data is ready. Let us pass it to **Line Plot**. We can see that forest fires are most common in August and September, while their frequency is higher during the weekend than during weekdays. - -![](images/Pivot-example.png) diff --git a/doc/visual-programming/source/widgets/data/preprocess.md b/doc/visual-programming/source/widgets/data/preprocess.md deleted file mode 100644 index 7f5c2f5b366..00000000000 --- a/doc/visual-programming/source/widgets/data/preprocess.md +++ /dev/null @@ -1,73 +0,0 @@ -Preprocess -========== - -Preprocesses data with selected methods. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Preprocessor: preprocessing method -- Preprocessed Data: data preprocessed with selected methods - -Preprocessing is crucial for achieving better-quality analysis results. The **Preprocess** widget offers several preprocessing methods that can be combined in a single preprocessing pipeline. Some methods are available as separate widgets, which offer advanced techniques and greater parameter tuning. - -![](images/preprocess-stamped.png) - -1. List of preprocessors. Double click the preprocessors you wish to use and shuffle their order by dragging them up or down. You can also add preprocessors by dragging them from the left menu to the right. -2. Preprocessing pipeline. -3. When the box is ticked (*Send Automatically*), the widget will communicate changes automatically. Alternatively, click *Send*. - -Preprocessors -------------- - -![](images/Preprocess1.png) - -1. List of preprocessors. -2. Discretization of continuous values: - - [Entropy-MDL discretization](http://sci2s.ugr.es/keel/pdf/algorithm/congreso/fayyad1993.pdf) by Fayyad and Irani that uses [expected information](http://kevinmeurer.com/a-simple-guide-to-entropy-based-discretization/) to determine bins. - - *Equal frequency discretization* splits by frequency (same number of instances in each bin. - - *Equal width discretization* creates bins of equal width (span of each bin is the same). - - *Remove numeric features* altogether. -3. Continuization of discrete values: - - *Most frequent as base* treats the most frequent discrete value as 0 and others as 1. The discrete attributes with more than 2 values, the most frequent will be considered as a base and contrasted with remaining values in corresponding columns. - - *One feature per value* creates columns for each value, place 1 where an instance has that value and 0 where it doesn't. Essentially [One Hot Encoding](http://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.OneHotEncoder.html). - - *Remove non-binary features* retains only categorical features that have values of either 0 or 1 and transforms them into continuous. - - *Remove categorical features* removes categorical features altogether. - - *Treat as ordinal* takes discrete values and treats them as numbers. If discrete values are categories, each category will be assigned a number as they appear in the data. - - *Divide by number of values* is similar to treat as ordinal, but the final values will be divided by the total number of values and hence the range of the new continuous variable will be [0, 1]. -4. Impute missing values: - - *Average/Most frequent* replaces missing values (NaN) with the average (for continuous) or most frequent (for discrete) value. - - *Replace with random value* replaces missing values with random ones within the range of each variable. - - *Remove rows with missing values*. -5. Select relevant features: - - Similar to [Rank](../data/rank.md), this preprocessor outputs only the most informative features. Score can be determined by information gain, [gain ratio](https://en.wikipedia.org/wiki/Information_gain_ratio), [gini index](https://en.wikipedia.org/wiki/Gini_coefficient), [ReliefF](https://en.wikipedia.org/wiki/Relief_(feature_selection)), [fast correlation based filter](https://www.aaai.org/Papers/ICML/2003/ICML03-111.pdf), [ANOVA](https://en.wikipedia.org/wiki/One-way_analysis_of_variance), [Chi2](https://en.wikipedia.org/wiki/Chi-squared_distribution), [RReliefF](http://lkm.fri.uni-lj.si/rmarko/papers/robnik03-mlj.pdf), and [Univariate Linear Regression](http://scikit-learn.org/stable/modules/feature_selection.html#feature-selection-using-selectfrommodel). - - *Strategy* refers to how many variables should be on the output. *Fixed* returns a fixed number of top scored variables, while *Percentile* return the selected top percent of the features. -6. *Select random features* outputs either a fixed number of features from the original data or a percentage. This is mainly used for advanced testing and educational purposes. - -![](images/Preprocess2.png) - -1. Normalize adjusts values to a common scale. Center values by mean or median or omit centering altogether. Similar for scaling, one can scale by SD (standard deviation), by span or not at all. -2. Randomize instances. Randomize classes shuffles class values and destroys connection between instances and class. Similarly, one can randomize features or meta data. If replicable shuffling is on, randomization results can be shared and repeated with a saved workflow. This is mainly used for advanced testing and educational purposes. -3. *Remove sparse features* retains features that have more than a number/percentage of non-zero/missing values. The rest are discarded. -4. Principal component analysis outputs results of a PCA transformation. Similar to the [PCA](../unsupervised/PCA.md) widget. -5. [CUR matrix decomposition](https://en.wikipedia.org/wiki/CUR_matrix_approximation) is a dimensionality reduction method, similar to SVD. - -Examples --------- - -In the first example, we have used the *heart_disease.tab* dataset available in the dropdown menu of the [File](../data/file.md) widget. then we used **Preprocess** to impute missing values and normalize features. We can observe the changes in the [Data Table](../data/datatable.md) and compare it to the non-processed data. - -![](images/Preprocess-Example1.png) - -In the second example, we show how to use **Preprocess** for predictive modeling. - -This time we are using the *heart_disease.tab* data from the [File](../data/file.md) widget. You can access the data in the dropdown menu. This is a dataset with 303 patients that came to the doctor suffering from a chest pain. After the tests were done, some patients were found to have diameter narrowing and others did not (this is our class variable). - -Some values are missing in our data set, so we would like to impute missing values before evaluating the model. We do this by passing a preprocessor directly to [Test and Score](../evaluate/testandscore.md). In **Preprocess**, we set the correct preprocessing pipeline (in our example only a single preprocessor with *Impute missing values*), then connect it to the Preprocessor input of Test and Score. - -We also pass the data and the learner (in this case, a [Logistic Regression](../model/logisticregression.md)). This is the correct way to pass a preprocessor to cross-validation as each fold will independently get preprocessed in the training phase. This is particularly important for feature selection. - -![](images/Preprocess-Example2.png) diff --git a/doc/visual-programming/source/widgets/data/purgedomain.md b/doc/visual-programming/source/widgets/data/purgedomain.md deleted file mode 100644 index 22a284fb6bb..00000000000 --- a/doc/visual-programming/source/widgets/data/purgedomain.md +++ /dev/null @@ -1,41 +0,0 @@ -Purge Domain -============ - -Removes unused attribute values and useless attributes, sorts the remaining values. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: filtered dataset - -Definitions of nominal attributes sometimes contain values which don’t appear in the data. Even if this does not happen in the original data, filtering the data, selecting exemplary subsets and alike can remove all examples for which the attribute has some particular value. Such values clutter data presentation, especially various visualizations, and should be removed. - -After purging an attribute, it may become single-valued or, in extreme case, have no values at all (if the value of this attribute was undefined for all examples). In such cases, the attribute can be removed. - -A different issue is the order of attribute values: if the data is read from a file in a format in which values are not declared in advance, they are sorted “in order of appearance”. Sometimes we would prefer to have them sorted alphabetically. - -![](images/PurgeDomain-stamped.png) - -1. Purge attributes. -2. Purge classes. -3. Purge meta attributes. -4. Information on the filtering process. -5. Produce a report. -6. If *Apply automatically* is ticked, the widget will output data at - each change of widget settings. - -Such purification is done by the widget **Purge Domain**. Ordinary attributes and class attributes are treated separately. For each, we can decide if we want the values sorted or not. Next, we may allow the widget to remove attributes with less than two values or remove the class attribute if there are less than two classes. Finally, we can instruct the widget to check which values of attributes actually appear in the data and remove the unused values. The widget cannot remove values if it is not allowed to remove the attributes, since having attributes without values makes no sense. - -The new, reduced attributes get the prefix “R”, which distinguishes them from the original ones. The values of new attributes can be computed from the old ones, but not the other way around. This means that if you construct a classifier from the new attributes, you can use it to classify the examples described by the original attributes. But not the opposite: constructing a classifier from the old attributes and using it on examples described by the reduced ones won’t work. Fortunately, the latter is seldom the case. In a typical setup, one would explore the data, visualize it, filter it, purify it… and then test the final model on the original data. - -Example -------- - -The **Purge Domain** widget would typically appear after data filtering, for instance when selecting a subset of visualized examples. - -In the above schema, we play with the *adult.tab* dataset: we visualize it and select a portion of the data, which contains only four out of the five original classes. To get rid of the empty class, we put the data through **Purge Domain** before going on to the [Box Plot](../visualize/boxplot.md) widget. The latter shows only the four classes which are in the **Purge Data** output. To see the effect of data purification, uncheck *Remove unused class variable values* and observe the effect this has on [Box Plot](../visualize/boxplot.md). - -![](images/PurgeDomain-example.png) diff --git a/doc/visual-programming/source/widgets/data/pythonscript.md b/doc/visual-programming/source/widgets/data/pythonscript.md deleted file mode 100644 index 6c91b1bca28..00000000000 --- a/doc/visual-programming/source/widgets/data/pythonscript.md +++ /dev/null @@ -1,88 +0,0 @@ -Python Script -============= - -Extends functionalities through Python scripting. - -**Inputs** - -- Data (Orange.data.Table): input dataset bound to ``in_data`` variable -- Learner (Orange.classification.Learner): input learner bound to ``in_learner`` variable -- Classifier (Orange.classification.Learner): input classifier bound to ``in_classifier`` variable -- Object: input Python object bound to ``in_object`` variable - -**Outputs** - -- Data (Orange.data.Table): dataset retrieved from ``out_data`` variable -- Learner (Orange.classification.Learner): learner retrieved from ``out_learner`` variable -- Classifier (Orange.classification.Learner): classifier retrieved from ``out_classifier`` variable -- Object: Python object retrieved from ``out_object`` variable - -**Python Script** widget can be used to run a python script in the input, when a suitable functionality is not implemented in an existing widget. The script has ``in_data``, ``in_distance``, ``in_learner``, ``in_classifier`` and ``in_object`` variables (from input signals) in its local namespace. If a signal is not connected or it did not yet receive any data, those variables contain ``None``. - -After the script is executed variables from the script’s local namespace are extracted and used as outputs of the widget. The widget can be further connected to other widgets for visualizing the output. - -For instance the following script would simply pass on all signals it receives: - - out_data = in_data - out_distance = in_distance - out_learner = in_learner - out_classifier = in_classifier - out_object = in_object - -Note: You should not modify the input objects in place. - -![](images/PythonScript-stamped.png) - -1. Info box contains names of basic operators for Orange Python script. -2. The *Library* control can be used to manage multiple scripts. Pressing "+" will add a new entry and open it in the *Python script* editor. When the script is modified, its entry in the *Library* will change to indicate it has unsaved changes. Pressing *Update* will save the script (keyboard shortcut "Ctrl+S"). A script can be removed by selecting it and pressing the "-" button. -3. Pressing *Execute* in the *Run* box executes the script (keyboard shortcut "Ctrl+R"). Any script output (from ``print``) is captured and displayed in the *Console* below the script. -4. The *Python script* editor on the left can be used to edit a script (it supports some rudimentary syntax highlighting). -5. Console displays the output of the script. - -Examples --------- - -Python Script widget is intended to extend functionalities for advanced users. Classes from Orange library are described in the [documentation](https://docs.biolab.si/3/data-mining-library/#reference). To find further information about orange Table class see [Table](https://docs.biolab.si/3/data-mining-library/reference/data.table.html), [Domain](https://docs.biolab.si/3/data-mining-library/reference/data.domain.html), and [Variable](https://docs.biolab.si/3/data-mining-library/reference/data.variable.html) documentation. - -One can, for example, do batch filtering by attributes. We used zoo.tab for the example and we filtered out all the attributes that have more than 5 discrete values. This in our case removed only 'leg' attribute, but imagine an example where one would have many such attributes. - - from Orange.data import Domain, Table - domain = Domain([attr for attr in in_data.domain.attributes - if attr.is_continuous or len(attr.values) <= 5], - in_data.domain.class_vars) - out_data = Table(domain, in_data) - -![](images/PythonScript-filtering.png) - -The second example shows how to round all the values in a few lines of code. This time we used wine.tab and rounded all the values to whole numbers. - - import numpy as np - out_data = in_data.copy() - #copy, otherwise input data will be overwritten - np.round(out_data.X, 0, out_data.X) - -![](images/PythonScript-round.png) - -The third example introduces some Gaussian noise to the data. Again we make a copy of the input data, then walk through all the values with a double for loop and add random noise. - - import random - from Orange.data import Domain, Table - new_data = in_data.copy() - for inst in new_data: - for f in inst.domain.attributes: - inst[f] += random.gauss(0, 0.02) - out_data = new_data - -![](images/PythonScript-gauss.png) - -The final example uses Orange3-Text add-on. **Python Script** is very useful for custom preprocessing in text mining, extracting new features from strings, or utilizing advanced *nltk* or *gensim* functions. Below, we simply tokenized our input data from *deerwester.tab* by splitting them by whitespace. - - print('Running Preprocessing ...') - tokens = [doc.split(' ') for doc in in_data.documents] - print('Tokens:', tokens) - out_object = in_data - out_object.store_tokens(tokens) - -You can add a lot of other preprocessing steps to further adjust the output. The output of **Python Script** can be used with any widget that accepts the type of output your script produces. In this case, connection is green, which signalizes the right type of input for Word Cloud widget. - -![](images/PythonScript-Example3.png) diff --git a/doc/visual-programming/source/widgets/data/randomize.md b/doc/visual-programming/source/widgets/data/randomize.md deleted file mode 100644 index 672e03ac6c2..00000000000 --- a/doc/visual-programming/source/widgets/data/randomize.md +++ /dev/null @@ -1,33 +0,0 @@ -Randomize -========= - -Shuffles classes, attributes and/or metas of an input dataset. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: randomized dataset - -The **Randomize** widget receives a dataset in the input and outputs the same dataset in which the classes, attributes or/and metas are shuffled. - -![](images/Randomize-Default.png) - -1. Select group of columns of the dataset you want to shuffle. -2. Select proportion of the dataset you want to shuffle. -3. Produce replicable output. -4. If *Apply automatically* is ticked, changes are committed automatically. Otherwise, you have to press *Apply* after each change. -5. Produce a report. - -Example -------- - -The **Randomize** widget is usually placed right after (e.g. [File](../data/file.md) widget. The basic usage is shown in the following workflow, where values of class variable of Iris dataset are randomly shuffled. - -![](images/Randomize-Example1.png) - -In the next example we show how shuffling class values influences model performance on the same dataset as above. - -![](images/Randomize-Example2.png) diff --git a/doc/visual-programming/source/widgets/data/rank.md b/doc/visual-programming/source/widgets/data/rank.md deleted file mode 100644 index 4f99fb094d6..00000000000 --- a/doc/visual-programming/source/widgets/data/rank.md +++ /dev/null @@ -1,70 +0,0 @@ -Rank -==== - -Ranking of attributes in classification or regression datasets. - -**Inputs** - -- Data: input dataset -- Scorer: models for feature scoring - -**Outputs** - -- Reduced Data: dataset with selected attributes -- Scores: data table with feature scores -- Features: list of attributes - -The **Rank** widget scores variables according to their correlation with discrete or numeric target variable, based on applicable internal scorers (like information gain, chi-square and linear regression) and any connected external models that supports scoring, such as linear regression, logistic regression, random forest, SGD, etc. The widget can also handle unsupervised data, but only by external scorers, such as PCA. - -![](images/Rank-stamped.png) - -1. Select scoring methods. See the options for classification, regression and unsupervised data in the **Scoring methods** section. -2. Select attributes to output. *None* won't output any attributes, while *All* will output all of them. With manual selection, select the attributes from the table on the right. *Best ranked* will output n best ranked attributes. - If *Send Automatically* is ticked, the widget automatically communicates changes to other widgets. -3. Status bar. Produce a report by clicking on the file icon. Observe input and output of the widget. On the right, warnings and errors are shown. - -Scoring methods (classification) --------------------------------- - -1. Information Gain: the expected amount of information (reduction of entropy) -2. [Gain Ratio](https://en.wikipedia.org/wiki/Information_gain_ratio): a ratio of the information gain and the attribute's intrinsic information, which reduces the bias towards multivalued features that occurs in information gain -3. [Gini](https://en.wikipedia.org/wiki/Gini_coefficient): the inequality among values of a frequency distribution -4. [ANOVA](https://en.wikipedia.org/wiki/One-way_analysis_of_variance): the difference between average values of the feature in different classes -5. [Chi2](https://en.wikipedia.org/wiki/Chi-squared_distribution): dependence between the feature and the class as measured by the chi-square statistic -6. [ReliefF](https://en.wikipedia.org/wiki/Relief_(feature_selection)): the ability of an attribute to distinguish between classes on similar data instances -7. [FCBF (Fast Correlation Based Filter)](https://www.aaai.org/Papers/ICML/2003/ICML03-111.pdf): entropy-based measure, which also identifies redundancy due to pairwise correlations between features - -Additionally, you can connect certain learners that enable scoring the features according to how important they are in models that the learners build (e.g. [Logistic Regression](../model/logisticregression.md), [Random Forest](../model/randomforest.md), [SGD](../model/stochasticgradient.md)). Please note that the data is normalized before ranking. - -Scoring methods (regression) ----------------------------- - -1. [Univariate Regression](https://en.wikipedia.org/wiki/Simple_linear_regression): linear regression for a single variable -2. [RReliefF](http://www.clopinet.com/isabelle/Projects/reading/robnik97-icml.pdf): relative distance between the predicted (class) values of the two instances. - -Additionally, you can connect regression learners (e.g. [Linear Regression](../model/linearregression.md), [Random Forest](../model/randomforest.md), [SGD](../model/stochasticgradient.md)). Please note that the data is normalized before ranking. - -Scoring method (unsupervised) ------------------------------ - -Currently, only [PCA](../unsupervised/PCA.md) is supported for unsupervised data. Connect PCA to Rank to obtain the scores. The scores correspond to the correlation of a variable with the individual principal component. - -Example: Attribute Ranking and Selection ----------------------------------------- - -Below, we have used the **Rank** widget immediately after the [File](../data/file.md) widget to reduce the set of data attributes and include only the most informative ones: - -![](images/Rank-Select-Schema.png) - -Notice how the widget outputs a dataset that includes only the best-scored attributes: - -![](images/Rank-Select-Widgets.png) - -Example: Feature Subset Selection for Machine Learning ------------------------------------------------------- - -What follows is a bit more complicated example. In the workflow below, we first split the data into a training set and a test set. In the upper branch, the training data passes through the **Rank** widget to select the most informative attributes, while in the lower branch there is no feature selection. Both feature selected and original datasets are passed to their own [Test & Score](../evaluate/testandscore.md) widgets, which develop a *Naive Bayes* classifier and score it on a test set. - -![](images/Rank-and-Test.png) - -For datasets with many features, a naive Bayesian classifier feature selection, as shown above, would often yield a better predictive accuracy. diff --git a/doc/visual-programming/source/widgets/data/save.md b/doc/visual-programming/source/widgets/data/save.md deleted file mode 100644 index d52025b4358..00000000000 --- a/doc/visual-programming/source/widgets/data/save.md +++ /dev/null @@ -1,36 +0,0 @@ -Save Data -========= - -Saves data to a file. - -**Inputs** - -- Data: input dataset - -The **Save Data** widget considers a dataset provided in the input channel and saves it to a data file with a specified name. It can save the data as: - -- a tab-delimited file (.tab) -- comma-separated file (.csv) -- pickle (.pkl), used for storing preprocessing of [Corpus](https://orange.biolab.si/widget-catalog/text-mining/corpus-widget/) objects -- Excel spreadsheets (.xlsx) -- spectra ASCII (.dat) -- hyperspectral map ASCII (.xyz) -- compressed formats (.tab.gz, .csv.gz, .pkl.gz) - -The widget does not save the data every time it receives a new signal in the input as this would constantly (and, mostly, inadvertently) overwrite the file. Instead, the data is saved only after a new file name is set or the user pushes the *Save* button. - -If the file is saved to the same directory as the workflow or in the subtree of that directory, the widget remembers the relative path. Otherwise, it will store an absolute path but disable auto save for security reasons. - -![](images/SaveData.png) - -- *Add type annotations to header*: Include Orange's three-row header in the output file. -- *Autosave when receiving new data*: Always save new data. Be careful! This will overwrite existing data on your system. -- *Save* by overwriting the existing file. -- *Save as* to create a new file. - -Example -------- - -In the workflow below, we used the *Zoo* dataset. We loaded the data into the [Scatter Plot](../visualize/scatterplot.md) widget, with which we selected a subset of data instances and pushed them to the **Save Data** widget to store them in a file. - -![](images/Save-Workflow.png) diff --git a/doc/visual-programming/source/widgets/data/select-by-data-index.md b/doc/visual-programming/source/widgets/data/select-by-data-index.md deleted file mode 100644 index 794968a88e7..00000000000 --- a/doc/visual-programming/source/widgets/data/select-by-data-index.md +++ /dev/null @@ -1,33 +0,0 @@ -Select by Data Index -==================== - -Match instances by index from data subset. - -**Inputs** - -- Data: reference data set -- Data Subset: subset to match - -**Outputs** - -- Matching data: subset from reference data set that matches indices from subset data -- Unmatched data: subset from reference data set that does not match indices from subset data -- Annotated data: reference data set with an additional column defining matches - -**Select by Data Index** enables matching the data by indices. Each row in a data set has an index and given a subset, this widget can match these indices to indices from the reference data. Most often it is used to retrieve the original data from the transformed data (say, from PCA space). - -![](images/Select-by-Data-Index-stamped.png) - -1. Information on the reference data set. This data is used as index reference. -2. Information on the data subset. The indices of this data set are used to find matching data in the reference data set. Matching data are on the output by default. - -Example -------- - -A typical use of **Select by Data Index** is to retrieve the original data after a transformation. We will load *iris.tab* data in the [File](../data/file.md) widget. Then we will transform this data with [PCA](../unsupervised/PCA.md). We can project the transformed data in a [Scatter Plot](../visualize/scatterplot.md), where we can only see PCA components and not the original features. - -Now we will select an interesting subset (we could also select the entire data set). If we observe it in a [Data Table](../data/datatable.md), we can see that the data is transformed. If we would like to see this data with the original features, we will have to retrieve them with **Select by Data Index**. - -Connect the original data and the subset from [Scatter Plot](../visualize/scatterplot.md) to **Select by Data Index**. The widget will match the indices of the subset with the indices of the reference (original) data and output the matching reference data. A final inspection in another [Data Table](../data/datatable.md) confirms the data on the output is from the original data space. - -![](images/Select-by-Data-Index-Example1.png) diff --git a/doc/visual-programming/source/widgets/data/selectcolumns.md b/doc/visual-programming/source/widgets/data/selectcolumns.md deleted file mode 100644 index 7615d040076..00000000000 --- a/doc/visual-programming/source/widgets/data/selectcolumns.md +++ /dev/null @@ -1,38 +0,0 @@ -Select Columns -============== - -Manual selection of data attributes and composition of data domain. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: dataset with columns as set in the widget - -The **Select Columns** widget is used to manually compose your [data domain](https://en.wikipedia.org/wiki/Data_domain). The user can decide which attributes will be used and how. Orange distinguishes between ordinary attributes, (optional) class attributes and meta attributes. For instance, for building a classification model, the domain would be composed of a set of attributes and a discrete class attribute. Meta attributes are not used in modeling, but several widgets can use them as instance labels. - -Orange attributes have a type and are either discrete, continuous or a character string. The attribute type is marked with a symbol appearing before the name of the attribute (D, C, S, respectively). - -![](images/SelectColumns-stamped.png) - -1. Left-out data attributes that will not be in the output data file -2. Data attributes in the new data file -3. Target variable. If none, the new dataset will be without a target variable. -4. Meta attributes of the new data file. These attributes are included in the dataset but are, for most methods, not considered in the analysis. -5. Produce a report. -6. Reset the domain composition to that of the input data file. -7. Tick if you wish to auto-apply changes of the data domain. -8. Apply changes of the data domain and send the new data file to the output channel of the widget. - -Examples --------- - -In the workflow below, the *Iris* data from the [File](../data/file.md) widget is fed into the **Select Columns** widget, where we select to output only two attributes (namely petal width and petal length). We view both the original dataset and the dataset with selected columns in the [Data Table](../data/datatable.md) widget. - -![](images/SelectColumns-Example1.png) - -For a more complex use of the widget, we composed a workflow to redefine the classification problem in the *heart-disease* dataset. Originally, the task was to predict if the patient has a coronary artery diameter narrowing. We changed the problem to that of gender classification, based on age, chest pain and cholesterol level, and informatively kept the diameter narrowing as a meta attribute. - -![](images/SelectColumns-Example2.png) diff --git a/doc/visual-programming/source/widgets/data/selectrows.md b/doc/visual-programming/source/widgets/data/selectrows.md deleted file mode 100644 index 91b4f94ebf2..00000000000 --- a/doc/visual-programming/source/widgets/data/selectrows.md +++ /dev/null @@ -1,46 +0,0 @@ -Select Rows -=========== - -Selects data instances based on conditions over data features. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Matching Data: instances that match the conditions -- Non-Matching Data: instances that do not match the conditions -- Data: data with an additional column showing whether a instance is selected - -This widget selects a subset from an input dataset, based on user-defined conditions. Instances that match the selection rule are placed in the output *Matching Data* channel. - -Criteria for data selection are presented as a collection of conjunct terms (i.e. selected items are those matching all the terms in '*Conditions*'). - -Condition terms are defined through selecting an attribute, selecting an operator from a list of operators, and, if needed, defining the value to be used in the condition term. Operators are different for discrete, continuous and string attributes. - -![](images/SelectRows-stamped.png) - -1. Conditions you want to apply, their operators and related values -2. Add a new condition to the list of conditions. -3. Add all the possible variables at once. -4. Remove all the listed variables at once. -5. Information on the input dataset and information on instances that match the condition(s) -6. Purge the output data. -7. When the *Send automatically* box is ticked, all changes will be automatically communicated to other widgets. -8. Produce a report. - -Any change in the composition of the condition will update the information pane (*Data Out*). - -If *Send automatically* is selected, then the output is updated on any change in the composition of the condition or any of its terms. - -Example -------- - -In the workflow below, we used the *Zoo* data from the [File](../data/file.md) widget and fed it into the **Select Rows** widget. In the widget, we chose to output only two animal types, namely fish and reptiles. We can inspect both the original dataset and the dataset with selected rows in the [Data Table](../data/datatable.md) widget. - -![](images/SelectRows-Example.png) - -In the next example, we used the data from the *Titanic* dataset and similarly fed it into the [Box Plot](../visualize/boxplot.md) widget. We first observed the entire dataset based on survival. Then we selected only first class passengers in the **Select Rows** widget and fed it again into the [Box Plot](../visualize/boxplot.md). There we could see all the first class passengers listed by their survival rate and grouped by gender. - -![](images/SelectRows-Workflow.png) diff --git a/doc/visual-programming/source/widgets/data/sqltable.md b/doc/visual-programming/source/widgets/data/sqltable.md deleted file mode 100644 index 890804c4886..00000000000 --- a/doc/visual-programming/source/widgets/data/sqltable.md +++ /dev/null @@ -1,57 +0,0 @@ - -SQL Table -========= - -Reads data from an SQL database. - -**Outputs** - -- Data: dataset from the database - -The **SQL** widget accesses data stored in an SQL database. It can connect to PostgreSQL (requires [psycopg2](http://initd.org/psycopg/) module) or [SQL Server](https://www.microsoft.com/en-us/sql-server/) (requires [pymssql](http://pymssql.org/en/stable/) module). - -To handle large databases, Orange attempts to execute a part of the computation in the database itself without downloading the data. This only works with PostgreSQL database and requires quantile and tsm_system_time [extensions](https://github.com/biolab/orange3/wiki/Installation-of-SQL-extensions) installed on server. If these extensions are not installed, the data will be downloaded locally. - -![](images/SQLTable-stamped.png) - -1. Database type (can be either PostgreSQL or MSSQL). -2. Host name. -3. Database name. -4. Username. -5. Password. -6. Press the blue button to connect to the database. Then select the table in the dropdown. -7. *Auto-discover categorical variables* will cast INT and CHAR columns with less than 20 distinct values as categorical variables (finding all distinct values can be slow on large tables). When not selected, INT will be treated as numeric and CHAR as text. *Download to local memory* downloads the selected table to your local machine. - -##Installation Instructions - -###PostgreSQL - -Install the backend. - - pip install psycopg2 - -Alternatively, you can follow [these instructions](https://blog.biolab.si/2018/02/16/how-to-enable-sql-widget-in-orange/) for installing the backend. - -If the installation of `psycopg2` fails, follow to instructions in the error message you get (it explains how to solve the error) or install an already compiled version of `psycopg2-binary` package: - - pip install psycopg2-binary - -Note: `psycopg2-binary` comes with own versions of a few C libraries, among which libpq and libssl, which will be used regardless of other libraries available on the client: upgrading the system libraries will not upgrade the libraries used by psycopg2. Please build psycopg2 from source if you want to maintain binary upgradeability. - -[Install the extensions](https://github.com/biolab/orange3/wiki/Installation-of-SQL-extensions). [optional] - -###MSSQL - -Install the backend. - - pip install pymssql - -If you are encountering issues, follow [these instructions](https://github.com/biolab/orange3/wiki/Installation-of-SQL-extensions#mssql). - -##Example - -Here is a simple example on how to use the **SQL Table** widget. Place the widget on the canvas, enter your database credentials and connect to your database. Then select the table you wish to analyse. - -Connect **SQL Table** to [Data Table](../data/datatable.md) widget to inspect the output. If the table is populated, your data has transferred correctly. Now, you can use the **SQL Table** widget in the same way as the [File](../data/file.md) widget. - -![](images/SQLTable-Example.png) diff --git a/doc/visual-programming/source/widgets/data/transpose.md b/doc/visual-programming/source/widgets/data/transpose.md deleted file mode 100644 index f51cbee9e0b..00000000000 --- a/doc/visual-programming/source/widgets/data/transpose.md +++ /dev/null @@ -1,23 +0,0 @@ -Transpose -========= - -Transposes a data table. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: transposed dataset - -**Transpose** widget transposes data table. - -![](images/transpose-stamped.png) - -Example -------- - -This is a simple workflow showing how to use **Transpose**. Connect the widget to [File](../data/file.md) widget. The output of **Transpose** is a transposed data table with rows as columns and columns as rows. You can observe the result in a [Data Table](../data/datatable.md). - -![](images/transpose-example.png) diff --git a/doc/visual-programming/source/widgets/data/unique.md b/doc/visual-programming/source/widgets/data/unique.md deleted file mode 100644 index a4135857d8f..00000000000 --- a/doc/visual-programming/source/widgets/data/unique.md +++ /dev/null @@ -1,26 +0,0 @@ -Unique -====== - -Remove duplicated data instances. - -**Inputs** - -- Data: data table - -**Outputs** - -- Data: data table without duplicates - -The widget removes duplicated data instances. The user can choose a subset of observed variables, so two instances are considered as duplicates although they may differ in values of other, ignored variables. - -![](images/Unique-stamped.png) - -1. Select the variables that are considered in comparing data instances. -2. Data instance that is kept. The options are to use the first, last, middle or random instance, or to keep none, that is, to remove duplicated instances altogether. - -Example -------- - -Data set *Zoo* contains two frogs. This workflow keeps only one by removing instances with the same names. - -![](images/Unique-Example.png) \ No newline at end of file diff --git a/doc/visual-programming/source/widgets/data/zoo-first.tab b/doc/visual-programming/source/widgets/data/zoo-first.tab deleted file mode 100644 index caaedd5c8e0..00000000000 --- a/doc/visual-programming/source/widgets/data/zoo-first.tab +++ /dev/null @@ -1,9 +0,0 @@ -name hair feathers eggs milk airborne aquatic predator toothed backbone breathes venomous fins legs tail domestic catsize type -string d d d d d d d d d d d d d d d d d - class -aardvark 1 0 0 1 0 0 1 1 1 1 0 0 4 0 0 1 mammal -antelope 1 0 0 1 0 0 0 1 1 1 0 0 4 1 0 1 mammal -bass 0 0 1 0 0 1 1 1 1 0 0 1 0 1 0 0 fish -bear 1 0 0 1 0 0 1 1 1 1 0 0 4 0 0 1 mammal -boar 1 0 0 1 0 0 1 1 1 1 0 0 4 1 0 1 mammal -buffalo 1 0 0 1 0 0 0 1 1 1 0 0 4 1 0 1 mammal diff --git a/doc/visual-programming/source/widgets/data/zoo-only-images.tab b/doc/visual-programming/source/widgets/data/zoo-only-images.tab deleted file mode 100644 index a0c6d3f2788..00000000000 --- a/doc/visual-programming/source/widgets/data/zoo-only-images.tab +++ /dev/null @@ -1 +0,0 @@ -name images string string meta meta type=image antelope http://icons.iconarchive.com/icons/joseph-aeron/us-fish-and-wildlife-service/128/antelope-icon.png bass http://images2.fanpop.com/images/photos/5700000/Largemouth-Bass-fishing-5708828-120-106.jpg bear http://icons.iconarchive.com/icons/iconshock/alaska/256/Polar-Bear-icon.png boar https://pbs.twimg.com/profile_images/2967299392/e0aa28ab427452deacc567a8f2816b3f.jpeg carp http://www.landbigfish.com/images/fish/LBF_Common_Carp.jpg catfish http://www.agfc.com/speciesPhotos/fish_catfish_yellowbullhead.jpg chicken http://latimesblogs.latimes.com/.a/6a00d8341c630a53ef01156f75357a970c-400wi deer http://icons.iconarchive.com/icons/joseph-aeron/us-fish-and-wildlife-service/128/deer-icon.png dolphin http://f0.pepst.com/c/D7D076/36618/ssc3/home/023/deepakjain/albums/dolphin_14kb.jpg_480_480_0_64000_0_1_0.jpg duck http://i.dailymail.co.uk/i/pix/2009/05/21/article-1185197-05075EEF000005DC-380_468x286.jpg gull http://www.allaboutbirds.org/guide/PHOTO/LARGE/herring_gull_adult_breeding2.jpg haddock http://www.oceantrawlers.com/sites/default/files/haddock_0.png?1317994124 hamster http://img3.wikia.nocookie.net/__cb20130325185045/animalcrossing/images/4/49/Tumblr_lvrcmvCpsS1qbeyouo1_500.jpg kiwi http://i.telegraph.co.uk/multimedia/archive/01891/kiwi_1891642c.jpg mink http://i.dailymail.co.uk/i/pix/2012/05/15/article-2144681-0D4F15C800000578-601_468x370.jpg \ No newline at end of file diff --git a/doc/visual-programming/source/widgets/data/zoo-second.tab b/doc/visual-programming/source/widgets/data/zoo-second.tab deleted file mode 100644 index 6be93d2af88..00000000000 --- a/doc/visual-programming/source/widgets/data/zoo-second.tab +++ /dev/null @@ -1,14 +0,0 @@ -name hair feathers eggs milk airborne aquatic predator toothed backbone breathes venomous fins legs tail domestic catsize type -string d d d d d d d d d d d d d d d d d - class -calf 1 0 0 1 0 0 0 1 1 1 0 0 4 1 1 1 mammal -carp 0 0 1 0 0 1 0 1 1 0 0 1 0 1 1 0 fish -catfish 0 0 1 0 0 1 1 1 1 0 0 1 0 1 0 0 fish -cavy 1 0 0 1 0 0 0 1 1 1 0 0 4 0 1 0 mammal -cheetah 1 0 0 1 0 0 1 1 1 1 0 0 4 1 0 1 mammal -chicken 0 1 1 0 1 0 0 0 1 1 0 0 2 1 1 0 bird -chub 0 0 1 0 0 1 1 1 1 0 0 1 0 1 0 0 fish -clam 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 invertebrate -crab 0 0 1 0 0 1 1 0 0 0 0 0 4 0 0 0 invertebrate -crayfish 0 0 1 0 0 1 1 0 0 0 0 0 6 0 0 0 invertebrate -crow 0 1 1 0 1 0 1 0 1 1 0 0 2 1 0 0 bird \ No newline at end of file diff --git a/doc/visual-programming/source/widgets/data/zoo-with-images.tab b/doc/visual-programming/source/widgets/data/zoo-with-images.tab deleted file mode 100644 index a779be7c0c5..00000000000 --- a/doc/visual-programming/source/widgets/data/zoo-with-images.tab +++ /dev/null @@ -1 +0,0 @@ -catsize predator aquatic fins feathers type name images d d d d d d string string class meta meta type=image 1 0 0 0 0 mammal antelope http://icons.iconarchive.com/icons/joseph-aeron/us-fish-and-wildlife-service/128/antelope-icon.png 0 1 1 1 0 fish bass http://images2.fanpop.com/images/photos/5700000/Largemouth-Bass-fishing-5708828-120-106.jpg 1 1 0 0 0 mammal bear http://icons.iconarchive.com/icons/iconshock/alaska/256/Polar-Bear-icon.png 1 1 0 0 0 mammal boar https://pbs.twimg.com/profile_images/2967299392/e0aa28ab427452deacc567a8f2816b3f.jpeg 0 0 1 1 0 fish carp http://www.landbigfish.com/images/fish/LBF_Common_Carp.jpg 0 1 1 1 0 fish catfish http://www.agfc.com/speciesPhotos/fish_catfish_yellowbullhead.jpg 0 0 0 0 1 bird chicken http://latimesblogs.latimes.com/.a/6a00d8341c630a53ef01156f75357a970c-400wi 1 0 0 0 0 mammal deer http://icons.iconarchive.com/icons/joseph-aeron/us-fish-and-wildlife-service/128/deer-icon.png 1 1 1 1 0 mammal dolphin http://f0.pepst.com/c/D7D076/36618/ssc3/home/023/deepakjain/albums/dolphin_14kb.jpg_480_480_0_64000_0_1_0.jpg 0 0 1 0 1 bird duck http://i.dailymail.co.uk/i/pix/2009/05/21/article-1185197-05075EEF000005DC-380_468x286.jpg 0 1 1 0 1 bird gull http://www.allaboutbirds.org/guide/PHOTO/LARGE/herring_gull_adult_breeding2.jpg 0 0 1 1 0 fish haddock http://www.oceantrawlers.com/sites/default/files/haddock_0.png?1317994124 0 0 0 0 0 mammal hamster http://img3.wikia.nocookie.net/__cb20130325185045/animalcrossing/images/4/49/Tumblr_lvrcmvCpsS1qbeyouo1_500.jpg 0 1 0 0 1 bird kiwi http://i.telegraph.co.uk/multimedia/archive/01891/kiwi_1891642c.jpg 1 1 1 0 0 mammal mink http://i.dailymail.co.uk/i/pix/2012/05/15/article-2144681-0D4F15C800000578-601_468x370.jpg \ No newline at end of file diff --git a/doc/visual-programming/source/widgets/evaluate/calibrationplot.md b/doc/visual-programming/source/widgets/evaluate/calibrationplot.md deleted file mode 100644 index 0e09b036fe7..00000000000 --- a/doc/visual-programming/source/widgets/evaluate/calibrationplot.md +++ /dev/null @@ -1,27 +0,0 @@ -Calibration Plot -================ - -Shows the match between classifiers' probability predictions and actual class probabilities. - -**Inputs** - -- Evaluation Results: results of testing classification algorithms - -The [Calibration Plot](https://en.wikipedia.org/wiki/Calibration_curve)plots class probabilities against those predicted by the classifier(s). - -![](images/CalibrationPlot-stamped.png) - -1. Select the desired target class from the drop down menu. -2. Choose which classifiers to plot. The diagonal represents optimal behavior; the closer the classifier's curve gets, the more accurate its prediction probabilities are. Thus we would use this widget to see whether a classifier is overly optimistic (gives predominantly positive results) or pessimistic (gives predominantly negative results). -3. If *Show rug* is enabled, ticks are displayed at the bottom and the top of the graph, which represent negative and positive examples respectively. Their position corresponds to the classifier's probability prediction and the color shows the classifier. At the bottom of the graph, the points to the left are those which are (correctly) assigned a low probability of the target class, and those to the right are incorrectly assigned high probabilities. At the top of the graph, the instances to the right are correctly assigned high probabilities and vice versa. -4. Press *Save Image* if you want to save the created image to your computer in a .svg or .png format. -5. Produce a report. - -Example -------- - -At the moment, the only widget which gives the right type of signal needed by the **Calibration Plot** is [Test & Score](../evaluate/testandscore.md). The Calibration Plot will hence always follow Test & Score and, since it has no outputs, no other widgets follow it. - -Here is a typical example, where we compare three classifiers (namely [Naive Bayes](../model/naivebayes.md), [Tree](../model/tree.md) and [Constant](../model/constant.md)) and input them into [Test & Score](../evaluate/testandscore.md). We used the *Titanic* dataset. Test & Score then displays evaluation results for each classifier. Then we draw **Calibration Plot** and [ROC Analysis](../evaluate/rocanalysis.md) widgets from Test & Score to further analyze the performance of classifiers. **Calibration Plot** enables you to see prediction accuracy of class probabilities in a plot. - -![](images/CalibrationPlot-example.png) diff --git a/doc/visual-programming/source/widgets/evaluate/confusionmatrix.md b/doc/visual-programming/source/widgets/evaluate/confusionmatrix.md deleted file mode 100644 index 873ff427e36..00000000000 --- a/doc/visual-programming/source/widgets/evaluate/confusionmatrix.md +++ /dev/null @@ -1,50 +0,0 @@ -Confusion Matrix -================ - -Shows proportions between the predicted and actual class. - -**Inputs** - -- Evaluation results: results of testing classification algorithms - -**Outputs** - -- Selected Data: data subset selected from confusion matrix -- Data: data with the additional information on whether a data instance was selected - -The [Confusion Matrix](https://en.wikipedia.org/wiki/Confusion_matrix) gives the number/proportion of instances between the predicted and actual class. The selection of the elements in the matrix feeds the corresponding instances into the output signal. This way, one can observe which specific instances were misclassified and how. - -The widget usually gets the evaluation results from [Test & Score](../evaluate/testandscore.md); an example of the schema is shown below. - -![](images/ConfusionMatrix-stamped.png) - -1. When evaluation results contain data on multiple learning algorithms, we have to choose one in the *Learners* box. - The snapshot shows the confusion matrix for [Tree](../model/tree.md) and [Naive Bayesian](../model/naivebayes.md) models trained and tested on the *iris* data. The right-hand side of the widget contains the matrix for the naive Bayesian model (since this model is selected on the left). Each row corresponds to a correct class, while columns represent the predicted classes. For instance, four instances of *Iris-versicolor* were misclassified as *Iris-virginica*. The rightmost column gives the number of instances from each class (there are 50 irises of each of the three classes) and the bottom row gives the number of instances classified into each class (e.g., 48 instances were classified into virginica). -2. In *Show*, we select what data we would like to see in the matrix. - - **Number of instances** shows correctly and incorrectly classified instances numerically. - - **Proportions of predicted** shows how many instances classified as, say, *Iris-versicolor* are in which true class; in the table we can read the 0% of them are actually setosae, 88.5% of those classified as versicolor are versicolors, and 7.7% are virginicae. - - **Proportions of actual** shows the opposite relation: of all true versicolors, 92% were classified as versicolors and 8% as virginicae. - ![](images/ConfusionMatrix-propTrue.png) -3. In *Select*, you can choose the desired output. - - **Correct** sends all correctly classified instances to the output by selecting the diagonal of the matrix. - - **Misclassified** selects the misclassified instances. - - **None** annuls the selection. - As mentioned before, one can also select individual cells of the table to select specific kinds of misclassified instances (e.g. the versicolors classified as virginicae). -4. When sending selected instances, the widget can add new attributes, such as predicted classes or their probabilities, if the corresponding options *Predictions* and/or *Probabilities* are checked. -5. The widget outputs every change if *Send Automatically* is ticked. If not, the user will need to click *Send Selected* to commit the changes. -6. Produce a report. - -Example -------- - -The following workflow demonstrates what this widget can be used for. - -![](images/ConfusionMatrix-Schema.png) - -[Test & Score](../evaluate/testandscore.md) gets the data from [File](../data/file.md) and two learning algorithms from [Naive Bayes](../model/naivebayes.md) and [Tree](../model/tree.md). It performs cross-validation or some other train-and-test procedures to get class predictions by both algorithms for all (or some) data instances. The test results are fed into the **Confusion Matrix**, where we can observe how many instances were misclassified and in which way. - -In the output, we used [Data Table](../data/datatable.md) to show the instances we selected in the confusion matrix. If we, for instance, click *Misclassified*, the table will contain all instances which were misclassified by the selected method. - -The [Scatter Plot](../visualize/scatterplot.md) gets two sets of data. From the [File](../data/file.md) widget it gets the complete data, while the confusion matrix sends only the selected data, misclassifications for instance. The scatter plot will show all the data, with bold symbols representing the selected data. - -![](images/ConfusionMatrix-Example.png) diff --git a/doc/visual-programming/source/widgets/evaluate/icons/calibration-plot.png b/doc/visual-programming/source/widgets/evaluate/icons/calibration-plot.png deleted file mode 100644 index bbce5c62831..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/icons/calibration-plot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/icons/confusion-matrix.png b/doc/visual-programming/source/widgets/evaluate/icons/confusion-matrix.png deleted file mode 100644 index ace561e5046..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/icons/confusion-matrix.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/icons/lift-curve.png b/doc/visual-programming/source/widgets/evaluate/icons/lift-curve.png deleted file mode 100644 index 134fc149be0..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/icons/lift-curve.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/icons/predictions.png b/doc/visual-programming/source/widgets/evaluate/icons/predictions.png deleted file mode 100644 index 3ef99a4756a..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/icons/predictions.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/icons/roc-analysis.png b/doc/visual-programming/source/widgets/evaluate/icons/roc-analysis.png deleted file mode 100644 index bf1af55fdd5..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/icons/roc-analysis.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/icons/test-and-score.png b/doc/visual-programming/source/widgets/evaluate/icons/test-and-score.png deleted file mode 100644 index 088553170e8..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/icons/test-and-score.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot-example.png b/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot-example.png deleted file mode 100644 index 0a6d54cc558..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot-stamped.png b/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot-stamped.png deleted file mode 100644 index 8e35f979ac0..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot.png b/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot.png deleted file mode 100644 index cfe3aaa3471..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/CalibrationPlot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-Example.png b/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-Example.png deleted file mode 100644 index 2f6515d11bf..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-Schema.png b/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-Schema.png deleted file mode 100644 index aa6b412397e..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-Schema.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-propTrue.png b/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-propTrue.png deleted file mode 100644 index 8f4edffcf82..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-propTrue.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-stamped.png b/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-stamped.png deleted file mode 100644 index c4731323804..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ConfusionMatrix-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-cumulative-gain.png b/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-cumulative-gain.png deleted file mode 100644 index 6450b7d7cf4..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-cumulative-gain.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-example.png b/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-example.png deleted file mode 100644 index ec9e02e0f4c..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-stamped.png b/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-stamped.png deleted file mode 100644 index 10241c49489..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/LiftCurve-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/LiftCurve.png b/doc/visual-programming/source/widgets/evaluate/images/LiftCurve.png deleted file mode 100644 index fd1d60d73cf..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/LiftCurve.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/Predictions-Example1.png b/doc/visual-programming/source/widgets/evaluate/images/Predictions-Example1.png deleted file mode 100644 index 55318051171..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/Predictions-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/Predictions-Example2.png b/doc/visual-programming/source/widgets/evaluate/images/Predictions-Example2.png deleted file mode 100644 index 6626a3d5b48..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/Predictions-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/Predictions-stamped.png b/doc/visual-programming/source/widgets/evaluate/images/Predictions-stamped.png deleted file mode 100644 index 318a1ed5e65..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/Predictions-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ROC-Comparison.png b/doc/visual-programming/source/widgets/evaluate/images/ROC-Comparison.png deleted file mode 100644 index bfcfc7189fc..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ROC-Comparison.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-AUC.png b/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-AUC.png deleted file mode 100644 index 0038b0f56a2..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-AUC.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-Plain.png b/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-Plain.png deleted file mode 100644 index 7bf29e84d4a..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-Plain.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-basic-stamped.png b/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-basic-stamped.png deleted file mode 100644 index 528768b98cc..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-basic-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-basic.png b/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-basic.png deleted file mode 100644 index 483cd9700fb..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-basic.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-example.png b/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-example.png deleted file mode 100644 index da5daa7c835..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis.png b/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis.png deleted file mode 100644 index 551f8302943..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/ROCAnalysis.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Classification.png b/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Classification.png deleted file mode 100644 index 5e7b89c603e..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Example.png b/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Example.png deleted file mode 100644 index 41de29d9d5a..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Regression.png b/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Regression.png deleted file mode 100644 index 11e86731584..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-Regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-stamped.png b/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-stamped.png deleted file mode 100644 index b6859931393..00000000000 Binary files a/doc/visual-programming/source/widgets/evaluate/images/TestAndScore-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/evaluate/liftcurve.md b/doc/visual-programming/source/widgets/evaluate/liftcurve.md deleted file mode 100644 index dd2bbd8e5d4..00000000000 --- a/doc/visual-programming/source/widgets/evaluate/liftcurve.md +++ /dev/null @@ -1,34 +0,0 @@ -Lift Curve -========== - -Measures the performance of a chosen classifier against a random classifier. - -**Inputs** - -- Evaluation Results: results of testing classification algorithms - -The **Lift curve** shows the curves for analysing the proportion of true positive data instances in relation to the classifier's threshold or the number of instances that we classify as positive. - -Cumulative gains chart shows the proportion of true positive instances (for example, the number of clients who accept the offer) as a function of the number of positive instances (the number of clients contacted), assuming the the instances are ordered according to the model's probability of being positive (e.g. ranking of clients). - -![](images/LiftCurve-cumulative-gain.png) - -Lift curve shows the ratio between the proportion of true positive instances in the selection and the proportion of customers contacted. See [a tutorial for more details](https://medium.com/analytics-vidhya/understanding-lift-curve-b674d21e426). - -![](images/LiftCurve-stamped.png) - -1. Choose the desired *Target class*. The default is chosen alphabetically. -2. Choose whether to observe lift curve or cumulative gains. -3. If test results contain more than one classifier, the user can choose which curves she or he wants to see plotted. Click on a classifier to select or deselect the curve. -4. *Show lift convex hull* plots a convex hull over lift curves for all classifiers (yellow curve). The curve shows the optimal classifier (or combination thereof) for each desired lift or cumulative gain. -5. Press *Save Image* to save the created image in a .svg or .png format. -6. Produce a report. -7. A plot with **lift** or **cumulative gain** vs. **positive rate**. The dashed line represents the behavior of a random classifier. - - -Example -------- - -The widgets that provide the right type of the signal needed by the **Lift Curve** (evaluation data) are [Test & Score](../evaluate/testandscore.md) and [Predictions](../evaluate/predictions.md). - -In the example below, we observe the lift curve and cumulative gain for the bank marketing data, where the classification goal is to predict whether the client will accept a term deposit offer based on his age, job, education, marital status and similar data. The data set is available in the Datasets widget. We run the learning algorithms in the Test and Score widget and send the results to Lift Curve to see their performance against a random model. Of the two algorithms tested, logistic regression outperforms the naive Bayesian classifier. The curve tells us that by picking the first 20 % of clients as ranked by the model, we are going to hit four times more positive instances than by selecting a random sample with 20 % of clients. diff --git a/doc/visual-programming/source/widgets/evaluate/predictions.md b/doc/visual-programming/source/widgets/evaluate/predictions.md deleted file mode 100644 index f23240e9c47..00000000000 --- a/doc/visual-programming/source/widgets/evaluate/predictions.md +++ /dev/null @@ -1,51 +0,0 @@ -Predictions -=========== - -Shows models' predictions on the data. - -**Inputs** - -- Data: input dataset -- Predictors: predictors to be used on the data - -**Outputs** - -- Predictions: data with added predictions -- Evaluation Results: results of testing classification algorithms - -The widget receives a dataset and one or more predictors (predictive models, not learning algorithms - see the example below). It outputs the data and the predictions. - -![](images/Predictions-stamped.png) - -1. Information on the input, namely the number of instances to predict, the number of predictors and the task (classification or regression). If you have sorted the data table by attribute and you wish to see the original view, press *Restore Original Order*. -2. You can select the options for classification. If *Predicted class* is ticked, the view provides information on predicted class. If *Predicted probabilities for* is ticked, the view provides information on probabilities predicted by the classifier(s). You can also select the predicted class displayed in the view. The option *Draw distribution bars* provides a visualization of probabilities. -3. By ticking the *Show full dataset*, you can view the entire data table (otherwise only class variable will be shown). -4. Select the desired output. -5. Predictions. - -The widget show the probabilities and final decisions of [predictive models](https://en.wikipedia.org/wiki/Predictive_modelling). The output of the widget is another dataset, where predictions are appended as new meta attributes. You can select which features you wish to output (original data, predictions, probabilities). The result can be observed in a [Data Table](../data/datatable.md). If the predicted data includes true class values, the result of prediction can also be observed in a [Confusion Matrix](../evaluate/confusionmatrix.md). - -Examples --------- - -In the first example, we will use *Attrition - Train* data from the [Datasets](../data/datasets.md) widget. This is a data on attrition of employees. In other words, we wish to know whether a certain employee will resign from the job or not. We will construct a predictive model with the [Tree](../model/tree.md) widget and observe probabilities in **Predictions**. - -For predictions we need both the training data, which we have loaded in the first **Datasets** widget and the data to predict, which we will load in another [Datasets](../data/datasets.md) widget. We will use *Attrition - Predict* data this time. Connect the second data set to **Predictions**. Now we can see predictions for the three data instances from the second data set. - -The [Tree](../model/tree.md) model predicts none of the employees will leave the company. You can try other model and see if predictions change. Or test the predictive scores first in the [Test & Score](../evaluate/testandscore.md) widget. - -![](images/Predictions-Example1.png) - -In the second example, we will see how to properly use [Preprocess](../data/preprocess.md) with **Predictions** or [Test & Score](../evaluate/testandscore.md). - -This time we are using the *heart disease.tab* data from the [File](../data/file.md) widget. You can access the data through the dropdown menu. This is a dataset with 303 patients that came to the doctor suffering from a chest pain. After the tests were done, some patients were found to have diameter narrowing and others did not (this is our class variable). - -The heart disease data have some missing values and we wish to account for that. First, we will split the data set into train and test data with [Data Sampler](../data/datasampler.md). - -Then we will send the *Data Sample* into [Preprocess](../data/preprocess.md). We will use *Impute Missing Values*, but you can try any combination of preprocessors on your data. We will send preprocessed data to [Logistic Regression](../model/logisticregression.md) and the constructed model to **Predictions**. - -Finally, **Predictions** also needs the data to predict on. We will use the output of [Data Sampler](../data/datasampler.md) for prediction, but this time not the *Data Sample*, but the *Remaining Data*, this is the data that wasn't used for training the model. - -Notice how we send the remaining data directly to **Predictions** without applying any preprocessing. This is because Orange handles preprocessing on new data internally to prevent any errors in the model construction. The exact same preprocessor that was used on the training data will be used for predictions. The same process applies to [Test & Score](../evaluate/testandscore.md). - -![](images/Predictions-Example2.png) diff --git a/doc/visual-programming/source/widgets/evaluate/rocanalysis.md b/doc/visual-programming/source/widgets/evaluate/rocanalysis.md deleted file mode 100644 index 9908cf671ba..00000000000 --- a/doc/visual-programming/source/widgets/evaluate/rocanalysis.md +++ /dev/null @@ -1,45 +0,0 @@ -ROC Analysis -============ - -Plots a true positive rate against a false positive rate of a test. - -**Inputs** - -- Evaluation Results: results of testing classification algorithms - -The widget shows ROC curves for the tested models and the corresponding convex hull. It serves as a mean of comparison between classification models. The curve plots a false positive rate on an x-axis (1-specificity; probability that target=1 when true value=0) against a true positive rate on a y-axis (sensitivity; probability that target=1 when true value=1). The closer the curve follows the left-hand border and then the top border of the ROC space, the more accurate the classifier. Given the costs of false positives and false negatives, the widget can also determine the optimal classifier and threshold. - -![](images/ROCAnalysis-basic-stamped.png) - -1. Choose the desired *Target Class*. The default class is chosen alphabetically. -2. If test results contain more than one classifier, the user can choose which curves she or he wants to see plotted. Click on a classifier to select or deselect it. -3. When the data comes from multiple iterations of training and testing, such as k-fold cross validation, the results can be (and usually are) averaged. - ![](images/ROC-Comparison.png) - The averaging options are: - - **Merge predictions from folds** (top left), which treats all the test data as if they came from a single iteration - - **Mean TP rate** (top right) averages the curves vertically, showing the corresponding confidence intervals - - **Mean TP and FP at threshold** (bottom left) traverses over threshold, averages the positions of curves and shows horizontal and vertical confidence intervals - - **Show individual curves** (bottom right) does not average but prints all the curves instead -4. Option *Show convex ROC curves* refers to convex curves over each individual classifier (the thin lines positioned over curves). *Show ROC convex hull* plots a convex hull combining all classifiers (the gray area below the curves). Plotting both types of convex curves makes sense since selecting a threshold in a concave part of the curve cannot yield optimal results, disregarding the cost matrix. Besides, it is possible to reach any point on the convex curve by combining the classifiers represented by the points on the border of the concave region. - ![](images/ROCAnalysis-AUC.png) -The diagonal dotted line represents the behavior of a random classifier. The full diagonal line represents iso-performance. A black "*A*" symbol at the bottom of the graph proportionally readjusts the graph. -5. The final box is dedicated to the analysis of the curve. The user can specify the cost of false positives (FP) and false negatives (FN), and the prior target class probability. - - - *Default threshold (0.5) point* shows the point on the ROC curve achieved by the classifier if it predicts the target class if its probability equals or exceeds 0.5. - - *Show performance line* shows iso-performance in the ROC space so that all the points on the line give the same profit/loss. The line further to the upper left is better than the one down and right. The direction of the line depends upon costs and probabilities. This gives a recipe for depicting the optimal threshold for the given costs: this is the point where the tangent with the given inclination touches the curve and it is marked in the plot. If we push the iso-performance higher or more to the left, the points on the iso-performance line cannot be reached by the learner. Going down or to the right, decreases the performance. - - The widget allows setting the costs from 1 to 1000. Units are not important, as are not the magnitudes. What matters is the relation between the two costs, so setting them to 100 and 200 will give the same result as 400 and 800. - Defaults: both costs equal (500), Prior target class probability 50%(from the data). - ![](images/ROCAnalysis-Plain.png) - False positive cost: 830, False negative cost 650, Prior target - class probability 73%. - ![](images/ROCAnalysis.png) -6. Press *Save Image* if you want to save the created image to your - computer in a .svg or .png format. -7. Produce a report. - -Example -------- - -At the moment, the only widget which gives the right type of signal needed by the **ROC Analysis** is [Test & Score](../evaluate/testandscore.md). Below, we compare two classifiers, namely [Tree](../model/tree.md) and [Naive Bayes](../model/naivebayes.md), in **Test\&Score** and then compare their performance in **ROC Analysis**, [Life Curve](../evaluate/liftcurve.md) and [Calibration Plot](../evaluate/calibrationplot.md). - -![](images/ROCAnalysis-example.png) diff --git a/doc/visual-programming/source/widgets/evaluate/testandscore.md b/doc/visual-programming/source/widgets/evaluate/testandscore.md deleted file mode 100644 index 0526597a311..00000000000 --- a/doc/visual-programming/source/widgets/evaluate/testandscore.md +++ /dev/null @@ -1,64 +0,0 @@ -Test and Score -============== - -Tests learning algorithms on data. - -**Inputs** - -- Data: input dataset -- Test Data: separate data for testing -- Learner: learning algorithm(s) - -**Outputs** - -- Evaluation Results: results of testing classification algorithms - -The widget tests learning algorithms. Different sampling schemes are available, including using separate test data. The widget does two things. First, it shows a table with different classifier performance measures, such as [classification accuracy](https://en.wikipedia.org/wiki/Accuracy_and_precision) and [area under the curve](https://en.wikipedia.org/wiki/Receiver_operating_characteristic#Area_under_the_curve). Second, it outputs evaluation results, which can be used by other widgets for analyzing the performance of classifiers, such as [ROC Analysis](../evaluate/rocanalysis.md) or [Confusion Matrix](../evaluate/confusionmatrix.md). - -The *Learner* signal has an uncommon property: it can be connected to more than one widget to test multiple learners with the same procedures. - -![](images/TestAndScore-stamped.png) - -1. The widget supports various sampling methods. - - [Cross-validation](https://en.wikipedia.org/wiki/Cross-validation_\(statistics\)) splits the data into a given number of folds (usually 5 or 10). The algorithm is tested by holding out examples from one fold at a time; the model is induced from other folds and examples from the held out fold are classified. This is repeated for all the folds. - - **Cross validation by feature** performs cross-validation but folds are defined by the selected categorical feature from meta-features. - - **Random sampling** randomly splits the data into the training and testing set in the given proportion (e.g. 70:30); the whole procedure is repeated for a specified number of times. - - **Leave-one-out** is similar, but it holds out one instance at a time, inducing the model from all others and then classifying the held out instances. This method is obviously very stable, reliable... and very slow. - - **Test on train data** uses the whole dataset for training and then for testing. This method practically always gives wrong results. - - **Test on test data**: the above methods use the data from *Data* signal only. To input another dataset with testing examples (for instance from another file or some data selected in another widget), we select *Separate Test Data* signal in the communication channel and select Test on test data. -2. For classification, *Target class* can be selected at the bottom of the widget. When *Target class* is (Average over classes), methods return scores that are weighted averages over all classes. For example, in case of the classifier with 3 classes, scores are computed for class 1 as a target class, class 2 as a target class, and class 3 as a target class. Those scores are averaged with weights based on the class size to retrieve the final score. -3. The widget will compute a number of performance statistics. A few are shown by default. To see others, right-click on the header and select the desired statistic. - - Classification - ![](images/TestAndScore-Classification.png) - - [Area under ROC](http://gim.unmc.edu/dxtests/roc3.htm) is the area under the receiver-operating curve. - - [Classification accuracy](https://en.wikipedia.org/wiki/Accuracy_and_precision) is the proportion of correctly classified examples. - - [F-1](https://en.wikipedia.org/wiki/F1_score) is a weighted harmonic mean of precision and recall (see below). - - [Precision](https://en.wikipedia.org/wiki/Precision_and_recall) is the proportion of true positives among instances classified as positive, e.g. the proportion of *Iris virginica* correctly identified as Iris virginica. - - [Recall](https://en.wikipedia.org/wiki/Precision_and_recall) is the proportion of true positives among all positive instances in the data, e.g. the number of sick among all diagnosed as sick. - - [Specificity](https://en.wikipedia.org/wiki/Sensitivity_and_specificity) is the proportion of true negatives among all negative instances, e.g. the number of non-sick among all diagnosed as non-sick. - - [LogLoss](https://en.wikipedia.org/wiki/Cross_entropy) or cross-entropy loss takes into account the uncertainty of your prediction based on how much it varies from the actual label. - - Train time - cumulative time in seconds used for training models. - - Test time - cumulative time in seconds used for testing models. - - Regression - ![](images/TestAndScore-Regression.png) - - [MSE](https://en.wikipedia.org/wiki/Mean_squared_error) measures the average of the squares of the errors or deviations (the difference between the estimator and what is estimated). - - [RMSE](https://en.wikipedia.org/wiki/Root_mean_square) is the square root of the arithmetic mean of the squares of a set of numbers (a measure of imperfection of the fit of the estimator to the data) - - [MAE]() is used to measure how close forecasts or predictions are to eventual outcomes. - - [R2]() is interpreted as the proportion of the variance in the dependent variable that is predictable from the independent variable. - - [CVRMSE](https://en.wikipedia.org/wiki/Root-mean-square_deviation) is RMSE normalized by the mean value of actual values. - - Train time - cumulative time in seconds used for training models. - - Test time - cumulative time in seconds used for testing models. -4. Choose the score for pairwise comparison of models and the region of practical equivalence (ROPE), in which differences are considered negligible. -5. Pairwise comparison of models using the selected score (available only for cross-validation). The number in the table gives the probability that the model corresponding to the row has a higher score than the model corresponding to the column. What the higher score means depends on the metric: a higher score can either mean a model is better (for example, CA or AUC) or the opposite (for example, RMSE). If negligible difference is enabled, the smaller number below shows the probability that the difference between the pair is negligible. The test is based on the [Bayesian interpretation of the t-test](https://link.springer.com/article/10.1007/s10994-015-5486-z) ([shorter introduction](https://baycomp.readthedocs.io/en/latest/introduction.html)). -6. Get help and produce a report. - -Example -------- - -In a typical use of the widget, we give it a dataset and a few learning algorithms and we observe their performance in the table inside the **Test & Score** widget and in the [ROC](../evaluate/rocanalysis.md). The data is often preprocessed before testing; in this case we did some manual feature selection ([Select Columns](../data/selectcolumns.md) widget) on *Titanic* dataset, where we want to know only the sex and status of the survived and omit the age. - -In the bottom table, we have a pairwise comparison of models. We selected that comparison is based on the _area under ROC curve_ statistic. The number in the table gives the probability that the model corresponding to the row is better than the model corresponding to the column. We can, for example, see that probability for the tree to be better than SVM is almost one, and the probability that tree is better than Naive Bayes is 0.001. Smaller numbers in the table are probabilities that the difference between the pair is negligible based on the negligible threshold 0.1. - -![](images/TestAndScore-Example.png) - -Another example of using this widget is presented in the documentation for the [Confusion Matrix](../evaluate/confusionmatrix.md) widget. diff --git a/doc/visual-programming/source/widgets/mkdocs.yml b/doc/visual-programming/source/widgets/mkdocs.yml deleted file mode 100644 index 406c357d83e..00000000000 --- a/doc/visual-programming/source/widgets/mkdocs.yml +++ /dev/null @@ -1,2 +0,0 @@ -site_name: My Docs -docs_dir: . diff --git a/doc/visual-programming/source/widgets/model/adaboost.md b/doc/visual-programming/source/widgets/model/adaboost.md deleted file mode 100644 index 6e7c4e8189d..00000000000 --- a/doc/visual-programming/source/widgets/model/adaboost.md +++ /dev/null @@ -1,55 +0,0 @@ -AdaBoost -======== - -An ensemble meta-algorithm that combines weak learners and adapts to the 'hardness' of each training sample. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) -- Learner: learning algorithm - -**Outputs** - -- Learner: AdaBoost learning algorithm -- Model: trained model - -The [AdaBoost](https://en.wikipedia.org/wiki/AdaBoost) (short for "Adaptive boosting") widget is a machine-learning algorithm, formulated by [Yoav Freund and Robert Schapire](https://cseweb.ucsd.edu/~yfreund/papers/IntroToBoosting.pdf). It can be used with other learning algorithms to boost their performance. It does so by tweaking the weak learners. - -**AdaBoost** works for both classification and regression. - -![](images/AdaBoost-stamped.png) - -1. The learner can be given a name under which it will appear in other widgets. The default name is "AdaBoost". -2. Set the parameters. The base estimator is a tree and you can set: - - *Number of estimators* - - *Learning rate*: it determines to what extent the newly acquired information will override the old information (0 = the agent will not learn anything, 1 = the agent considers only the most recent information) - - *Fixed seed for random generator*: set a fixed seed to enable reproducing the results. -3. Boosting method. - - *Classification algorithm* (if classification on input): SAMME (updates base estimator's weights with classification results) or SAMME.R (updates base estimator's weight with probability estimates). - - *Regression loss function* (if regression on input): Linear (), Square (), Exponential (). -4. Produce a report. -5. Click *Apply* after changing the settings. That will put the new learner in the output and, if the training examples are given, construct a new model and output it as well. To communicate changes automatically tick *Apply Automatically*. - -Preprocessing -------------- - -AdaBoost uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Examples --------- - -For classification, we loaded the *iris* dataset. We used *AdaBoost*, [Tree](../model/tree.md) and [Logistic Regression](../model/logisticregression.md) and evaluated the models' performance in [Test & Score](../evaluate/testandscore.md). - -![](images/AdaBoost-classification.png) - -For regression, we loaded the *housing* dataset, sent the data instances to two different models (**AdaBoost** and [Tree](../model/tree.md)) and output them to the [Predictions](../evaluate/predictions.md) widget. - -![](images/AdaBoost-regression.png) diff --git a/doc/visual-programming/source/widgets/model/calibratedlearner.md b/doc/visual-programming/source/widgets/model/calibratedlearner.md deleted file mode 100644 index 4da3facc34e..00000000000 --- a/doc/visual-programming/source/widgets/model/calibratedlearner.md +++ /dev/null @@ -1,45 +0,0 @@ -Calibrated Learner -================== - -Wraps another learner with probability calibration and decision threshold optimization. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) -- Base Learner: learner to calibrate - -**Outputs** - -- Learner: calibrated learning algorithm -- Model: trained model using the calibrated learner - -This learner produces a model that calibrates the distribution of class probabilities and optimizes decision threshold. The widget works only for binary classification tasks. - -![](images/Calibrated-Learner-stamped.png) - -1. The name under which it will appear in other widgets. Default name is composed of the learner, calibration and optimization parameters. -2. Probability calibration: - - - [Sigmoid calibration](http://citeseer.ist.psu.edu/viewdoc/summary?doi=10.1.1.41.1639) - - [Isotonic calibration](https://scikit-learn.org/stable/auto_examples/plot_isotonic_regression.html) - - No calibration - -3. Decision threshold optimization: - - - Optimize classification accuracy - - Optimize F1 score - - No threshold optimization - -4. Press *Apply* to commit changes. If *Apply Automatically* is ticked, changes are committed automatically. - -Example -------- - -A simple example with **Calibrated Learner**. We are using the *titanic* data set as the widget requires binary class values (in this case they are 'survived' and 'not survived'). - -We will use [Logistic Regression](logisticregression.md) as the base learner which will we calibrate with the default settings, that is with sigmoid optimization of distribution values and by optimizing the CA. - -Comparing the results with the uncalibrated **Logistic Regression** model we see that the calibrated model performs better. - -![](images/Calibrated-Learner-Example.png) diff --git a/doc/visual-programming/source/widgets/model/cn2ruleinduction.md b/doc/visual-programming/source/widgets/model/cn2ruleinduction.md deleted file mode 100644 index 3026db3eb45..00000000000 --- a/doc/visual-programming/source/widgets/model/cn2ruleinduction.md +++ /dev/null @@ -1,70 +0,0 @@ -CN2 Rule Induction -================== - -Induce rules from data using CN2 algorithm. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: CN2 learning algorithm -- CN2 Rule Classifier: trained model - -The CN2 algorithm is a classification technique designed for the efficient induction of simple, comprehensible rules of form "if *cond* then predict *class*", even in domains where noise may be present. - -**CN2 Rule Induction** works only for classification. - -![](images/CN2-stamped.png) - -1. Name under which the learner appears in other widgets. The default name is *CN2 Rule Induction*. -2. *Rule ordering*: - - **Ordered**: induce ordered rules (decision list). Rule conditions are found and the majority class is assigned in the rule head. - - **Unordered**: induce unordered rules (rule set). Learn rules for each class individually, in regard to the original learning data. -3. *Covering algorithm*: - - **Exclusive**: after covering a learning instance, remove it from further consideration. - - **Weighted**: after covering a learning instance, decrease its weight (multiplication by *gamma*) and in-turn decrease its impact on further iterations of the algorithm. -4. *Rule search*: - - **Evaluation measure**: select a heuristic to evaluate found hypotheses: - - [Entropy](https://en.wikipedia.org/wiki/Entropy_(information_theory)) (measure of unpredictability of content) - - [Laplace Accuracy](https://en.wikipedia.org/wiki/Laplace%27s_method) - - Weighted Relative Accuracy - - **Beam width**; remember the best rule found thus far and monitor a fixed number of alternatives (the beam). -5. *Rule filtering*: - - **Minimum rule coverage**: found rules must cover at least the minimum required number of covered examples. Unordered rules must cover this many target class examples. - - **Maximum rule length**: found rules may combine at most the maximum allowed number of selectors (conditions). - - **Default alpha**: significance testing to prune out most specialised (less frequently applicable) rules in regard to the initial distribution of classes. - - **Parent alpha**: significance testing to prune out most specialised (less frequently applicable) rules in regard to the parent class distribution. -6. Tick 'Apply Automatically' to auto-communicate changes to other widgets and to immediately train the classifier if learning data is connected. Alternatively, press ‘Apply‘ after configuration. - -Preprocessing -------------- - -CN2 Rule Induction uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes empty columns -- removes instances with unknown target values -- imputes missing values with mean values - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Examples --------- - -For the example below, we have used *zoo* dataset and passed it to **CN2 Rule Induction**. We can review and interpret the built model with [CN2 Rule Viewer](../visualize/cn2ruleviewer.md) widget. - -![](images/CN2-visualize.png) - -The second workflow tests evaluates **CN2 Rule Induction** and [Tree](../model/tree.md) in [Test & Score](../evaluate/testandscore.md). - -![](images/CN2-classification.png) - -References ----------- - -1. Fürnkranz, Johannes. "Separate-and-Conquer Rule Learning", Artificial Intelligence Review 13, 3-54, 1999. -2. Clark, Peter and Tim Niblett. "The CN2 Induction Algorithm", Machine Learning Journal, 3 (4), 261-283, 1989. -3. Clark, Peter and Robin Boswell. "Rule Induction with CN2: Some Recent Improvements", Machine Learning - Proceedings of the 5th European Conference (EWSL-91),151-163, 1991. -4. Lavrač, Nada et al. "Subgroup Discovery with CN2-SD",Journal of Machine Learning Research 5, 153-188, 2004 diff --git a/doc/visual-programming/source/widgets/model/constant.md b/doc/visual-programming/source/widgets/model/constant.md deleted file mode 100644 index c5d06c46440..00000000000 --- a/doc/visual-programming/source/widgets/model/constant.md +++ /dev/null @@ -1,47 +0,0 @@ -Constant -======== - -Predict the most frequent class or mean value from the training set. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: majority/mean learning algorithm -- Model: trained model - -This learner produces a model that always predicts the[majority](https://en.wikipedia.org/wiki/Predictive_modelling#Majority_classifier) for classification tasks and [mean value](https://en.wikipedia.org/wiki/Mean) for regression tasks. - -For classification, when predicting the class value with [Predictions](../evaluate/predictions.md), the widget will return relative frequencies of the classes in the training set. When there are two or more majority classes, the classifier chooses the predicted class randomly, but always returns the same class for a particular example. - -For regression, it *learns* the mean of the class variable and returns a predictor with the same mean value. - -The widget is typically used as a baseline for other models. - -![](images/Constant-stamped.png) - -This widget provides the user with two options: - -1. The name under which it will appear in other widgets. Default name is "Constant". -2. Produce a report. - -If you change the widget's name, you need to click *Apply*. Alternatively, tick the box on the left side and changes will be communicated automatically. - -Preprocessing -------------- - -Constant does not use any preprocessing. - -Examples --------- - -In a typical classification example, we would use this widget to compare the scores of other learning algorithms (such as kNN) with the default scores. Use *iris* dataset and connect it to [Test & Score](../evaluate/testandscore.md). Then connect **Constant** and [kNN](../model/knn.md) to [Test & Score](../evaluate/testandscore.md) and observe how well [kNN](../model/knn.md) performs against a constant baseline. - -![](images/Constant-classification.png) - -For regression, we use **Constant** to construct a predictor in [Predictions](../evaluate/predictions.md). We used the *housing* dataset. In **Predictions**, you can see that *Mean Learner* returns one (mean) value for all instances. - -![](images/Constant-regression.png) diff --git a/doc/visual-programming/source/widgets/model/gradientboosting.md b/doc/visual-programming/source/widgets/model/gradientboosting.md deleted file mode 100644 index fa6b172332b..00000000000 --- a/doc/visual-programming/source/widgets/model/gradientboosting.md +++ /dev/null @@ -1,58 +0,0 @@ -Gradient Boosting -================= - -Predict using gradient boosting on decision trees. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: gradient boosting learning algorithm -- Model: trained model - -[Gradient Boosting](https://en.wikipedia.org/wiki/Gradient_boosting) is a machine learning technique for regression and classification problems, which produces a prediction model in the form of an ensemble of weak prediction models, typically decision trees. - -![](images/GradientBoosting-stamped.png) - -1. Specify the name of the model. The default name is "Gradient Boosting". -2. Select a gradient boosting method: - - [Gradient Boosting (scikit-learn)](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.GradientBoostingClassifier.html) - - [Extreme Gradient Boosting (xgboost)](https://xgboost.readthedocs.io/en/latest/index.html) - - [Extreme Gradient Boosting Random Forest (xgboost)](https://xgboost.readthedocs.io/en/latest/index.html) - - [Gradient Boosting (catboost)](https://catboost.ai/docs/concepts/python-quickstart.html) -3. Basic properties: - - *Number of trees*: Specify how many gradient boosted trees will be included. A large number usually results in better performance. - - *Learning rate*: Specify the boosting learning rate. Learning rate shrinks the contribution of each tree. - - *Replicable training*: Fix the random seed, which enables replicability of the results. - - *Regularization*: Specify the L2 regularization term. Available only for *xgboost* and *catboost* methods. -4. Growth control: - - *Limit depth of individual trees*: Specify the maximum depth of the individual tree. - - *Do not split subsets smaller than*: Specify the smallest subset that can be split. Available only for *scikit-learn* methods. -5. Subsampling: - - *Fraction of training instances*: Specify the percentage of the training instances for fitting the individual tree. Available for *scikit-learn* and *xgboost* methods. - - *Fraction of features for each tree*: Specify the percentage of features to use when constructing each tree. Available for *xgboost* and *catboost* methods. - - *Fraction of features for each level*: Specify the percentage of features to use for each level. Available only for *xgboost* methods. - - *Fraction of features for each split*: Specify the percentage of features to use for each split. Available only for *xgboost* methods. -6. Click *Apply* to communicate the changes to other widgets. Alternatively, tick the box on the left side of the *Apply* button and changes will be communicated automatically. - -Preprocessing -------------- - -Gradient Boosting uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Example -------- - -For a classification tasks, we use the *heart disease* data. Here, we compare all available methods in the [Test & Score](../evaluate/testandscore.md) widget. - -![](images/GradientBoosting-example.png) diff --git a/doc/visual-programming/source/widgets/model/icons/adaboost.png b/doc/visual-programming/source/widgets/model/icons/adaboost.png deleted file mode 100644 index 595fe39a188..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/adaboost.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/cn2ruleinduction.png b/doc/visual-programming/source/widgets/model/icons/cn2ruleinduction.png deleted file mode 100644 index 6e6657d9288..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/cn2ruleinduction.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/constant.png b/doc/visual-programming/source/widgets/model/icons/constant.png deleted file mode 100755 index 376cad654da..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/constant.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/knn.png b/doc/visual-programming/source/widgets/model/icons/knn.png deleted file mode 100644 index 0a85f615830..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/knn.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/linear-regression.png b/doc/visual-programming/source/widgets/model/icons/linear-regression.png deleted file mode 100755 index c0eb5bada86..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/linear-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/load-model.png b/doc/visual-programming/source/widgets/model/icons/load-model.png deleted file mode 100644 index 0efb11a9676..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/load-model.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/logistic-regression.png b/doc/visual-programming/source/widgets/model/icons/logistic-regression.png deleted file mode 100644 index 4aaa167d3cb..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/logistic-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/naive-bayes.png b/doc/visual-programming/source/widgets/model/icons/naive-bayes.png deleted file mode 100644 index bfd77fc501f..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/naive-bayes.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/neural-network.png b/doc/visual-programming/source/widgets/model/icons/neural-network.png deleted file mode 100755 index 124c82b1c3e..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/neural-network.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/random-forest.png b/doc/visual-programming/source/widgets/model/icons/random-forest.png deleted file mode 100644 index ee187a5e895..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/random-forest.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/save-model.png b/doc/visual-programming/source/widgets/model/icons/save-model.png deleted file mode 100644 index 28e87b882e8..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/save-model.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/stacking.png b/doc/visual-programming/source/widgets/model/icons/stacking.png deleted file mode 100644 index 565d7045f91..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/stacking.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/stochastic-gradient.png b/doc/visual-programming/source/widgets/model/icons/stochastic-gradient.png deleted file mode 100755 index f35f639f5e2..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/stochastic-gradient.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/svm.png b/doc/visual-programming/source/widgets/model/icons/svm.png deleted file mode 100644 index 44b72f4b284..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/svm.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/icons/tree.png b/doc/visual-programming/source/widgets/model/icons/tree.png deleted file mode 100644 index 82315cf0371..00000000000 Binary files a/doc/visual-programming/source/widgets/model/icons/tree.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/AdaBoost-classification.png b/doc/visual-programming/source/widgets/model/images/AdaBoost-classification.png deleted file mode 100644 index 7e516255edd..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/AdaBoost-classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/AdaBoost-regression.png b/doc/visual-programming/source/widgets/model/images/AdaBoost-regression.png deleted file mode 100644 index ffef8d3e41e..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/AdaBoost-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/AdaBoost-stamped.png b/doc/visual-programming/source/widgets/model/images/AdaBoost-stamped.png deleted file mode 100644 index 7f02a1eb73f..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/AdaBoost-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/CN2-classification.png b/doc/visual-programming/source/widgets/model/images/CN2-classification.png deleted file mode 100644 index b0c95e61c7e..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/CN2-classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/CN2-stamped.png b/doc/visual-programming/source/widgets/model/images/CN2-stamped.png deleted file mode 100644 index 43cc1637271..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/CN2-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/CN2-visualize.png b/doc/visual-programming/source/widgets/model/images/CN2-visualize.png deleted file mode 100644 index cdfe1cf7c84..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/CN2-visualize.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Calibrated-Learner-Example.png b/doc/visual-programming/source/widgets/model/images/Calibrated-Learner-Example.png deleted file mode 100644 index 169b92ddd62..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Calibrated-Learner-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Calibrated-Learner-stamped.png b/doc/visual-programming/source/widgets/model/images/Calibrated-Learner-stamped.png deleted file mode 100644 index a215105854d..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Calibrated-Learner-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Constant-classification.png b/doc/visual-programming/source/widgets/model/images/Constant-classification.png deleted file mode 100644 index 0795f579329..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Constant-classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Constant-regression.png b/doc/visual-programming/source/widgets/model/images/Constant-regression.png deleted file mode 100644 index aa4865e92e0..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Constant-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Constant-stamped.png b/doc/visual-programming/source/widgets/model/images/Constant-stamped.png deleted file mode 100644 index 281e9caf4d6..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Constant-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/GradientBoosting-example.png b/doc/visual-programming/source/widgets/model/images/GradientBoosting-example.png deleted file mode 100644 index 7180940831b..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/GradientBoosting-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/GradientBoosting-stamped.png b/doc/visual-programming/source/widgets/model/images/GradientBoosting-stamped.png deleted file mode 100644 index 665438b240e..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/GradientBoosting-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/LinearRegression-regression.png b/doc/visual-programming/source/widgets/model/images/LinearRegression-regression.png deleted file mode 100644 index 3b84f327a6f..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/LinearRegression-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/LinearRegression-stamped.png b/doc/visual-programming/source/widgets/model/images/LinearRegression-stamped.png deleted file mode 100644 index 8cb0ddf8c06..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/LinearRegression-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/LoadModel-example.png b/doc/visual-programming/source/widgets/model/images/LoadModel-example.png deleted file mode 100644 index 1ea03eae623..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/LoadModel-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/LoadModel-stamped.png b/doc/visual-programming/source/widgets/model/images/LoadModel-stamped.png deleted file mode 100644 index 463edb1b500..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/LoadModel-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/LogisticRegression-classification.png b/doc/visual-programming/source/widgets/model/images/LogisticRegression-classification.png deleted file mode 100644 index c172871d2ac..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/LogisticRegression-classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/LogisticRegression-stamped.png b/doc/visual-programming/source/widgets/model/images/LogisticRegression-stamped.png deleted file mode 100644 index 39bc17a1691..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/LogisticRegression-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/NN-Example-Predict.png b/doc/visual-programming/source/widgets/model/images/NN-Example-Predict.png deleted file mode 100644 index 1b79f92740b..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/NN-Example-Predict.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/NN-Example-Test.png b/doc/visual-programming/source/widgets/model/images/NN-Example-Test.png deleted file mode 100644 index d1dfbf705d0..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/NN-Example-Test.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/NaiveBayes-classification.png b/doc/visual-programming/source/widgets/model/images/NaiveBayes-classification.png deleted file mode 100644 index 61328818346..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/NaiveBayes-classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/NaiveBayes-stamped.png b/doc/visual-programming/source/widgets/model/images/NaiveBayes-stamped.png deleted file mode 100644 index a159f4d9e26..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/NaiveBayes-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/NaiveBayes-visualize.png b/doc/visual-programming/source/widgets/model/images/NaiveBayes-visualize.png deleted file mode 100644 index a09d18424ad..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/NaiveBayes-visualize.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/NeuralNetwork-stamped.png b/doc/visual-programming/source/widgets/model/images/NeuralNetwork-stamped.png deleted file mode 100644 index a5ec6e24620..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/NeuralNetwork-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/RandomForest-classification.png b/doc/visual-programming/source/widgets/model/images/RandomForest-classification.png deleted file mode 100644 index 5d7da3880d7..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/RandomForest-classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/RandomForest-regression.png b/doc/visual-programming/source/widgets/model/images/RandomForest-regression.png deleted file mode 100644 index 753b401cb28..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/RandomForest-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/RandomForest.png b/doc/visual-programming/source/widgets/model/images/RandomForest.png deleted file mode 100644 index 7b064925ff0..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/RandomForest.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/SVM-Predictions.png b/doc/visual-programming/source/widgets/model/images/SVM-Predictions.png deleted file mode 100644 index 1db70911253..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/SVM-Predictions.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/SVM-stamped.png b/doc/visual-programming/source/widgets/model/images/SVM-stamped.png deleted file mode 100644 index f49309388be..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/SVM-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/SVM-support-vectors.png b/doc/visual-programming/source/widgets/model/images/SVM-support-vectors.png deleted file mode 100644 index 4f5bd3b35a8..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/SVM-support-vectors.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/SaveModel-example.png b/doc/visual-programming/source/widgets/model/images/SaveModel-example.png deleted file mode 100644 index af16d9ef20f..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/SaveModel-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/SaveModel-save.png b/doc/visual-programming/source/widgets/model/images/SaveModel-save.png deleted file mode 100644 index 7d1232c56f6..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/SaveModel-save.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/SaveModel-stamped.png b/doc/visual-programming/source/widgets/model/images/SaveModel-stamped.png deleted file mode 100644 index e8455f36276..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/SaveModel-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Stacking-Example.png b/doc/visual-programming/source/widgets/model/images/Stacking-Example.png deleted file mode 100644 index bcd17922cf7..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Stacking-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Stacking-stamped.png b/doc/visual-programming/source/widgets/model/images/Stacking-stamped.png deleted file mode 100644 index 9d23ecc93c1..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Stacking-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-classification.png b/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-classification.png deleted file mode 100644 index 1a052bd1586..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-regression.png b/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-regression.png deleted file mode 100644 index f85648ad9ac..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-stamped.png b/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-stamped.png deleted file mode 100644 index ea6de4456dd..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/StochasticGradientDescent-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Tree-classification-model.png b/doc/visual-programming/source/widgets/model/images/Tree-classification-model.png deleted file mode 100644 index 9974878da2b..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Tree-classification-model.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Tree-classification-visualize.png b/doc/visual-programming/source/widgets/model/images/Tree-classification-visualize.png deleted file mode 100644 index 946bcfe718b..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Tree-classification-visualize.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Tree-regression-subset.png b/doc/visual-programming/source/widgets/model/images/Tree-regression-subset.png deleted file mode 100644 index 1ad859bc27c..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Tree-regression-subset.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/Tree-stamped.png b/doc/visual-programming/source/widgets/model/images/Tree-stamped.png deleted file mode 100644 index 697da551ccb..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/Tree-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/kNN-regression.png b/doc/visual-programming/source/widgets/model/images/kNN-regression.png deleted file mode 100644 index 2f9ac1a85d9..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/kNN-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/images/kNN-stamped.png b/doc/visual-programming/source/widgets/model/images/kNN-stamped.png deleted file mode 100644 index 08a29d1f5b0..00000000000 Binary files a/doc/visual-programming/source/widgets/model/images/kNN-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/model/knn.md b/doc/visual-programming/source/widgets/model/knn.md deleted file mode 100644 index b894655acdb..00000000000 --- a/doc/visual-programming/source/widgets/model/knn.md +++ /dev/null @@ -1,55 +0,0 @@ -kNN -=== - -Predict according to the nearest training instances. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: kNN learning algorithm -- Model: trained model - -The **kNN** widget uses the [kNN algorithm](https://en.wikipedia.org/wiki/K-nearest_neighbors_algorithm) that searches for k closest training examples in feature space and uses their average as prediction. - -![](images/kNN-stamped.png) - -1. A name under which it will appear in other widgets. The default name is "kNN". -2. Set the number of nearest neighbors, the distance parameter (metric) and weights as model criteria. - - Metric can be: - - [Euclidean](https://en.wikipedia.org/wiki/Euclidean_distance) ("straight line", distance between two points) - - [Manhattan](https://en.wikipedia.org/wiki/Taxicab_geometry) (sum of absolute differences of all attributes) - - [Maximal](https://en.wikipedia.org/wiki/Chebyshev_distance) (greatest of absolute differences between attributes) - - [Mahalanobis](https://en.wikipedia.org/wiki/Mahalanobis_distance) (distance between point and distribution). - - The *Weights* you can use are: - - **Uniform**: all points in each neighborhood are weighted equally. - - **Distance**: closer neighbors of a query point have a greater influence than the neighbors further away. -3. Produce a report. -4. When you change one or more settings, you need to click *Apply*, which will put a new learner on the output and, if the training examples are given, construct a new model and output it as well. Changes can also be applied automatically by clicking the box on the left side of the *Apply* button. - -Preprocessing -------------- - -kNN uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values -- normalizes the data by centering to mean and scaling to standard deviation of 1 - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Examples --------- - -The first example is a classification task on *iris* dataset. We compare the results of [k-Nearest neighbors](https://en.wikipedia.org/wiki/K-nearest_neighbors_algorithm) with the default model [Constant](../model/constant.md), which always predicts the majority class. - -![](images/Constant-classification.png) - -The second example is a regression task. This workflow shows how to use the *Learner* output. For the purpose of this example, we used the *housing* dataset. We input the **kNN** prediction model into [Predictions](../evaluate/predictions.md) and observe the predicted values. - -![](images/kNN-regression.png) diff --git a/doc/visual-programming/source/widgets/model/linearregression.md b/doc/visual-programming/source/widgets/model/linearregression.md deleted file mode 100644 index 4ba817be856..00000000000 --- a/doc/visual-programming/source/widgets/model/linearregression.md +++ /dev/null @@ -1,49 +0,0 @@ -Linear Regression -================= - -A linear regression algorithm with optional L1 (LASSO), L2 (ridge) or L1L2 (elastic net) regularization. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: linear regression learning algorithm -- Model: trained model -- Coefficients: linear regression coefficients - -The **Linear Regression** widget constructs a learner/predictor that learns a [linear function](https://en.wikipedia.org/wiki/Linear_regression) from its input data. The model can identify the relationship between a predictor xi and the response variable y. Additionally, [Lasso](https://en.wikipedia.org/wiki/Least_squares#Lasso_method) and [Ridge](https://en.wikipedia.org/wiki/Least_squares#Lasso_method) regularization parameters can be specified. Lasso regression minimizes a penalized version of the least squares loss function with L1-norm penalty and Ridge regularization with L2-norm penalty. - -Linear regression works only on regression tasks. - -![](images/LinearRegression-stamped.png) - -1. The learner/predictor name -2. Choose a model to train: - - no regularization - - a [Ridge](https://en.wikipedia.org/wiki/Least_squares#Lasso_method) regularization (L2-norm penalty) - - a [Lasso](https://en.wikipedia.org/wiki/Least_squares#Lasso_method) bound (L1-norm penalty) - - an [Elastic net](https://en.wikipedia.org/wiki/Elastic_net_regularization) regularization -3. Produce a report. -4. Press *Apply* to commit changes. If *Apply Automatically* is ticked, changes are committed automatically. - -Preprocessing -------------- - -Linear Regression uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Example -------- - -Below, is a simple workflow with *housing* dataset. We trained **Linear Regression** and [Random Forest](../model/randomforest.md) and evaluated their performance in [Test & Score](../evaluate/testandscore.md). - -![](images/LinearRegression-regression.png) diff --git a/doc/visual-programming/source/widgets/model/loadmodel.md b/doc/visual-programming/source/widgets/model/loadmodel.md deleted file mode 100644 index 6506594e76c..00000000000 --- a/doc/visual-programming/source/widgets/model/loadmodel.md +++ /dev/null @@ -1,21 +0,0 @@ -Load Model -========== - -Load a model from an input file. - -**Outputs** - -- Model: trained model - -![](images/LoadModel-stamped.png) - -1. Choose from a list of previously used models. -2. Browse for saved models. -3. Reload the selected model. - -Example -------- - -When you want to use a custom-set model that you've saved before, open the **Load Model** widget and select the desired file with the *Browse* icon. This widget loads the existing model into [Predictions](../evaluate/predictions.md) widget. Datasets used with **Load Model** have to contain compatible attributes! - -![](images/LoadModel-example.png) diff --git a/doc/visual-programming/source/widgets/model/logisticregression.md b/doc/visual-programming/source/widgets/model/logisticregression.md deleted file mode 100644 index fdc96a5b6b0..00000000000 --- a/doc/visual-programming/source/widgets/model/logisticregression.md +++ /dev/null @@ -1,44 +0,0 @@ -Logistic Regression -=================== - -The logistic regression classification algorithm with LASSO (L1) or ridge (L2) regularization. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: logistic regression learning algorithm -- Model: trained model -- Coefficients: logistic regression coefficients - -**Logistic Regression** learns a [Logistic Regression](https://en.wikipedia.org/wiki/Logistic_regression) model from the data. It only works for classification tasks. - -![](images/LogisticRegression-stamped.png) - -1. A name under which the learner appears in other widgets. The default name is "Logistic Regression". -2. [Regularization](https://en.wikipedia.org/wiki/Regularization_(mathematics)) type (either [L1](https://en.wikipedia.org/wiki/Least_squares#Lasso_method) or [L2](https://en.wikipedia.org/wiki/Tikhonov_regularization)). Set the cost strength (default is C=1). -3. Press *Apply* to commit changes. If *Apply Automatically* is ticked, changes will be communicated automatically. - -Preprocessing -------------- - -Logistic Regression uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Example -------- - -The widget is used just as any other widget for inducing a classifier. This is an example demonstrating prediction results with logistic regression on the *hayes-roth* dataset. We first load *hayes-roth_learn* in the [File](../data/file.md) widget and pass the data to **Logistic Regression**. Then we pass the trained model to [Predictions](../evaluate/predictions.md). - -Now we want to predict class value on a new dataset. We load *hayes-roth_test* in the second **File** widget and connect it to **Predictions**. We can now observe class values predicted with **Logistic Regression** directly in **Predictions**. - -![](images/LogisticRegression-classification.png) diff --git a/doc/visual-programming/source/widgets/model/naivebayes.md b/doc/visual-programming/source/widgets/model/naivebayes.md deleted file mode 100644 index b5f3fed48fe..00000000000 --- a/doc/visual-programming/source/widgets/model/naivebayes.md +++ /dev/null @@ -1,42 +0,0 @@ -Naive Bayes -=========== - -A fast and simple probabilistic classifier based on Bayes' theorem with the assumption of feature independence. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: naive bayes learning algorithm -- Model: trained model - -**Naive Bayes** learns a [Naive Bayesian](https://en.wikipedia.org/wiki/Naive_Bayes_classifier) model from the data. It only works for classification tasks. - -![](images/NaiveBayes-stamped.png) - -This widget has two options: the name under which it will appear in other widgets and producing a report. The default name is *Naive Bayes*. When you change it, you need to press *Apply*. - -Preprocessing -------------- - -Naive Bayes uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes empty columns -- discretizes numeric values to 4 bins with equal frequency - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Examples --------- - -Here, we present two uses of this widget. First, we compare the results of the -**Naive Bayes** with another model, the [Random Forest](../model/randomforest.md). We connect *iris* data from [File](../data/file.md) to [Test & Score](../evaluate/testandscore.md). We also connect **Naive Bayes** and [Random Forest](../model/randomforest.md) to **Test & Score** and observe their prediction scores. - -![](images/NaiveBayes-classification.png) - -The second schema shows the quality of predictions made with **Naive Bayes**. We feed the [Test & Score](../evaluate/testandscore.md) widget a Naive Bayes learner and then send the data to the [Confusion Matrix](../evaluate/confusionmatrix.md). We also connect [Scatter Plot](../visualize/scatterplot.md) with **File**. Then we select the misclassified instances in the **Confusion Matrix** and show feed them to [Scatter Plot](../visualize/scatterplot.md). The bold dots in the scatterplot are the misclassified instances from **Naive Bayes**. - -![](images/NaiveBayes-visualize.png) diff --git a/doc/visual-programming/source/widgets/model/neuralnetwork.md b/doc/visual-programming/source/widgets/model/neuralnetwork.md deleted file mode 100644 index c1fe9add64f..00000000000 --- a/doc/visual-programming/source/widgets/model/neuralnetwork.md +++ /dev/null @@ -1,61 +0,0 @@ -Neural Network -============== - -A multi-layer perceptron (MLP) algorithm with backpropagation. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: multi-layer perceptron learning algorithm -- Model: trained model - -The **Neural Network** widget uses sklearn's [Multi-layer Perceptron algorithm](http://scikit-learn.org/stable/modules/neural_networks_supervised.html) that can learn non-linear models as well as linear. - -![](images/NeuralNetwork-stamped.png) - -1. A name under which it will appear in other widgets. The default name is "Neural Network". -2. Set model parameters: - - Neurons per hidden layer: defined as the ith element represents the number of neurons in the ith hidden layer. E.g. a neural network with 3 layers can be defined as 2, 3, 2. - - Activation function for the hidden layer: - - Identity: no-op activation, useful to implement linear bottleneck - - Logistic: the logistic sigmoid function - - tanh: the hyperbolic tan function - - ReLu: the rectified linear unit function - - Solver for weight optimization: - - L-BFGS-B: an optimizer in the family of quasi-Newton methods - - SGD: stochastic gradient descent - - Adam: stochastic gradient-based optimizer - - Alpha: L2 penalty (regularization term) parameter - - Max iterations: maximum number of iterations - - Other parameters are set to [sklearn's defaults](http://scikit-learn.org/stable/modules/generated/sklearn.neural_network.MLPClassifier.html). -3. Produce a report. -4. When the box is ticked (*Apply Automatically*), the widget will communicate changes automatically. Alternatively, click *Apply*. - -Preprocessing -------------- - -Neural Network uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values -- normalizes the data by centering to mean and scaling to standard deviation of 1 - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Examples --------- - -The first example is a classification task on *iris* dataset. We compare the results of **Neural Network** with the [Logistic Regression](../model/logisticregression.md). - -![](images/NN-Example-Test.png) - -The second example is a prediction task, still using the *iris* data. This workflow shows how to use the *Learner* output. We input the **Neural Network** prediction model into [Predictions](../evaluate/predictions.md) and observe the predicted values. - -![](images/NN-Example-Predict.png) diff --git a/doc/visual-programming/source/widgets/model/randomforest.md b/doc/visual-programming/source/widgets/model/randomforest.md deleted file mode 100644 index 4ee8df6df8e..00000000000 --- a/doc/visual-programming/source/widgets/model/randomforest.md +++ /dev/null @@ -1,61 +0,0 @@ -Random Forest -============= - -Predict using an ensemble of decision trees. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: random forest learning algorithm -- Model: trained model - -[Random forest](https://en.wikipedia.org/wiki/Random_forest) is an ensemble learning method used for classification, regression and other tasks. It was first proposed by Tin Kam Ho and further developed by Leo Breiman (Breiman, 2001) and Adele Cutler. - -**Random Forest** builds a set of decision trees. Each tree is developed from a bootstrap sample from the training data. When developing individual trees, an arbitrary subset of attributes is drawn (hence the term "Random"), from which the best attribute for the split is selected. The final model is based on the majority vote from individually developed trees in the forest. - -**Random Forest** works for both classification and regression tasks. - -![](images/RandomForest.png) - -1. Specify the name of the model. The default name is "Random Forest". -2. Basic properties: - - *Number of trees*: Specify how many decision trees will be included in the forest. - - *Number of trees considered at each split*: Specify how many attributes will be arbitrarily drawn for consideration at each node. If the latter is not specified (option *Number of attributes...* left unchecked), this number is equal to the square root of the number of attributes in the data. - - *Replicable training*: Fix the seed for tree generation, which enables replicability of the results. - - *Balance class distribution*: [Weigh classes](https://scikit-learn.org/stable/modules/generated/sklearn.utils.class_weight.compute_class_weight.html?highlight=sklearn%20utils%20class_weight) inversely proportional to their frequencies. -3. Growth control: - - *Limit depth of individual trees*: Original Breiman's proposal is to grow the trees without any pre-pruning, but since pre-pruning often works quite well and is faster, the user can set the depth to which the trees will be grown. - - *Do not split subsets smaller than*: Select the smallest subset that can be split. -4. Click *Apply* to communicate the changes to other widgets. Alternatively, tick the box on the left side of the *Apply* button and changes will be communicated automatically. - -Preprocessing -------------- - -Random Forest uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Examples --------- - -For classification tasks, we use *iris* dataset. Connect it to [Predictions](../evaluate/predictions.md). Then, connect [File](../data/file.md) to **Random Forest** and [Tree](../model/tree.md) and connect them further to [Predictions](../evaluate/predictions.md). Finally, observe the predictions for the two models. - -![](images/RandomForest-classification.png) - -For regressions tasks, we will use *housing* data. Here, we will compare different models, namely **Random Forest**, [Linear Regression](../model/linearregression.md) and [Constant](../model/constant.md), in the [Test & Score](../evaluate/testandscore.md) widget. - -![](images/RandomForest-regression.png) - -References ----------- - -Breiman, L. (2001). Random Forests. In Machine Learning, 45(1), 5-32. Available [here](https://www.stat.berkeley.edu/~breiman/randomforest2001.pdf). diff --git a/doc/visual-programming/source/widgets/model/savemodel.md b/doc/visual-programming/source/widgets/model/savemodel.md deleted file mode 100644 index 8d75d081ee2..00000000000 --- a/doc/visual-programming/source/widgets/model/savemodel.md +++ /dev/null @@ -1,24 +0,0 @@ -Save Model -========== - -Save a trained model to an output file. - -If the file is saved to the same directory as the workflow or in the subtree of that directory, the widget remembers the relative path. Otherwise it will store an absolute path, but disable auto save for security reasons. - -**Inputs** - -- Model: trained model - -![](images/SaveModel-stamped.png) - -1. Choose from previously saved models. -2. Save the created model with the *Browse* icon. Click on the icon and enter the name of the file. The model will be saved to a pickled file. -![](images/SaveModel-save.png) -3. Save the model. - -Example -------- - -When you want to save a custom-set model, feed the data to the model (e.g. [Logistic Regression](../model/logisticregression.md)) and connect it to **Save Model**. Name the model; load it later into workflows with [Load Model](../model/loadmodel.md). Datasets used with **Load Model** have to contain compatible attributes. - -![](images/SaveModel-example.png) diff --git a/doc/visual-programming/source/widgets/model/stacking.md b/doc/visual-programming/source/widgets/model/stacking.md deleted file mode 100644 index c3e4876f3f2..00000000000 --- a/doc/visual-programming/source/widgets/model/stacking.md +++ /dev/null @@ -1,33 +0,0 @@ -Stacking -======== - -Stack multiple models. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) -- Learners: learning algorithm -- Aggregate: model aggregation method - -**Outputs** - -- Learner: aggregated (stacked) learning algorithm -- Model: trained model - -**Stacking** is an ensemble method that computes a meta model from several base models. The **Stacking** widget has the **Aggregate** input, which provides a method for aggregating the input models. If no aggregation input is given the default methods are used. Those are **Logistic Regression** for classification and **Ridge Regression** for regression problems. - -![](images/Stacking-stamped.png) - -1. The meta learner can be given a name under which it will appear in other widgets. The default name is “Stack”. -2. Click *Apply* to commit the aggregated model. That will put the new learner in the output and, if the training examples are given, construct a new model and output it as well. To communicate changes automatically tick *Apply Automatically*. -3. Access help and produce a report. - -Example -------- - -We will use [Paint Data](../data/paintdata.md) to demonstrate how the widget is used. We painted a complex dataset with 4 class labels and sent it to [Test & Score](../evaluate/testandscore.md). We also provided three [kNN](../model/knn.md) learners, each with a different parameters (number of neighbors is 5, 10 or 15). Evaluation results are good, but can we do better? - -Let's use **Stacking**. **Stacking** requires several learners on the input and an aggregation method. In our case, this is [Logistic Regression](../model/logisticregression.md). A constructed meta learner is then sent to **Test & Score**. Results have improved, even if only marginally. **Stacking** normally works well on complex data sets. - -![](images/Stacking-Example.png) diff --git a/doc/visual-programming/source/widgets/model/stochasticgradient.md b/doc/visual-programming/source/widgets/model/stochasticgradient.md deleted file mode 100644 index 0c92fe94f42..00000000000 --- a/doc/visual-programming/source/widgets/model/stochasticgradient.md +++ /dev/null @@ -1,79 +0,0 @@ -Stochastic Gradient Descent -=========================== - -Minimize an objective function using a stochastic approximation of gradient descent. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: stochastic gradient descent learning algorithm -- Model: trained model - -The **Stochastic Gradient Descent** widget uses [stochastic gradient descent](https://en.wikipedia.org/wiki/Stochastic_gradient_descent) that minimizes a chosen loss function with a linear function. The algorithm approximates a true gradient by considering one sample at a time, and simultaneously updates the model based on the gradient of the loss function. For regression, it returns predictors as minimizers of the sum, i.e. M-estimators, and is especially useful for large-scale and sparse datasets. - -![](images/StochasticGradientDescent-stamped.png) - -1. Specify the name of the model. The default name is "SGD". -2. Algorithm parameters: - - Classification loss function: - - [Hinge](https://en.wikipedia.org/wiki/Hinge_loss) (linear SVM) - - [Logistic Regression](http://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html#sklearn.linear_model.LogisticRegression) (logistic regression SGD) - - [Modified Huber](https://en.wikipedia.org/wiki/Huber_loss) (smooth loss that brings tolerance to outliers as well as probability estimates) - - *Squared Hinge* (quadratically penalized hinge) - - [Perceptron](http://scikit-learn.org/stable/modules/generated/sklearn.linear_model.Perceptron.html#sklearn.linear_model.Perceptron) (linear loss used by the perceptron algorithm) - - [Squared Loss](https://en.wikipedia.org/wiki/Mean_squared_error#Regression) (fitted to ordinary least-squares) - - [Huber](https://en.wikipedia.org/wiki/Huber_loss) (switches to linear loss beyond ε) - - [Epsilon insensitive](http://kernelsvm.tripod.com/) (ignores errors within ε, linear beyond it) - - *Squared epsilon insensitive* (loss is squared beyond ε-region). - - Regression loss function: - - [Squared Loss](https://en.wikipedia.org/wiki/Mean_squared_error#Regression) (fitted to ordinary least-squares) - - [Huber](https://en.wikipedia.org/wiki/Huber_loss) (switches to linear loss beyond ε) - - [Epsilon insensitive](http://kernelsvm.tripod.com/) (ignores errors within ε, linear beyond it) - - *Squared epsilon insensitive* (loss is squared beyond ε-region). -3. Regularization norms to prevent overfitting: - - None. - - [Lasso (L1)](https://en.wikipedia.org/wiki/Taxicab_geometry) (L1 leading to sparse solutions) - - [Ridge (L2)](https://en.wikipedia.org/wiki/Norm_(mathematics)#p-norm) (L2, standard regularizer) - - [Elastic net](https://en.wikipedia.org/wiki/Elastic_net_regularization) (mixing both penalty norms). - - Regularization strength defines how much regularization will be applied (the less we regularize, the more we allow the model to fit the data) and the mixing parameter what the ratio between L1 and L2 loss will be (if set to 0 then the loss is L2, if set to 1 then it is L1). -4. Learning parameters. - - Learning rate: - - *Constant*: learning rate stays the same through all epochs (passes) - - [Optimal](http://leon.bottou.org/projects/sgd): a heuristic proposed by Leon Bottou - - [Inverse scaling](http://users.ics.aalto.fi/jhollmen/dippa/node22.html): earning rate is inversely related to the number of iterations - - Initial learning rate. - - Inverse scaling exponent: learning rate decay. - - Number of iterations: the number of passes through the training data. - - If *Shuffle data after each iteration* is on, the order of data instances is mixed after each pass. - - If *Fixed seed for random shuffling* is on, the algorithm will use a fixed random seed and enable replicating the results. -5. Produce a report. -6. Press *Apply* to commit changes. Alternatively, tick the box on the left side of the *Apply* button and changes will be communicated automatically. - -Preprocessing -------------- - -SGD uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values -- normalizes the data by centering to mean and scaling to standard deviation of 1 - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Examples --------- - -For the classification task, we will use *iris* dataset and test two models on it. We connected [Stochastic Gradient Descent](../model/stochasticgradient.md) and [Tree](../model/tree.md) to [Test & Score](../evaluate/testandscore.md). We also connected [File](../data/file.md) to **Test & Score** and observed model performance in the widget. - -![](images/StochasticGradientDescent-classification.png) - -For the regression task, we will compare three different models to see which predict what kind of results. For the purpose of this example, the *housing* dataset is used. We connect the [File](../data/file.md) widget to **Stochastic Gradient Descent**, [Linear Regression](../model/linearregression.md) and [kNN](../model/knn.md) widget and all four to the [Predictions](../evaluate/predictions.md) widget. - -![](images/StochasticGradientDescent-regression.png) diff --git a/doc/visual-programming/source/widgets/model/svm.md b/doc/visual-programming/source/widgets/model/svm.md deleted file mode 100644 index d4580763007..00000000000 --- a/doc/visual-programming/source/widgets/model/svm.md +++ /dev/null @@ -1,69 +0,0 @@ -SVM -=== - -Support Vector Machines map inputs to higher-dimensional feature spaces. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: linear regression learning algorithm -- Model: trained model -- Support Vectors: instances used as support vectors - -[Support vector machine](https://en.wikipedia.org/wiki/Support_vector_machine) (SVM) is a machine learning technique that separates the attribute space with a hyperplane, thus maximizing the margin between the instances of different classes or class values. The technique often yields supreme predictive performance results. Orange embeds a popular implementation of SVM from the [LIBSVM](https://www.csie.ntu.edu.tw/~cjlin/libsvm/) package. This widget is its graphical user interface. - -For regression tasks, **SVM** performs linear regression in a high dimension feature space using an ε-insensitive loss. Its estimation accuracy depends on a good setting of C, ε and kernel parameters. The widget outputs class predictions based on a [SVM Regression](https://en.wikipedia.org/wiki/Support_vector_machine#Regression). - -The widget works for both classification and regression tasks. - -![](images/SVM-stamped.png) - -1. The learner can be given a name under which it will appear in other widgets. The default name is "SVM". -2. SVM type with test error settings. *SVM* and *ν-SVM* are based on different minimization of the error function. On the right side, you can set test error bounds: - - [SVM](http://scikit-learn.org/stable/modules/generated/sklearn.svm.SVR.html): - - [Cost](http://www.quora.com/What-are-C-and-gamma-with-regards-to-a-support-vector-machine): penalty term for loss and applies for classification and regression tasks. - - ε: a parameter to the epsilon-SVR model, applies to regression tasks. Defines the distance from true values within which no penalty is associated with predicted values. - - [ν-SVM](http://scikit-learn.org/stable/modules/generated/sklearn.svm.NuSVR.html#sklearn.svm.NuSVR): - - [Cost](http://www.quora.com/What-are-C-and-gamma-with-regards-to-a-support-vector-machine): penalty term for loss and applies only to regression tasks - - ν: a parameter to the ν-SVR model, applies to classification and regression tasks. An upper bound on the fraction of training errors and a lower bound of the fraction of support vectors. -3. Kernel is a function that transforms attribute space to a new feature space to fit the maximum-margin hyperplane, thus allowing the algorithm to create the model with [Linear](https://en.wikipedia.org/wiki/Linear_model), [Polynomial](https://en.wikipedia.org/wiki/Polynomial_kernel), [RBF](https://en.wikipedia.org/wiki/Radial_basis_function_kernel) and [Sigmoid](http://crsouza.com/2010/03/kernel-functions-for-machine-learning-applications/#sigmoid) kernels. Functions that specify the kernel are presented upon selecting them, and the constants involved are: - - **g** for the gamma constant in kernel function (the recommended value is 1/k, where k is the number of the attributes, but since there may be no training set given to the widget the default is 0 and the user has to set this option manually), - - **c** for the constant c0 in the kernel function (default 0), and - - **d** for the degree of the kernel (default 3). -4. Set permitted deviation from the expected value in *Numerical Tolerance*. Tick the box next to *Iteration Limit* to set the maximum number of iterations permitted. -5. Produce a report. -6. Click *Apply* to commit changes. If you tick the box on the left side of the *Apply* button, changes will be communicated automatically. - -Preprocessing -------------- - -SVM uses default preprocessing when no other preprocessors are given. It executes them in the following order: - -- removes instances with unknown target values -- continuizes categorical variables (with one-hot-encoding) -- removes empty columns -- imputes missing values with mean values - -For classification, SVM also normalizes dense and scales sparse data. - -To remove default preprocessing, connect an empty [Preprocess](../data/preprocess.md) widget to the learner. - -Examples --------- - -In the first (regression) example, we have used *housing* dataset and split the data into two data subsets (*Data Sample* and *Remaining Data*) with [Data Sampler](../data/datasampler.md). The sample was sent to SVM which produced a *Model*, which was then used in [Predictions](../evaluate/predictions.md) to predict the values in *Remaining Data*. A similar schema can be used if the data is already in two separate files; in this case, two [File](../data/file.md) widgets would be used instead of the [File](../data/file.md) - [Data Sampler](../data/datasampler.md) combination. - -![](images/SVM-Predictions.png) - -The second example shows how to use **SVM** in combination with [Scatter Plot](../visualize/scatterplot.md). The following workflow trains a SVM model on *iris* data and outputs support vectors, which are those data instances that were used as support vectors in the learning phase. We can observe which are these data instances in a scatter plot visualization. Note that for the workflow to work correctly, you must set the links between widgets as demonstrated in the screenshot below. - -![](images/SVM-support-vectors.png) - -References ----------- - -[Introduction to SVM on StatSoft](http://www.statsoft.com/Textbook/Support-Vector-Machines). diff --git a/doc/visual-programming/source/widgets/model/tree.md b/doc/visual-programming/source/widgets/model/tree.md deleted file mode 100644 index 910246ae9e5..00000000000 --- a/doc/visual-programming/source/widgets/model/tree.md +++ /dev/null @@ -1,49 +0,0 @@ -Tree -==== - -A tree algorithm with forward pruning. - -**Inputs** - -- Data: input dataset -- Preprocessor: preprocessing method(s) - -**Outputs** - -- Learner: decision tree learning algorithm -- Model: trained model - -**Tree** is a simple algorithm that splits the data into nodes by class purity (information gain for categorical and MSE for numeric target variable). It is a precursor to [Random Forest](../model/randomforest.md). Tree in Orange is designed in-house and can handle both categorical and numeric datasets. - -It can also be used for both classification and regression tasks. - -![](images/Tree-stamped.png) - -1. The learner can be given a name under which it will appear in other widgets. The default name is "Tree". -2. Tree parameters: - - **Induce binary tree**: build a binary tree (split into two child nodes) - - **Min. number of instances in leaves**: if checked, the algorithm will never construct a split which would put less than the specified number of training examples into any of the branches. - - **Do not split subsets smaller than**: forbids the algorithm to split the nodes with less than the given number of instances. - - **Limit the maximal tree depth**: limits the depth of the classification tree to the specified number of node levels. -3. **Stop when majority reaches [%]**: stop splitting the nodes after a specified majority threshold is reached -4. Produce a report. After changing the settings, you need to click *Apply*, which will put the new learner on the output and, if the training examples are given, construct a new classifier and output it as well. Alternatively, tick the box on the left and changes will be communicated automatically. - -Preprocessing -------------- - -Tree does not use any preprocessing. - -Examples --------- - -There are two typical uses for this widget. First, you may want to induce a model and check what it looks like in [Tree Viewer](../visualize/treeviewer.md). - -![](images/Tree-classification-visualize.png) - -The second schema trains a model and evaluates its performance against [Logistic Regression](../model/logisticregression.md). - -![](images/Tree-classification-model.png) - -We used the *iris* dataset in both examples. However, **Tree** works for regression tasks as well. Use *housing* dataset and pass it to **Tree**. The selected tree node from [Tree Viewer](../visualize/treeviewer.md) is presented in the [Scatter Plot](../visualize/scatterplot.md) and we can see that the selected examples exhibit the same features. - -![](images/Tree-regression-subset.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/DBSCAN.md b/doc/visual-programming/source/widgets/unsupervised/DBSCAN.md deleted file mode 100644 index bb3ed06d287..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/DBSCAN.md +++ /dev/null @@ -1,46 +0,0 @@ -DBSCAN -====== - -Groups items using the DBSCAN clustering algorithm. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: dataset with cluster index as a class attribute - -The widget applies the -[DBSCAN clustering](https://en.wikipedia.org/wiki/DBSCAN) algorithm to -the data and outputs a new dataset with cluster indices as a meta -attribute. The widget also shows the sorted graph with distances to -k-th nearest neighbors. With k values set to **Core point neighbors** -as suggested in the -[methods article](https://www.aaai.org/Papers/KDD/1996/KDD96-037.pdf). -This gives the user the idea of an -ideal selection for **Neighborhood distance** setting. As suggested by -authors this parameter should be set to the first value in the first -"valley" in the graph. - -![](images/dbscan-stamped.png) - -1. Set *minimal number of core neighbors* for a cluster and *maximal -neighborhood distance. -2. Set the distance metric that is used in grouping the items. -3. If *Apply Automatically* is ticked, the widget will commit changes -automatically. Alternatively, click *Apply*. -4. The graph shows the distance to the k-th nearest neighbor. *k* is -set by the **Core point neighbor** option. With moving the black slider -left and right you can select the right **Neighbourhood distance**. - -Example -------- - -In the following example, we connected the File widget with selected -Iris dataset to the DBSCAN widget. In the DBSCAN widget, we set -**Core points neighbors** parameter to 5. And select the -**Neighbourhood distance** to the value in the first "valley" in the -graph. We show clusters in the Scatter Plot widget. - -![](images/dbscan-example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/PCA.md b/doc/visual-programming/source/widgets/unsupervised/PCA.md deleted file mode 100644 index f4b3fdd5416..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/PCA.md +++ /dev/null @@ -1,46 +0,0 @@ -PCA -=== - -PCA linear transformation of input data. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Transformed Data: PCA transformed data -- Components: [Eigenvectors](https://en.wikipedia.org/wiki/Eigenvalues_and_eigenvectors). - -[Principal Component Analysis](https://en.wikipedia.org/wiki/Principal_component_analysis) (PCA) computes the PCA linear transformation of the input data. It outputs either a transformed dataset with weights of individual instances or weights of principal components. - -![](images/PCA-stamped.png) - -1. Select how many principal components you wish in your output. It is best to choose as few as possible with variance covered as high as possible. You can also set how much variance you wish to cover with your principal components. -2. You can normalize data to adjust the values to common scale. If checked, columns are divided by their standard deviations. -3. When *Apply Automatically* is ticked, the widget will automatically communicate all changes. Alternatively, click *Apply*. -4. Press *Save Image* if you want to save the created image to your computer. -5. Produce a report. -6. Principal components graph, where the red (lower) line is the variance covered per component and the green (upper) line is cumulative variance covered by components. - -The number of components of the transformation can be selected either in the *Components Selection* input box or by dragging the vertical cutoff line in the graph. - -Preprocessing -------------- - -The widget preprocesses the input data in the following order: - -- continuizes categorical variables (with one-hot-encoding) -- imputes missing values with mean values -- if *Normalize variables* is checked, it divides columns by their standard deviation. - -Examples --------- - -**PCA** can be used to simplify visualizations of large datasets. Below, we used the *Iris* dataset to show how we can improve the visualization of the dataset with PCA. The transformed data in the [Scatter Plot](../visualize/scatterplot.md) show a much clearer distinction between classes than the default settings. - -![](images/PCAExample.png) - -The widget provides two outputs: transformed data and principal components. Transformed data are weights for individual instances in the new coordinate system, while components are the system descriptors (weights for principal components). When fed into the [Data Table](../data/datatable.md), we can see both outputs in numerical form. We used two data tables in order to provide a more clean visualization of the workflow, but you can also choose to edit the links in such a way that you display the data in just one data table. You only need to create two links and connect the *Transformed data* and *Components* inputs to the *Data* output. - -![](images/PCAExample2.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/correspondenceanalysis.md b/doc/visual-programming/source/widgets/unsupervised/correspondenceanalysis.md deleted file mode 100644 index ced266e4d3f..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/correspondenceanalysis.md +++ /dev/null @@ -1,28 +0,0 @@ -Correspondence Analysis -======================= - -Correspondence analysis for categorical multivariate data. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Coordinates: coordinates of all components - -[Correspondence Analysis](https://en.wikipedia.org/wiki/Correspondence_analysis) (CA) computes the CA linear transformation of the input data. While it is similar to PCA, CA computes linear transformation on discrete rather than on continuous data. - -![](images/CorrespondenceAnalysis-stamped.png) - -1. Select the variables you want to see plotted. -2. Select the component for each axis. -3. [Inertia](https://en.wikipedia.org/wiki/Sylvester%27s_law_of_inertia) values (percentage of independence from transformation, i.e. variables are in the same dimension). -4. Produce a report. - -Example -------- - -Below, is a simple comparison between the **Correspondence Analysis** and [Scatter Plot](../visualize/scatterplot.md) widgets on the *Titanic* dataset. While the [Scatter Plot](../visualize/scatterplot.md) shows fairly well which class and sex had a good survival rate and which one didn't, **Correspondence Analysis** can plot several variables in a 2-D graph, thus making it easy to see the relations between variable values. It is clear from the graph that "no", "male" and "crew" are related to each other. The same goes for "yes", "female" and "first". - -![](images/CorrespondenceAnalysis-Example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/distancefile.md b/doc/visual-programming/source/widgets/unsupervised/distancefile.md deleted file mode 100644 index f9f819418b3..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/distancefile.md +++ /dev/null @@ -1,25 +0,0 @@ -Distance File -============= - -Loads an existing distance file. - -**Outputs** - -- Distance File: distance matrix - -![](images/DistanceFile-stamped.png) - -1. Choose from a list of previously saved distance files. -2. Browse for saved distance files. -3. Reload the selected distance file. -4. Information about the distance file (number of points, - labelled/unlabelled). -5. Browse documentation datasets. -6. Produce a report. - -Example -------- - -When you want to use a custom-set distance file that you've saved before, open the **Distance File** widget and select the desired file with the *Browse* icon. This widget loads the existing distance file. In the snapshot below, we loaded the transformed *Iris* distance matrix from the [Save Distance Matrix](../unsupervised/savedistancematrix.md) example. We displayed the transformed data matrix in the [Distance Map](../unsupervised/distancemap.md) widget. We also decided to display a distance map of the original *Iris* dataset for comparison. - -![](images/DistanceFile-Example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/distancemap.md b/doc/visual-programming/source/widgets/unsupervised/distancemap.md deleted file mode 100644 index 61a3766760f..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/distancemap.md +++ /dev/null @@ -1,52 +0,0 @@ -Distance Map -============ - -Visualizes distances between items. - -**Inputs** - -- Distances: distance matrix - -**Outputs** - -- Data: instances selected from the matrix -- Features: attributes selected from the matrix - -The **Distance Map** visualizes distances between objects. The visualization is the same as if we printed out a table of numbers, except that the numbers are replaced by colored spots. - -Distances are most often those between instances ("*rows*" in the [Distances](../unsupervised/distances.md) widget) or attributes ("*columns*" in Distances widget). The only suitable input for **Distance Map** is the [Distances](../unsupervised/distances.md) widget. For the output, the user can select a region of the map and the widget will output the corresponding instances or attributes. Also note that the **Distances** widget ignores discrete values and calculates distances only for continuous data, thus it can only display distance map for discrete data if you [Continuize](../data/continuize.md) them first. - -The snapshot shows distances between columns in the *heart disease* data, where smaller distances are represented with light and larger with dark orange. The matrix is symmetric and the diagonal is a light shade of orange - no attribute is different from itself. Symmetricity is always assumed, while the diagonal may also be non-zero. - -![](images/DistanceMap-stamped.png) - -1. *Element sorting* arranges elements in the map by - - None (lists instances as found in the dataset) - - **Clustering** (clusters data by similarity) - - **Clustering with ordered leaves** (maximizes the sum of similarities of adjacent elements) -2. *Colors* - - **Colors** (select the color palette for your distance map) - - **Low** and **High** are thresholds for the color palette (low for instances or attributes with low distances and high for instances or attributes with high distances). -3. Select *Annotations*. -4. If *Send Selected Automatically* is on, the data subset is communicated automatically, otherwise you need to press *Send Selected*. -5. Press *Save Image* if you want to save the created image to your computer. -6. Produce a report. - -Normally, a color palette is used to visualize the entire range of distances appearing in the matrix. This can be changed by setting the low and high threshold. In this way we ignore the differences in distances outside this interval and visualize the interesting part of the distribution. - -Below, we visualized the most correlated attributes (distances by columns) in the *heart disease* dataset by setting the color threshold for high distances to the minimum. We get a predominantly black square, where attributes with the lowest distance scores are represented by a lighter shade of the selected color schema (in our case: orange). Beside the diagonal line, we see that in our example *ST by exercise* and *major vessels colored* are the two attributes closest together. - -![](images/DistanceMap-Highlighted.png) - -The user can select a region in the map with the usual click-and-drag of the cursor. When a part of the map is selected, the widget outputs all items from the selected cells. - -Examples --------- - -The first workflow shows a very standard use of the **Distance Map** widget. We select 70% of the original *Iris* data as our sample and view the distances between rows in **Distance Map**. - -![](images/DistanceMap-Example1.png) - -In the second example, we use the *heart disease* data again and select a subset of women only from the [Scatter Plot](../visualize/scatterplot.md). Then, we visualize distances between columns in the **Distance Map**. Since the subset also contains some discrete data, the [Distances](../unsupervised/distances.md) widget warns us it will ignore the discrete features, thus we will see only continuous instances/attributes in the map. - -![](images/DistanceMap-Example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/distancematrix.md b/doc/visual-programming/source/widgets/unsupervised/distancematrix.md deleted file mode 100644 index 8777fbfa69e..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/distancematrix.md +++ /dev/null @@ -1,31 +0,0 @@ -Distance Matrix -=============== - -Visualizes distance measures in a distance matrix. - -**Inputs** - -- Distances: distance matrix - -**Outputs** - -- Distances: distance matrix -- Table: distance measures in a distance matrix - -The **Distance Matrix** widget creates a distance matrix, which is a two-dimensional array containing the distances, taken pairwise, between the elements of a set. The number of elements in the dataset defines the size of the matrix. Data matrices are essential for hierarchical clustering and they are extremely useful in bioinformatics as well, where they are used to represent protein structures in a coordinate-independent manner. - -![](images/DistanceMatrix-stamped.png) - -1. Elements in the dataset and the distances between them. -2. Label the table. The options are: *none*, *enumeration*, *according to variables*. -3. Produce a report. -4. Click *Send* to communicate changes to other widgets. Alternatively, tick the box in front of the *Send* button and changes will be communicated automatically (*Send Automatically*). - -The only two suitable inputs for **Distance Matrix** are the [Distances](../unsupervised/distances.md) widget and the [Distance Transformation](../unsupervised/distancetransformation.md) widget. The output of the widget is a data table containing the distance matrix. The user can decide how to label the table and the distance matrix (or instances in the distance matrix) can then be visualized or displayed in a separate data table. - -Example -------- - -The example below displays a very standard use of the **Distance Matrix** widget. We compute the distances between rows in the sample from the *Iris* dataset and output them in the **Distance Matrix**. It comes as no surprise that Iris Virginica and Iris Setosa are the furthest apart. - -![](images/DistanceMatrix-Example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/distances.md b/doc/visual-programming/source/widgets/unsupervised/distances.md deleted file mode 100644 index f66976b87df..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/distances.md +++ /dev/null @@ -1,55 +0,0 @@ -Distances -========= - -Computes distances between rows/columns in a dataset. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Distances: distance matrix - -The **Distances** widget computes distances between rows or columns in a dataset. By default, the data will be normalized to ensure equal treatment of individual features. Normalization is always done column-wise. - -Sparse data can only be used with Euclidean, Manhattan and Cosine metric. - -The resulting distance matrix can be fed further to [Hierarchical Clustering](hierarchicalclustering.md) for uncovering groups in the data, to [Distance Map](distancemap.md) or [Distance Matrix](distancematrix.md) for visualizing the distances (Distance Matrix can be quite slow for larger data sets), to [MDS](mds.md) for mapping the data instances using the distance matrix and finally, saved with [Save Distance Matrix](savedistancematrix.md). Distance file can be loaded with [Distance File](distancefile.md). - -Distances work well with Orange add-ons, too. The distance matrix can be fed to Network from Distances (Network add-on) to convert the matrix into a graph and to Duplicate Detection (Text add-on) to find duplicate documents in the corpus. - -![](images/Distances-stamped.png) - -1. Choose whether to measure distances between rows or columns. -2. Choose the *Distance Metric*: - - [Euclidean](https://en.wikipedia.org/wiki/Euclidean_distance) ("straight line", distance between two points) - - [Manhattan](https://en.wiktionary.org/wiki/Manhattan_distance) (the sum of absolute differences for all attributes) - - [Cosine](https://en.wikipedia.org/wiki/Cosine_similarity) (the cosine of the angle between two vectors of an inner product space) - - [Jaccard](https://en.wikipedia.org/wiki/Jaccard_index) (the size of the intersection divided by the size of the union of the sample sets) - - [Spearman](https://en.wikipedia.org/wiki/Spearman's_rank_correlation_coefficient)(linear correlation between the rank of the values, remapped as a distance in a [0, 1] interval) - - [Spearman absolute](https://en.wikipedia.org/wiki/Spearman's_rank_correlation_coefficient)(linear correlation between the rank of the absolute values, remapped as a distance in a [0, 1] interval) - - [Pearson](https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient) (linear correlation between the values, remapped as a distance in a [0, 1] interval) - - [Pearson absolute](https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient) (linear correlation between the absolute values, remapped as a distance in a [0, 1] interval) - - [Hamming](https://en.wikipedia.org/wiki/Hamming_distance) (the number of features at which the corresponding values are different) - - [Bhattacharyya distance](https://en.wikipedia.org/wiki/Bhattacharyya_distance) (Similarity between two probability distributions, not a real distance as it doesn't obey triangle inequality.) - - Normalize the features. Normalization is always done column-wise. Values are zero centered and scaled. - In case of missing values, the widget automatically imputes the average value of the row or the column. - The widget works for both numeric and categorical data. In case of categorical data, the distance is 0 if the two values are the same ('green' and 'green') and 1 if they are not ('green' and 'blue'). -3. Tick *Apply Automatically* to automatically commit changes to other widgets. Alternatively, press '*Apply*'. - -Examples --------- - -The first example shows a typical use of the **Distances** widget. We are using the *iris.tab* data from the [File](../data/file.md) widget. We compute distances between data instances (rows) and pass the result to the [Hierarchical Clustering](hierarchicalclustering.md). This is a simple workflow to find groups of data instances. - -![](images/Distances-Example1-rows.png) - -Alternatively, we can compute distance between columns and find how similar our features are. - -![](images/Distances-Example1-columns.png) - -The second example shows how to visualize the resulting distance matrix. A nice way to observe data similarity is in a [Distance Map](distancemap.md) or in [MDS](mds.md). - -![](images/Distances-Example2.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/distancetransformation.md b/doc/visual-programming/source/widgets/unsupervised/distancetransformation.md deleted file mode 100644 index e86d3839f67..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/distancetransformation.md +++ /dev/null @@ -1,37 +0,0 @@ -Distance Transformation -======================= - -Transforms distances in a dataset. - -**Inputs** - -- Distances: distance matrix - -**Outputs** - -- Distances: transformed distance matrix - -The **Distances Transformation** widget is used for the normalization and inversion of distance matrices. The normalization of data is necessary to bring all the variables into proportion with one another. - -![](images/DistanceTransformation-stamped.png) - -1. Choose the type of [Normalization](https://en.wikipedia.org/wiki/Normalization_\(statistics\)): - - **No normalization** - - **To interval [0, 1]** - - **To interval [-1, 1]** - - [Sigmoid function](https://en.wikipedia.org/wiki/Sigmoid_function): 1/(1+exp(-X)) -2. Choose the type of Inversion: - - **No inversion** - - **-X** - - **1 - X** - - **max(X) - X** - - **1/X** -3. Produce a report. -4. After changing the settings, you need to click *Apply* to commit changes to other widgets. Alternatively, tick *Apply automatically*. - -Example -------- - -In the snapshot below, you can see how transformation affects the distance matrix. We loaded the *Iris* dataset and calculated the distances between rows with the help of the [Distances](../unsupervised/distances.md) widget. In order to demonstrate how **Distance Transformation** affects the [Distance Matrix](../unsupervised/distancematrix.md), we created the workflow below and compared the transformed distance matrix with the "original" one. - -![](images/DistanceTransformation-Example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/hierarchicalclustering.md b/doc/visual-programming/source/widgets/unsupervised/hierarchicalclustering.md deleted file mode 100644 index c70bc32a8bd..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/hierarchicalclustering.md +++ /dev/null @@ -1,45 +0,0 @@ -Hierarchical Clustering -======================= - -Groups items using a hierarchical clustering algorithm. - -**Inputs** - -- Distances: distance matrix - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether an instance is selected - -The widget computes [hierarchical clustering](https://en.wikipedia.org/wiki/Hierarchical_clustering) of arbitrary types of objects from a matrix of distances and shows a corresponding [dendrogram](https://en.wikipedia.org/wiki/Dendrogram). - -![](images/HierarchicalClustering-stamped.png) - -1. The widget supports four ways of measuring distances between clusters: - - **Single linkage** computes the distance between the closest elements of the two clusters - - **Average linkage** computes the average distance between elements of the two clusters - - **Weighted linkage** uses the [WPGMA](http://research.amnh.org/~siddall/methods/day1.html) method - - **Complete linkage** computes the distance between the clusters' most distant elements -2. Labels of nodes in the dendrogram can be chosen in the **Annotation** box. -3. Huge dendrograms can be pruned in the *Pruning* box by selecting the maximum depth of the dendrogram. This only affects the display, not the actual clustering. -4. The widget offers three different selection methods: - - **Manual** (Clicking inside the dendrogram will select a cluster. Multiple clusters can be selected by holding Ctrl/Cmd. Each selected cluster is shown in a different color and is treated as a separate cluster in the output.) - - **Height ratio** (Clicking on the bottom or top ruler of the dendrogram places a cutoff line in the graph. Items to the right of the line are selected.) - - **Top N** (Selects the number of top nodes.) -5. Use *Zoom* and scroll to zoom in or out. -6. If the items being clustered are instances, they can be added a cluster index (*Append cluster IDs*). The ID can appear as an ordinary **Attribute**, **Class attribute** or a **Meta attribute**. In the second case, if the data already has a class attribute, the original class is placed among meta attributes. -7. The data can be automatically output on any change (*Auto send is on*) or, if the box isn't ticked, by pushing *Send Data*. -8. Clicking this button produces an image that can be saved. -9. Produce a report. - -Examples --------- - -The workflow below shows the output of **Hierarchical Clustering** for the *Iris* dataset in [Data Table](../data/datatable.md) widget. We see that if we choose *Append cluster IDs* in hierarchical clustering, we can see an additional column in the **Data Table** named *Cluster*. This is a way to check how hierarchical clustering clustered individual instances. - -![](images/HierarchicalClustering-Example.png) - -In the second example, we loaded the *Iris* dataset again, but this time we added the [Scatter Plot](../visualize/scatterplot.md), showing all the instances from the [File](../data/file.md) widget, while at the same time receiving the selected instances signal from **Hierarchical Clustering**. This way we can observe the position of the selected cluster(s) in the projection. - -![](images/HierarchicalClustering-Example2.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/correspondence-analysis.png b/doc/visual-programming/source/widgets/unsupervised/icons/correspondence-analysis.png deleted file mode 100644 index 0dfde060767..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/correspondence-analysis.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/distance-file.png b/doc/visual-programming/source/widgets/unsupervised/icons/distance-file.png deleted file mode 100644 index 16429c3b52b..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/distance-file.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/distance-map.png b/doc/visual-programming/source/widgets/unsupervised/icons/distance-map.png deleted file mode 100644 index 1c6ad541e8e..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/distance-map.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/distance-matrix.png b/doc/visual-programming/source/widgets/unsupervised/icons/distance-matrix.png deleted file mode 100644 index 833c3b636d6..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/distance-matrix.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/distance-transformation.png b/doc/visual-programming/source/widgets/unsupervised/icons/distance-transformation.png deleted file mode 100644 index c593dfa85a0..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/distance-transformation.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/distances.png b/doc/visual-programming/source/widgets/unsupervised/icons/distances.png deleted file mode 100644 index 0f0b464a1d8..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/distances.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/hierarchical-clustering.png b/doc/visual-programming/source/widgets/unsupervised/icons/hierarchical-clustering.png deleted file mode 100644 index e3aa9a0cdf0..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/hierarchical-clustering.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/k-means.png b/doc/visual-programming/source/widgets/unsupervised/icons/k-means.png deleted file mode 100644 index 5bfddfda34f..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/k-means.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/manifold-learning.png b/doc/visual-programming/source/widgets/unsupervised/icons/manifold-learning.png deleted file mode 100644 index 131bbcbf6de..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/manifold-learning.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/mds.png b/doc/visual-programming/source/widgets/unsupervised/icons/mds.png deleted file mode 100644 index 527ef3c2ba1..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/mds.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/pca.png b/doc/visual-programming/source/widgets/unsupervised/icons/pca.png deleted file mode 100644 index 16a4f2c31ff..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/pca.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/save-distance-matrix.png b/doc/visual-programming/source/widgets/unsupervised/icons/save-distance-matrix.png deleted file mode 100644 index 69f632ab250..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/save-distance-matrix.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/silhouette-plot.png b/doc/visual-programming/source/widgets/unsupervised/icons/silhouette-plot.png deleted file mode 100644 index 87caf0b4943..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/silhouette-plot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/som.png b/doc/visual-programming/source/widgets/unsupervised/icons/som.png deleted file mode 100644 index deda3197d40..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/som.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/icons/tSNE.png b/doc/visual-programming/source/widgets/unsupervised/icons/tSNE.png deleted file mode 100644 index 0f6631f4c6a..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/icons/tSNE.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis-Example.png deleted file mode 100644 index 6b51afe40c2..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis-stamped.png deleted file mode 100644 index ab9aac63363..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis.png b/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis.png deleted file mode 100644 index 9a76b4785c6..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/CorrespondenceAnalysis.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile-Example.png deleted file mode 100644 index 31ca5240862..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile-stamped.png deleted file mode 100644 index 16ffa6ed4c1..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile.png deleted file mode 100644 index 5c0dc8b24d5..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceFile.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Example.png deleted file mode 100644 index 1bb2a8b4bbd..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Example1.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Example1.png deleted file mode 100644 index d63d24d25bf..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Highlighted.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Highlighted.png deleted file mode 100644 index ba7b1eb314b..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-Highlighted.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-stamped.png deleted file mode 100644 index 8ed40fe9e77..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap.png deleted file mode 100644 index afccde1e1e5..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMap.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix-Example.png deleted file mode 100644 index 0140e13b7a8..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix-stamped.png deleted file mode 100644 index 00636bf13c3..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix.png deleted file mode 100644 index cb7b3af9507..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceMatrix.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation-Example.png deleted file mode 100644 index 1f2fbcdf690..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation-stamped.png deleted file mode 100644 index f3a6a52bc33..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation.png b/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation.png deleted file mode 100644 index 5d6b3b704ca..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/DistanceTransformation.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example1-columns.png b/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example1-columns.png deleted file mode 100644 index 00d74b3c1b4..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example1-columns.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example1-rows.png b/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example1-rows.png deleted file mode 100644 index c4b23b66599..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example1-rows.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example2.png b/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example2.png deleted file mode 100644 index 77180d3cc66..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/Distances-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/Distances-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/Distances-stamped.png deleted file mode 100644 index 550930eee41..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/Distances-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-Example.png deleted file mode 100644 index d3b455c24b6..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-Example2.png b/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-Example2.png deleted file mode 100644 index 240694ce635..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-stamped.png deleted file mode 100644 index fdc3a9a132b..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering.png b/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering.png deleted file mode 100644 index 9e7eaabb25e..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/HierarchicalClustering.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Example.png deleted file mode 100644 index bd8decd1bf6..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Example2.png b/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Example2.png deleted file mode 100644 index 3f650e98b5d..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Schema.png b/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Schema.png deleted file mode 100644 index 84b529f78d8..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Schema.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Schema2.png b/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Schema2.png deleted file mode 100644 index 1048ced97d3..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/K-MeansClustering-Schema2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/Louvain-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/Louvain-Example.png deleted file mode 100644 index fbb98caa6b1..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/Louvain-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/Louvain-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/Louvain-stamped.png deleted file mode 100644 index ec249c930cd..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/Louvain-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/MDS-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/MDS-Example.png deleted file mode 100644 index 37908e26503..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/MDS-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/MDS-zoo-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/MDS-zoo-stamped.png deleted file mode 100644 index cbfdb86f565..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/MDS-zoo-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/MDS-zoo.png b/doc/visual-programming/source/widgets/unsupervised/images/MDS-zoo.png deleted file mode 100644 index 419e1d1c70c..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/MDS-zoo.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/PCA-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/PCA-stamped.png deleted file mode 100644 index 181a6440fbf..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/PCA-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/PCA.png b/doc/visual-programming/source/widgets/unsupervised/images/PCA.png deleted file mode 100644 index c11d8961f54..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/PCA.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/PCAExample.png b/doc/visual-programming/source/widgets/unsupervised/images/PCAExample.png deleted file mode 100644 index 530b01d5e06..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/PCAExample.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/PCAExample2.png b/doc/visual-programming/source/widgets/unsupervised/images/PCAExample2.png deleted file mode 100644 index 9f2dab3679d..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/PCAExample2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix-Example.png deleted file mode 100644 index b25dfee3d52..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix-stamped.png deleted file mode 100644 index 584152e1e58..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix.png b/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix.png deleted file mode 100644 index eab333598fd..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/SaveDistanceMatrix.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/Self-Organizing_Map-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/Self-Organizing_Map-stamped.png deleted file mode 100644 index e95e856d372..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/Self-Organizing_Map-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/Self-Organizing_Map_Example.png b/doc/visual-programming/source/widgets/unsupervised/images/Self-Organizing_Map_Example.png deleted file mode 100644 index b5fc5f4344b..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/Self-Organizing_Map_Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot-Example.png b/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot-Example.png deleted file mode 100644 index dade8110ac3..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot-stamped.png deleted file mode 100644 index 4b0c560f822..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot.png b/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot.png deleted file mode 100644 index a4597123ef9..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/SilhouettePlot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/collage-manifold.png b/doc/visual-programming/source/widgets/unsupervised/images/collage-manifold.png deleted file mode 100644 index c1689aebe60..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/collage-manifold.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/dbscan-example.png b/doc/visual-programming/source/widgets/unsupervised/images/dbscan-example.png deleted file mode 100644 index 90cfe393e5d..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/dbscan-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/dbscan-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/dbscan-stamped.png deleted file mode 100644 index d6af3d36b15..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/dbscan-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/kMeans-Scatterplot.png b/doc/visual-programming/source/widgets/unsupervised/images/kMeans-Scatterplot.png deleted file mode 100644 index 7f4d82fb027..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/kMeans-Scatterplot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/kMeans-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/kMeans-stamped.png deleted file mode 100644 index b00905c6808..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/kMeans-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/kMeans.png b/doc/visual-programming/source/widgets/unsupervised/images/kMeans.png deleted file mode 100644 index 8fc2aa827c8..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/kMeans.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/manifold-learning-example.png b/doc/visual-programming/source/widgets/unsupervised/images/manifold-learning-example.png deleted file mode 100644 index 9625be820c1..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/manifold-learning-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/manifold-learning-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/manifold-learning-stamped.png deleted file mode 100644 index 149ba2c3d4c..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/manifold-learning-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/tSNE-Example1.png b/doc/visual-programming/source/widgets/unsupervised/images/tSNE-Example1.png deleted file mode 100644 index 6f79cff177d..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/tSNE-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/tSNE-Example2.png b/doc/visual-programming/source/widgets/unsupervised/images/tSNE-Example2.png deleted file mode 100644 index 36b07965d31..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/tSNE-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/images/tSNE-stamped.png b/doc/visual-programming/source/widgets/unsupervised/images/tSNE-stamped.png deleted file mode 100644 index 428db0899d0..00000000000 Binary files a/doc/visual-programming/source/widgets/unsupervised/images/tSNE-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/unsupervised/kmeans.md b/doc/visual-programming/source/widgets/unsupervised/kmeans.md deleted file mode 100644 index cba15b54272..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/kmeans.md +++ /dev/null @@ -1,60 +0,0 @@ -k-Means -======= - -Groups items using the k-Means clustering algorithm. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: dataset with cluster index as a class attribute - -The widget applies the [k-Means clustering](https://en.wikipedia.org/wiki/K-means_clustering) algorithm to the data and outputs a new dataset in which the cluster index is used as a class attribute. The original class attribute, if it exists, is moved to meta attributes. Scores of clustering results for various k are also shown in the widget. - -![](images/kMeans-stamped.png) - -1. Select the number of clusters. - - **Fixed**: algorithm clusters data in a specified number of clusters. - - **Optimized**: widget shows clustering scores for the selected cluster range: - - [Silhouette](https://en.wikipedia.org/wiki/Silhouette_\(clustering\)) (contrasts average distance to elements in the same cluster with the average distance to elements in other clusters) - - **Inter-cluster distance** (measures distances between clusters,normally between centroids) - - **Distance to** [centroids](https://en.wikipedia.org/wiki/Centroid) (measures distances to the arithmetic means of clusters) -2. Select the initialization method (the way the algorithm begins clustering): - - [k-Means++](https://en.wikipedia.org/wiki/K-means%2B%2B) (first center is selected randomly, subsequent are chosen from the remaining points with probability proportioned to squared distance from the closest center) - - **Random initialization** (clusters are assigned randomly at first and then updated with further iterations) - **Re-runs** (how many times the algorithm is run from random initial positions; the result with the lowest within-cluster sum of squares will be used) and **maximal iterations** (the maximum number of iterations within each algorithm run) can be set manually. -3. The widget outputs a new dataset with appended cluster information. Select how to append cluster information (as class, feature or meta attribute) and name the column. -4. If *Apply Automatically* is ticked, the widget will commit changes automatically. Alternatively, click *Apply*. -5. Produce a report. -6. Check scores of clustering results for various k. - -Examples --------- - -We are going to explore the widget with the following schema. - -![](images/K-MeansClustering-Schema.png) - -First, we load the *Iris* dataset, divide it into three clusters and show it in the [Data Table](../data/datatable.md), where we can observe which instance went into which cluster. The interesting parts are the [Scatter Plot](../visualize/scatterplot.md) and [Select Rows](../data/selectrows.md). - -Since **k-Means** added the cluster index as a class attribute, the scatter plot will color the points according to the clusters they are in. - -![](images/kMeans-Scatterplot.png) - -What we are really interested in is how well the clusters induced by the (unsupervised) clustering algorithm match the actual classes in the data. We thus take [Select Rows](../data/selectrows.md) widget, in which we can select individual classes and have the corresponding points marked in the scatter plot. The match is perfect for *setosa*, and pretty good for the other two classes. - -![](images/K-MeansClustering-Example.png) - -You may have noticed that we left the **Remove unused values/attributes** and **Remove unused classes** in [Select Rows](../data/selectrows.md) unchecked. This is important: if the widget modifies the attributes, it outputs a list of modified instances and the scatter plot cannot compare them to the original data. - -Perhaps a simpler way to test the match between clusters and the original classes is to use the [Distributions](../visualize/distributions.md) widget. - -![](images/K-MeansClustering-Schema2.png) - -The only (minor) problem here is that this widget only visualizes normal (and not meta) attributes. We solve this by using [Select Columns](../data/selectcolumns.md): we reinstate the original class *Iris* as the class and put the cluster index among the attributes. - -The match is perfect for *setosa*: all instances of setosa are in the third cluster (blue). 48 *versicolors* are in the second cluster (red), while two ended up in the first. For *virginicae*, 36 are in the first cluster and 14 in the second. - -![](images/K-MeansClustering-Example2.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/louvainclustering.md b/doc/visual-programming/source/widgets/unsupervised/louvainclustering.md deleted file mode 100644 index a42e7e5eccd..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/louvainclustering.md +++ /dev/null @@ -1,37 +0,0 @@ -Louvain Clustering -================== - -Groups items using the Louvain clustering algorithm. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Data: dataset with cluster index as a class attribute -- Graph (with the Network addon): the weighted k-nearest neighbor graph - -The widget first converts the input data into a k-nearest neighbor graph. To preserve the notions of distance, the Jaccard index for the number of shared neighbors is used to weight the edges. Finally, a [modularity optimization](https://en.wikipedia.org/wiki/Louvain_Modularity) community detection algorithm is applied to the graph to retrieve clusters of highly interconnected nodes. The widget outputs a new dataset in which the cluster index is used as a meta attribute. - -![](images/Louvain-stamped.png) - -1. PCA processing is typically applied to the original data to remove noise. -2. The distance metric is used for finding specified number of nearest neighbors. -3. The number of nearest neighbors to use to form the KNN graph. -4. Resolution is a parameter for the Louvain community detection algorithm that affects the size of the recovered clusters. Smaller resolutions recover smaller, and therefore a larger number of clusters, and conversely, larger values recover clusters containing more data points. -5. When *Apply Automatically* is ticked, the widget will automatically communicate all changes. Alternatively, click *Apply*. - -Example -------- - -*Louvain Clustering* converts the dataset into a graph, where it finds highly interconnected nodes. We can visualize the graph itself using the **Network Explorer** from the Network addon. - -![](images/Louvain-Example.png) - -References ----------- - -Blondel, Vincent D., et al. "Fast unfolding of communities in large networks." Journal of statistical mechanics: theory and experiment 2008.10 (2008): P10008. - -Lambiotte, Renaud, J-C. Delvenne, and Mauricio Barahona. "Laplacian dynamics and multiscale modular structure in networks." arXiv preprint, arXiv:0812.1770 (2008). diff --git a/doc/visual-programming/source/widgets/unsupervised/manifoldlearning.md b/doc/visual-programming/source/widgets/unsupervised/manifoldlearning.md deleted file mode 100644 index 39371eb3f69..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/manifoldlearning.md +++ /dev/null @@ -1,64 +0,0 @@ -Manifold Learning -================= - -Nonlinear dimensionality reduction. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Transformed Data: dataset with reduced coordinates - -[Manifold Learning](https://en.wikipedia.org/wiki/Nonlinear_dimensionality_reduction) is a technique which finds a non-linear manifold within the higher-dimensional space. The widget then outputs new coordinates which correspond to a two-dimensional space. Such data can be later visualized with [Scatter Plot](../visualize/scatterplot.md) or other visualization widgets. - -![](images/manifold-learning-stamped.png) - -1. Method for manifold learning: - - [t-SNE](http://scikit-learn.org/stable/modules/manifold.html#t-distributed-stochastic-neighbor-embedding-t-sne) - - [MDS](http://scikit-learn.org/stable/modules/manifold.html#multi-dimensional-scaling-mds), see also [MDS widget](../unsupervised/mds.md) - - [Isomap](http://scikit-learn.org/stable/modules/manifold.html#isomap) - - [Locally Linear Embedding](http://scikit-learn.org/stable/modules/manifold.html#locally-linear-embedding) - - [Spectral Embedding](http://scikit-learn.org/stable/modules/manifold.html#spectral-embedding) -2. Set parameters for the method: - - t-SNE (distance measures): - - *Euclidean* distance - - *Manhattan* - - *Chebyshev* - - *Jaccard* - - *Mahalanobis* - - *Cosine* - - MDS (iterations and initialization): - - *max iterations*: maximum number of optimization interactions - - *initialization*: method for initialization of the algorithm (PCA or random) - - Isomap: - - number of *neighbors* - - Locally Linear Embedding: - - *method*: - - standard - - modified - - [hessian eigenmap](http://scikit-learn.org/stable/modules/manifold.html#hessian-eigenmapping) - - local - - number of *neighbors* - - *max iterations* - - Spectral Embedding: - - *affinity*: - - nearest neighbors - - RFB kernel -3. Output: the number of reduced features (components). -4. If *Apply automatically* is ticked, changes will be propagated automatically. Alternatively, click *Apply*. -5. Produce a report. - -**Manifold Learning** widget produces different embeddings for high-dimensional data. - -![](images/collage-manifold.png) - -From left to right, top to bottom: t-SNE, MDS, Isomap, Locally Linear Embedding and Spectral Embedding. - -Example -------- - -*Manifold Learning* widget transforms high-dimensional data into a lower dimensional approximation. This makes it great for visualizing datasets with many features. We used *voting.tab* to map 16-dimensional data onto a 2D graph. Then we used [Scatter Plot](../visualize/scatterplot.md) to plot the embeddings. - -![](images/manifold-learning-example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/mds.md b/doc/visual-programming/source/widgets/unsupervised/mds.md deleted file mode 100644 index 9069ac801c2..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/mds.md +++ /dev/null @@ -1,75 +0,0 @@ -MDS -=== - -Multidimensional scaling (MDS) projects items onto a plane fitted to given distances between points. - -**Inputs** - -- Data: input dataset -- Distances: distance matrix -- Data Subset: subset of instances - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: dataset with MDS coordinates - -[Multidimensional scaling](https://en.wikipedia.org/wiki/Multidimensional_scaling) is a technique which finds a low-dimensional (in our case a two-dimensional) projection of points, where it tries to fit distances between points as well as possible. The perfect fit is typically impossible to obtain since the data is high-dimensional or the distances are not [Euclidean](https://en.wikipedia.org/wiki/Euclidean_distance). - -In the input, the widget needs either a dataset or a matrix of distances. When visualizing distances between rows, you can also adjust the color of the points, change their shape, mark them, and output them upon selection. - -The algorithm iteratively moves the points around in a kind of a simulation of a physical model: if two points are too close to each other (or too far away), there is a force pushing them apart (or together). The change of the point’s position at each time interval corresponds to the sum of forces acting on it. - -![](images/MDS-zoo-stamped.png) - -1. The widget redraws the projection during optimization. Optimization is run automatically in the beginning and later by pushing *Start*. - - **Max iterations**: The optimization stops either when the projection changes only minimally at the last iteration or when a maximum number of iterations has been reached. - - **Initialization**: PCA (Torgerson) positions the initial points along principal coordinate axes. *Random* sets the initial points to a random position and then readjusts them. - - **Refresh**: Set how often you want to refresh the visualization. It can be at *Every iteration*, *Every 5/10/25/50 steps* or never (*None*). Setting a lower refresh interval makes the animation more visually appealing, but can be slow if the number of points is high. -2. Defines how the points are visualized. These options are available only when visualizing distances between rows (selected in the [Distances](../unsupervised/distances.md) widget). - - **Color**: Color of points by attribute (gray for continuous, colored for discrete). - - **Shape**: Shape of points by attribute (only for discrete). - - **Size**: Set the size of points (*Same size* or select an attribute) or let the size depend on the value of the continuous attribute the point represents (Stress). - - **Label**: Discrete attributes can serve as a label. - - **Symbol size**: Adjust the size of the dots. - - **Symbol opacity**: Adjust the transparency level of the dots. - - **Show similar pairs**: Adjust the strength of network lines. - - **Jitter**: Set [jittering](https://en.wikipedia.org/wiki/Jitter) to prevent the dots from overlapping. -3. Adjust the graph with *Zoom/Select*. The arrow enables you to select - data instances. The magnifying glass enables zooming, which can be - also done by scrolling in and out. The hand allows you to move the - graph around. The rectangle readjusts the graph proportionally. -4. Select the desired output: - - **Original features only** (input dataset) - - **Coordinates only** (MDS coordinates) - - **Coordinates as features** (input dataset + MDS coordinates as - regular attributes) - - **Coordinates as meta attributes** (input dataset + MDS - coordinates as meta attributes) -5. Sending the instances can be automatic if *Send selected - automatically* is ticked. Alternatively, click *Send selected*. -6. **Save Image** allows you to save the created image either as .svg - or .png file to your device. -7. Produce a report. - -The MDS graph performs many of the functions of the Visualizations -widget. It is in many respects similar to the `Scatter Plot -<../visualize/scatterplot>` widget, so we recommend reading that -widget's description as well. - -# Example - -The above graphs were drawn using the following simple schema. We used -the *iris.tab* dataset. Using the `Distances -<../unsupervised/distances>` widget we input the distance matrix into -the **MDS** widget, where we see the *Iris* data displayed in a -2-dimensional plane. We can see the appended coordinates in the `Data -Table <../data/datatable>` widget. - -![](images/MDS-Example.png) - -# References - -Wickelmaier, F. (2003). An Introduction to MDS. Sound Quality Research -Unit, Aalborg University. Available -[here](https://homepages.uni-tuebingen.de/florian.wickelmaier/pubs/Wickelmaier2003SQRU.pdf). diff --git a/doc/visual-programming/source/widgets/unsupervised/savedistancematrix.md b/doc/visual-programming/source/widgets/unsupervised/savedistancematrix.md deleted file mode 100644 index 0b7fe4aa254..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/savedistancematrix.md +++ /dev/null @@ -1,22 +0,0 @@ -Save Distance Matrix -==================== - -Saves a distance matrix. - -If the file is saved to the same directory as the workflow or in the subtree of that directory, the widget remembers the relative path. Otherwise it will store an absolute path, but disable auto save for security reasons. - -**Inputs** - -- Distances: distance matrix - -![](images/SaveDistanceMatrix-stamped.png) - -1. By clicking *Save*, you choose from previously saved distance matrices. Alternatively, tick the box on the left side of the *Save* button and changes will be communicated automatically. -2. By clicking *Save as*, you save the distance matrix to your computer, you only need to enter the name of the file and click *Save*. The distance matrix will be saved as type *.dst*. - -Example -------- - -In the snapshot below, we used the [Distance Transformation](../unsupervised/distancetransformation.md) widget to transform the distances in the *Iris* dataset. We then chose to save the transformed version to our computer, so we could use it later on. We decided to output all data instances. You can choose to output just a minor subset of the data matrix. Pairs are marked automatically. If you wish to know what happened to our changed file, see [Distance File](../unsupervised/distancefile.md). - -![](images/SaveDistanceMatrix-Example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/selforganizingmap.md b/doc/visual-programming/source/widgets/unsupervised/selforganizingmap.md deleted file mode 100644 index b5944e2e6c5..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/selforganizingmap.md +++ /dev/null @@ -1,37 +0,0 @@ -Self-Organizing Map -=================== - -Computation of a self-organizing map. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected - -A [self-organizing map (SOM)](https://en.wikipedia.org/wiki/Self-organizing_map) is a type of artificial neural network (ANN) that is trained using unsupervised learning to produce a two-dimensional, discretized representation of the data. It is a method to do dimensionality reduction. Self-organizing maps use a neighborhood function to preserve the topological properties of the input space. - -The points in the grid represent data instances. By default, the size of the point corresponds to the number of instances represented by the point. The points are colored by majority class (if available), while the intensity of interior color shows the proportion of majority class. To see the class distribution, select *Show pie charts* option. - -Just like other visualization widgets, **Self-Organizing Maps** also supports interactive selection of groups. Use Shift key to select a new group and Ctr+Shift to add to the existing group. - -![](images/Self-Organizing_Map-stamped.png) - -1. SOM properties: - - Set the grid type. Options are hexagonal or square grid. - - If *Set dimensions automatically* is checked, the size of the plot will be set automatically. Alternatively, set the size manually. - - Set the initialization type for the SOM projection. Options are PCA initialization, random initialization and replicable random (random_seed = 0). - - Once the parameters are set, press *Start* to re-run the optimization. -2. Set the color of the instances in the plot. The widget colors by class by default (if available). - - *Show pie charts* turns points into pie-charts that show the distributions of the values used for coloring. - - *Size by number of instances* scales the points according to the number of instances represented by the point. - -Example -------- - -Self-organizing maps are low-dimensional projections of the input data. We will use the *brown-selected* data and display the data instance in a 2-D projection. Seems like the three gene types are well-separated. We can select a subset from the grid and display it in a Data Table. - -![](images/Self-Organizing_Map_Example.png) diff --git a/doc/visual-programming/source/widgets/unsupervised/tsne.md b/doc/visual-programming/source/widgets/unsupervised/tsne.md deleted file mode 100644 index f95947f0655..00000000000 --- a/doc/visual-programming/source/widgets/unsupervised/tsne.md +++ /dev/null @@ -1,46 +0,0 @@ -t-SNE -===== - -Two-dimensional data projection with t-SNE. - -**Inputs** - -- Data: input dataset -- Data Subset: subset of instances - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected - -The **t-SNE** widget plots the data with a t-distributed stochastic neighbor embedding method. [t-SNE](https://en.wikipedia.org/wiki/T-distributed_stochastic_neighbor_embedding) is a dimensionality reduction technique, similar to MDS, where points are mapped to 2-D space by their probability distribution. - -![](images/tSNE-stamped.png) - -1. [Parameters](https://opentsne.readthedocs.io/en/latest/parameters.html) for plot optimization: - - measure of [perplexity](http://scikit-learn.org/stable/modules/generated/sklearn.manifold.TSNE.html). Roughly speaking, it can be interpreted as the number of nearest neighbors to distances will be preserved from each point. Using smaller values can reveal small, local clusters, while using large values tends to reveal the broader, global relationships between data points. - - *Preserve global structure*: this option will combine two different perplexity values (50 and 500) to try preserve both the local and global structure. - - *Exaggeration*: this parameter increases the attractive forces between points, and can directly be used to control the compactness of clusters. Increasing exaggeration may also better highlight the global structure of the data. t-SNE with exaggeration set to 4 is roughly equal to UMAP. - - *PCA components*: in Orange, we always run t-SNE on the principal components of the input data. This parameter controls the number of principal components to use when calculating distances between data points. - - *Normalize data*: We can apply standardization before running PCA. Standardization normalizes each column by subtracting the column mean and dividing by the standard deviation. - - Press Start to (re-)run the optimization. -2. Set the color of the displayed points. Set shape, size and label to differentiate between points. If *Label only selection and subset* is ticked, only selected and/or highlighted points will be labelled. -3. Set symbol size and opacity for all data points. Set jittering to randomly disperse data points. -4. *Show color regions* colors the graph by class, while *Show legend* displays a legend on the right. Click and drag the legend to move it. -5. *Select, zoom, pan and zoom to fit* are the options for exploring the graph. The manual selection of data instances works as an angular/square selection tool. Double click to move the projection. Scroll in or out for zoom. -6. If *Send selected automatically* is ticked, changes are communicated automatically. Alternatively, press *Send Selected*. - -Examples --------- - -The first example is a simple t-SNE plot of *brown-selected* data set. Load *brown-selected* with the [File](../data/file.md) widget. Then connect **t-SNE** to it. The widget will show a 2D map of yeast samples, where samples with similar gene expression profiles will be close together. Select the region, where the gene function is mixed and inspect it in a [Data Table](../data/datatable.md). - -![](images/tSNE-Example1.png) - -For the second example, use [Single Cell Datasets](https://orangedatamining.com/widget-catalog/single-cell/single_cell_datasets/) widget from the Single Cell add-on to load *Bone marrow mononuclear cells with AML (sample)* data. Then pass it through **k-Means** and select 2 clusters from Silhouette Scores. Ok, it looks like there might be two distinct clusters here. - -But can we find subpopulations in these cells? Select a few marker genes with the [Marker Genes](https://orangedatamining.com/widget-catalog/bioinformatics/marker_genes/) widget, for example natural killer cells (NK cells). Pass the marker genes and k-Means results to [Score Cells](https://orangedatamining.com/widget-catalog/single-cell/score_cells/) widget. Finally, add **t-SNE** to visualize the results. - -In **t-SNE**, use *Cluster* attribute to color the points and *Score* attribute to set their size. We see that killer cells are nicely clustered together and that t-SNE indeed found subpopulations. - -![](images/tSNE-Example2.png) diff --git a/doc/visual-programming/source/widgets/visualize/barplot.md b/doc/visual-programming/source/widgets/visualize/barplot.md deleted file mode 100644 index a57f847781b..00000000000 --- a/doc/visual-programming/source/widgets/visualize/barplot.md +++ /dev/null @@ -1,34 +0,0 @@ -Bar Plot -======== - -Visualizes comparisons among discrete categories. - -**Inputs** - -- Data: input dataset -- Data Subset: subset of instances - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected - -The **Bar Plot** widget visualizes numeric variables and compares them by a categorical variable. The widget is useful for observing outliers, distributions within groups, and comparing categories. - -![](images/Bar-Plot-stamped.png) - -1. Parameters of the plot. Values are the numeric variable to plot. Group by is the variable for grouping the data. Annotations are categorical labels below the plot. Color is the categorical variable whose values are used for coloring the bars. -2. *Select, zoom, pan and zoom to fit* are the options for exploring the graph. The manual selection of data instances works as an angular/square selection tool. Double click to move the projection. Scroll in or out for zoom. -3. If *Send automatically* is ticked, changes are communicated automatically. Alternatively, press *Send*. -4. Access help, save image, produce a report, or adjust visual settings. On the right, the information on input and output are shown. - -Example -------- - -The **Bar Plot** widget is most commonly used immediately after the [File](../data/file.md) widget to compare categorical values. In this example, we have used *heart-disease* data to inspect our variables. - -![](images/Bar-Plot-Example.png) - -First, we have observed cholesterol values of patient from our data set. We grouped them by diameter narrowing, which defines patients with a heart disease (1) and those without (0). We use the same variable for coloring the bars. - -Then, we selected patients over 60 years of age with [Select Rows](../data/selectrows.md). We sent the subset to **Bar Plot** to highlight these patients in the widget. The big outlier with a high cholesterol level is apparently over 60 years old. diff --git a/doc/visual-programming/source/widgets/visualize/boxplot.md b/doc/visual-programming/source/widgets/visualize/boxplot.md deleted file mode 100644 index 4f4ff203a4f..00000000000 --- a/doc/visual-programming/source/widgets/visualize/boxplot.md +++ /dev/null @@ -1,47 +0,0 @@ -Box Plot -======== - -Shows distribution of attribute values. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected - -The **Box Plot** widget shows the distributions of attribute values. It is a good practice to check any new data with this widget to quickly discover any anomalies, such as duplicated values (e.g., gray and grey), outliers, and alike. Bars can be selected - for example, values for categorical data or the quantile range for numeric data. - -![](images/BoxPlot-Continuous.png) - -1. Select the variable you want to plot. Tick *Order by relevance to subgroups* to order variables by Chi2 or ANOVA over the selected subgroup. -2. Choose *Subgroups* to see [box plots](https://en.wikipedia.org/wiki/Box_plot) displayed by a discrete subgroup. Tick *Order by relevance to variable* to order subgroups by Chi2 or ANOVA over the selected variable. -3. When instances are grouped by a subgroup, you can change the display mode. Annotated boxes will display the end values, the mean and the median, while comparing medians and compare means will, naturally, compare the selected value between subgroups. -![continuous](images/BoxPlot-Continuous-small.png) -4. The mean (the dark blue vertical line). The thin blue line represents the [standard deviation](http://mathworld.wolfram.com/StandardDeviation.html). -5. Values of the first (25%) and the third (75%) quantile. The blue highlighted area represents the values between the first and the third quartile. -6. The median (yellow vertical line). - -For discrete attributes, the bars represent the number of instances with each particular attribute value. The plot shows the number of different animal types in the *Zoo* dataset: there are 41 mammals, 13 fish, 20 birds, and so on. - -Display shows: -- *Stretch bars*: Shows relative values (proportions) of data instances. The unticked box shows absolute values. -- *Show box labels*: Display discrete values above each bar. -- *Sort by subgroup frequencies*: Sort subgroups by their descending frequency. - -![](images/BoxPlot-Discrete.png) - -Examples --------- - -The **Box Plot** widget is most commonly used immediately after the [File](../data/file.md) widget to observe the statistical properties of a dataset. In the first example, we have used *heart-disease* data to inspect our variables. - -![](images/BoxPlot-Example1.png) - -**Box Plot** is also useful for finding the properties of a specific dataset, for instance, a set of instances manually defined in another widget (e.g. [Scatter Plot](../visualize/scatterplot.md) or instances belonging to some cluster or a classification tree node. Let us now use *zoo* data and create a typical clustering workflow with [Distances](../unsupervised/distances.md) and [Hierarchical Clustering](../unsupervised/hierarchicalclustering.md). - -Now define the threshold for cluster selection (click on the ruler at the top). Connect **Box Plot** to **Hierarchical Clustering**, tick *Order by relevance*, and select *Cluster* as a subgroup. This will order attributes by how well they define the selected subgroup, in our case, a cluster. It seems like our clusters indeed correspond very well with the animal type! - -![](images/BoxPlot-Example2.png) diff --git a/doc/visual-programming/source/widgets/visualize/cn2ruleviewer.md b/doc/visual-programming/source/widgets/visualize/cn2ruleviewer.md deleted file mode 100644 index 089f653fa2e..00000000000 --- a/doc/visual-programming/source/widgets/visualize/cn2ruleviewer.md +++ /dev/null @@ -1,32 +0,0 @@ -CN2 Rule Viewer -=============== - -CN2 Rule Viewer - -**Inputs** - -- Data: dataset to filter -- CN2 Rule Classifier: CN2 Rule Classifier, including a list of induced rules - -**Outputs** - -- Filtered Data: data instances covered by all selected rules - -A widget that displays [CN2 classification](https://en.wikipedia.org/wiki/CN2_algorithm) rules. If data is also connected, upon rule selection, one can analyze which instances abide to the conditions. - -![](images/CN2RuleViewer-stamped.png) - -1. Original order of induced rules can be restored. -2. When rules are many and complex, the view can appear packed. For this reason, *compact view* was implemented, which allows a flat presentation and a cleaner inspection of rules. -3. Click *Report* to bring up a detailed description of the rule induction algorithm and its parameters, the data domain, and induced rules. - -Additionally, upon selection, rules can be copied to clipboard by pressing the default system shortcut (ctrl+C, cmd+C). - -Examples --------- - -In the schema below, the most common use of the widget is presented. First, the data is read and a CN2 rule classifier is trained. We are using *titanic* dataset for the rule construction. The rules are then viewed using the [Rule Viewer](../visualize/cn2ruleviewer.md). To explore different CN2 algorithms and understand how adjusting parameters influences the learning process, **Rule Viewer** should be kept open and in sight, while setting the CN2 learning algorithm (the presentation will be updated promptly). - -![](images/CN2-Viewer-Example1.png) - -Selecting a rule outputs filtered data instances. These can be viewed in a [Data Table](../data/datatable.md). diff --git a/doc/visual-programming/source/widgets/visualize/distributions.md b/doc/visual-programming/source/widgets/visualize/distributions.md deleted file mode 100644 index 4778388c485..00000000000 --- a/doc/visual-programming/source/widgets/visualize/distributions.md +++ /dev/null @@ -1,41 +0,0 @@ -Distributions -============= - -Displays value distributions for a single attribute. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether an instance is selected -- Histogram Data: bins and instance counts from the histogram - -The **Distributions** widget displays the [value distribution](https://en.wikipedia.org/wiki/Frequency_distribution) of discrete or continuous attributes. If the data contains a class variable, distributions may be conditioned on the class. - -The graph shows how many times (e.g., in how many instances) each attribute value appears in the data. If the data contains a class variable, class distributions for each of the attribute values will be displayed (like in the snapshot below). To create this graph, we used the *Zoo* dataset. - -![](images/Distributions-Discrete.png) - -1. A list of variables for display. *Sort categories by frequency* orders displayed values by frequency. -2. Set *Bin width* with the slider. Precision scale is set to sensible intervals. *Fitted distribution* fits selected distribution to the plot. Options are [Normal](https://en.wikipedia.org/wiki/Normal_distribution), [Beta](https://en.wikipedia.org/wiki/Beta_distribution), [Gamma](https://en.wikipedia.org/wiki/Gamma_distribution), [Rayleigh](https://en.wikipedia.org/wiki/Rayleigh_distribution), [Pareto](https://en.wikipedia.org/wiki/Pareto_distribution), [Exponential](https://en.wikipedia.org/wiki/Exponential_distribution), [Kernel density](https://en.wikipedia.org/wiki/Kernel_density_estimation). -3. Columns: - -- *Split by* displays value distributions for instances of a certain class. -- *Stack columns* displays one column per bin, colored by proportions of class values. -- *Show probabilities* shows probabilities of class values at selected variable. -- *Show cumulative distribution* cumulatively stacks frequencies. - -4. If *Apply Automatically* is ticked, changes are communicated automatically. Alternatively, click *Apply*. - -For continuous attributes, the attribute values are also displayed as a histogram. It is possible to fit various distributions to the data, for example, a Gaussian kernel density estimation. *Hide bars* hides histogram bars and shows only distribution (old behavior of Distributions). - -For this example, we used the *Iris* dataset. - -![](images/Distributions-Continuous.png) - -In class-less domains, the bars are displayed in blue. We used the *Housing* dataset. - -![](images/Distributions-NoClass.png) \ No newline at end of file diff --git a/doc/visual-programming/source/widgets/visualize/freeviz.md b/doc/visual-programming/source/widgets/visualize/freeviz.md deleted file mode 100644 index b88a19747c5..00000000000 --- a/doc/visual-programming/source/widgets/visualize/freeviz.md +++ /dev/null @@ -1,55 +0,0 @@ -FreeViz -======= - -Displays FreeViz projection. - -**Inputs** - -- Data: input dataset -- Data Subset: subset of instances - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected -- Components: FreeViz vectors - -**FreeViz** uses a paradigm borrowed from particle physics: points in the same class attract each other, those from different class repel each other, and the resulting forces are exerted on the anchors of the attributes, that is, on unit vectors of each of the dimensional axis. The points cannot move (are projected in the projection space), but the attribute anchors can, so the optimization process is a hill-climbing optimization where at the end the anchors are placed such that forces are in equilibrium. The button Optimize is used to invoke the optimization process. The result of the optimization may depend on the initial placement of the anchors, which can be set in a circle, arbitrary or even manually. The later also works at any stage of optimization, and we recommend to play with this option in order to understand how a change of one anchor affects the positions of the data points. In any linear projection, projections of unit vector that are very short compared to the others indicate that their associated attribute is not very informative for particular classification task. Those vectors, that is, their corresponding anchors, may be hidden from the visualization using Radius slider in Show anchors box. - -![](images/freeviz-zoo-stamped.png) - -1. Two initial positions of anchors are possible: random and circular. Optimization moves anchors in an optimal position. -2. Set the color of the displayed points (you will get colors for discrete values and grey-scale points for continuous). Set label, shape and size to differentiate between points. Set symbol size and opacity for all data points. -3. Anchors inside a circle are hidden. Circle radius can be be changed using a slider. -4. Adjust plot properties: - - Set [jittering](https://en.wikipedia.org/wiki/Jitter) to prevent the dots from overlapping (especially for discrete attributes). - - *Show legend* displays a legend on the right. Click and drag the legend to move it. - - *Show class density* colors the graph by class (see the screenshot below). - - *Label only selected points* allows you to select individual data instances and label them. -5. *Select, zoom, pan and zoom to fit* are the options for exploring the graph. The manual selection of data instances works as an angular/square selection tool. Double click to move the projection. Scroll in or out for zoom. -6. If *Send automatically* is ticked, changes are communicated automatically. Alternatively, press *Send*. -7. *Save Image* saves the created image to your computer in a .svg or .png format. -8. Produce a report. - -Manually move anchors ---------------------- - -![](images/freeviz-moveanchor.png) - -One can manually move anchors. Use a mouse pointer and hover above the end of an anchor. Click the left button and then you can move selected anchor where ever you want. - -Selection ---------- - -Selection can be used to manually defined subgroups in the data. Use Shift modifier when selecting data instances to put them into a new group. Shift + Ctrl (or Shift + Cmd on macOs) appends instances to the last group. - -Signal data outputs a data table with an additional column that contains group indices. - -![](images/FreeViz-selection.png) - -Explorative Data Analysis -------------------------- - -The **FreeViz**, as the rest of Orange widgets, supports zooming-in and out of part of the plot and a manual selection of data instances. These functions are available in the lower left corner of the widget. The default tool is *Select*, which selects data instances within the chosen rectangular area. *Pan* enables you to move the plot around the pane. With *Zoom* you can zoom in and out of the pane with a mouse scroll, while *Reset zoom* resets the visualization to its optimal size. An example of a simple schema, where we selected data instances from a rectangular region and sent them to the [Data Table](../data/datatable.md) widget, is shown below. - -![](images/FreeViz-Example-Explorative.png) diff --git a/doc/visual-programming/source/widgets/visualize/heatmap.md b/doc/visual-programming/source/widgets/visualize/heatmap.md deleted file mode 100644 index 539f13520ca..00000000000 --- a/doc/visual-programming/source/widgets/visualize/heatmap.md +++ /dev/null @@ -1,50 +0,0 @@ -Heat Map -======== - -Plots a heat map for a pair of attributes. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Selected Data: instances selected from the plot - -[Heat map](https://en.wikipedia.org/wiki/Heat_map) is a graphical method for visualizing attribute values by class in a two-way matrix. It only works on datasets containing continuous variables. The values are represented by color: the higher a certain value is, the darker the represented color. By combining class and attributes on x and y axes, we see where the attribute values are the strongest and where the weakest, thus enabling us to find typical features (discrete) or value range (continuous) for each class. - -![](images/HeatMap-stamped.png) - -1. The color scheme legend. **Low** and **High** are thresholds for the color palette (low for attributes with low values and high for attributes with high values). Selecting one of diverging palettes, which have two extreme colors and a neutral (black or white) color at the midpoint, enables an option to set a meaningful mid-point value (default is 0). -2. Merge data. -3. Sort columns and rows: - - **No Sorting** (lists attributes as found in the dataset) - - **Clustering** (clusters data by similarity) - - **Clustering with ordered leaves** (maximizes the sum of similarities of adjacent elements) -4. Set what is displayed in the plot in **Annotation & Legend**. - - If *Show legend* is ticked, a color chart will be displayed above the map. - - If *Stripes with averages* is ticked, a new line with attribute averages will be displayed on the left. - - **Row Annotations** adds annotations to each instance on the right. - - **Column Label Positions** places column labels in a selected place (None, Top, Bottom, Top and Bottom). -5. If *Keep aspect ratio* is ticked, each value will be displayed with a square (proportionate to the map). -6. If *Send Automatically* is ticked, changes are communicated automatically. Alternatively, click *Send*. -7. *Save image* saves the image to your computer in a .svg or .png format. -8. Produce a report. - -Example -------- - -The **Heat Map** below displays attribute values for the *Housing* dataset. The aforementioned dataset concerns the housing values in the suburbs of Boston. - -The first thing we see in the map are the 'B' and 'Tax' attributes, which are the only two colored in dark orange. The 'B' attribute provides information on the proportion of blacks by town and the 'Tax' attribute informs us about the full-value property-tax rate per $10,000. In order to get a clearer heat map, we then use the [Select Columns](../data/selectcolumns.md) widget and remove the two attributes from the dataset. Then we again feed the data to the **Heat map**. The new projection offers additional information. - -By removing 'B' and 'Tax', we can see other deciding factors, namely 'Age' and 'ZN'. The ‘Age’ attribute provides information on the proportion of owner-occupied units built prior to 1940 and the 'ZN' attribute informs us about the proportion of non-retail business acres per town. - -![](images/HeatMap-Example1.png) - -The **Heat Map** widget is a nice tool for discovering relevant features in the data. By removing some of the more pronounced features, we came across new information, which was hiding in the background. - -References ----------- - -[Housing Dataset](https://archive.ics.uci.edu/ml/datasets/Housing) diff --git a/doc/visual-programming/source/widgets/visualize/icons/box-plot.png b/doc/visual-programming/source/widgets/visualize/icons/box-plot.png deleted file mode 100644 index 23699ab6cfe..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/box-plot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/cn2ruleviewer.png b/doc/visual-programming/source/widgets/visualize/icons/cn2ruleviewer.png deleted file mode 100644 index 902f3ff30eb..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/cn2ruleviewer.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/distributions.png b/doc/visual-programming/source/widgets/visualize/icons/distributions.png deleted file mode 100644 index 936afcf6105..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/distributions.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/freeviz.png b/doc/visual-programming/source/widgets/visualize/icons/freeviz.png deleted file mode 100644 index aa240648319..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/freeviz.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/heat-map.png b/doc/visual-programming/source/widgets/visualize/icons/heat-map.png deleted file mode 100644 index 77e88e759f4..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/heat-map.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/line-plot.png b/doc/visual-programming/source/widgets/visualize/icons/line-plot.png deleted file mode 100644 index 0e88721944e..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/line-plot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/linear-projection.png b/doc/visual-programming/source/widgets/visualize/icons/linear-projection.png deleted file mode 100644 index e6a6db9ad9b..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/linear-projection.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/mosaic-display.png b/doc/visual-programming/source/widgets/visualize/icons/mosaic-display.png deleted file mode 100644 index ae830c13a60..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/mosaic-display.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/nomogram.png b/doc/visual-programming/source/widgets/visualize/icons/nomogram.png deleted file mode 100644 index 4025425b733..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/nomogram.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/pythagorean-forest.png b/doc/visual-programming/source/widgets/visualize/icons/pythagorean-forest.png deleted file mode 100644 index cd4c275f5d1..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/pythagorean-forest.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/pythagorean-tree.png b/doc/visual-programming/source/widgets/visualize/icons/pythagorean-tree.png deleted file mode 100644 index 899933f0333..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/pythagorean-tree.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/radviz.png b/doc/visual-programming/source/widgets/visualize/icons/radviz.png deleted file mode 100644 index f517871755c..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/radviz.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/scatter-map.png b/doc/visual-programming/source/widgets/visualize/icons/scatter-map.png deleted file mode 100644 index f8a39a9260d..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/scatter-map.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/scatter-plot.png b/doc/visual-programming/source/widgets/visualize/icons/scatter-plot.png deleted file mode 100644 index 1f7b859f595..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/scatter-plot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/sieve-diagram.png b/doc/visual-programming/source/widgets/visualize/icons/sieve-diagram.png deleted file mode 100644 index 7029ce19e3b..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/sieve-diagram.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/silhouette-plot.png b/doc/visual-programming/source/widgets/visualize/icons/silhouette-plot.png deleted file mode 100644 index 7604d9b357e..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/silhouette-plot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/tree-viewer.png b/doc/visual-programming/source/widgets/visualize/icons/tree-viewer.png deleted file mode 100644 index a3ef4294ccd..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/tree-viewer.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/icons/venn-diagram.png b/doc/visual-programming/source/widgets/visualize/icons/venn-diagram.png deleted file mode 100644 index 2110f002909..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/icons/venn-diagram.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Bar-Plot-Example.png b/doc/visual-programming/source/widgets/visualize/images/Bar-Plot-Example.png deleted file mode 100644 index d0a1a50c11b..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Bar-Plot-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Bar-Plot-stamped.png b/doc/visual-programming/source/widgets/visualize/images/Bar-Plot-stamped.png deleted file mode 100644 index 4599ad116b4..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Bar-Plot-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Continuous-small.png b/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Continuous-small.png deleted file mode 100644 index bbfc65f486f..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Continuous-small.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Continuous.png b/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Continuous.png deleted file mode 100644 index c0eda1e758e..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Continuous.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Discrete.png b/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Discrete.png deleted file mode 100644 index 9b0c70dc3b0..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Discrete.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Example1.png b/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Example1.png deleted file mode 100644 index 12a869cf2b2..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Example2.png b/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Example2.png deleted file mode 100644 index 3cbc48ed306..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/BoxPlot-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/CN2-Viewer-Example1.png b/doc/visual-programming/source/widgets/visualize/images/CN2-Viewer-Example1.png deleted file mode 100644 index 301a6296ddb..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/CN2-Viewer-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer-stamped.png b/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer-stamped.png deleted file mode 100644 index f2519790b38..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer-tags.txt b/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer-tags.txt deleted file mode 100644 index 51a48af0db1..00000000000 --- a/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer-tags.txt +++ /dev/null @@ -1,3 +0,0 @@ -0 171 400 -1 284 400 -2 742 400 diff --git a/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer.png b/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer.png deleted file mode 100644 index 9090c074fa5..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/CN2RuleViewer.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Distributions-Continuous.png b/doc/visual-programming/source/widgets/visualize/images/Distributions-Continuous.png deleted file mode 100644 index b9f92df585e..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Distributions-Continuous.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Distributions-Discrete.png b/doc/visual-programming/source/widgets/visualize/images/Distributions-Discrete.png deleted file mode 100644 index eb87c1baac4..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Distributions-Discrete.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Distributions-NoClass.png b/doc/visual-programming/source/widgets/visualize/images/Distributions-NoClass.png deleted file mode 100644 index cb6a569c9f3..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Distributions-NoClass.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/FreeViz-Example-Explorative.png b/doc/visual-programming/source/widgets/visualize/images/FreeViz-Example-Explorative.png deleted file mode 100644 index bea4bf56d61..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/FreeViz-Example-Explorative.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/FreeViz-selection.png b/doc/visual-programming/source/widgets/visualize/images/FreeViz-selection.png deleted file mode 100644 index 62adc04c854..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/FreeViz-selection.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/HeatMap-Example1.png b/doc/visual-programming/source/widgets/visualize/images/HeatMap-Example1.png deleted file mode 100644 index 116eb46ba54..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/HeatMap-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/HeatMap-Example2.png b/doc/visual-programming/source/widgets/visualize/images/HeatMap-Example2.png deleted file mode 100644 index 9c2f855a6b8..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/HeatMap-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/HeatMap-stamped.png b/doc/visual-programming/source/widgets/visualize/images/HeatMap-stamped.png deleted file mode 100644 index 96dbc045868..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/HeatMap-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/HeatMap.png b/doc/visual-programming/source/widgets/visualize/images/HeatMap.png deleted file mode 100644 index f877bb05416..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/HeatMap.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/LinePlot-Example.png b/doc/visual-programming/source/widgets/visualize/images/LinePlot-Example.png deleted file mode 100644 index d0a2f0e9f71..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/LinePlot-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/LinePlot-stamped.png b/doc/visual-programming/source/widgets/visualize/images/LinePlot-stamped.png deleted file mode 100644 index d0f99d20646..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/LinePlot-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/LinearProjection-example.png b/doc/visual-programming/source/widgets/visualize/images/LinearProjection-example.png deleted file mode 100644 index de43ff95b00..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/LinearProjection-example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/LinearProjection-stamped.png b/doc/visual-programming/source/widgets/visualize/images/LinearProjection-stamped.png deleted file mode 100644 index eccaa5fc5e5..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/LinearProjection-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display-Example.png b/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display-Example.png deleted file mode 100644 index 5933f0be9a6..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display-stamped.png b/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display-stamped.png deleted file mode 100644 index f54f61346fe..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display.png b/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display.png deleted file mode 100644 index 81ee93cbe98..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Mosaic-Display.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Nomogram-Example.png b/doc/visual-programming/source/widgets/visualize/images/Nomogram-Example.png deleted file mode 100644 index 2eec917d537..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Nomogram-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Nomogram-Features.png b/doc/visual-programming/source/widgets/visualize/images/Nomogram-Features.png deleted file mode 100644 index c53699b8833..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Nomogram-Features.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Nomogram-LogisticRegression.png b/doc/visual-programming/source/widgets/visualize/images/Nomogram-LogisticRegression.png deleted file mode 100644 index f33481d17fe..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Nomogram-LogisticRegression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Nomogram-NaiveBayes.png b/doc/visual-programming/source/widgets/visualize/images/Nomogram-NaiveBayes.png deleted file mode 100644 index ea9788b81ea..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Nomogram-NaiveBayes.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-Example.png b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-Example.png deleted file mode 100644 index afec87bcec9..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-stamped.png b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-stamped.png deleted file mode 100644 index 6f0658fa4bf..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-tags.txt b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-tags.txt deleted file mode 100644 index 1ebb2bf4cc2..00000000000 --- a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Forest-tags.txt +++ /dev/null @@ -1,3 +0,0 @@ -0 171 35 -1 171 89 -2 171 551 diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-comparison.png b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-comparison.png deleted file mode 100644 index 5618fb15b85..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-comparison.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-scatterplot-workflow.png b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-scatterplot-workflow.png deleted file mode 100644 index fcf7c66bb06..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-scatterplot-workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-scatterplot.png b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-scatterplot.png deleted file mode 100644 index 12e9fbe34bc..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree-scatterplot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-continuous.png b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-continuous.png deleted file mode 100644 index af5722f73a4..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-continuous.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-stamped.png b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-stamped.png deleted file mode 100644 index 94729b7979e..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-tags.txt b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-tags.txt deleted file mode 100644 index 979b78edc7b..00000000000 --- a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1-tags.txt +++ /dev/null @@ -1,4 +0,0 @@ -0 171 35 -1 171 100 -2 171 241 -3 171 511 diff --git a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1.png b/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1.png deleted file mode 100644 index c969c83fb21..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Pythagorean-Tree1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Radviz-Brown-2.png b/doc/visual-programming/source/widgets/visualize/images/Radviz-Brown-2.png deleted file mode 100644 index 80f937b6f10..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Radviz-Brown-2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Radviz-Brown.png b/doc/visual-programming/source/widgets/visualize/images/Radviz-Brown.png deleted file mode 100644 index 35e6021940a..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Radviz-Brown.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ScatterMap-Example.png b/doc/visual-programming/source/widgets/visualize/images/ScatterMap-Example.png deleted file mode 100644 index 008b12324a5..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ScatterMap-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ScatterMap2-stamped.png b/doc/visual-programming/source/widgets/visualize/images/ScatterMap2-stamped.png deleted file mode 100644 index 5a0d6816658..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ScatterMap2-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ScatterMap2.png b/doc/visual-programming/source/widgets/visualize/images/ScatterMap2.png deleted file mode 100644 index 60a3d2eeb4b..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ScatterMap2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ScatterPlot-selection.png b/doc/visual-programming/source/widgets/visualize/images/ScatterPlot-selection.png deleted file mode 100644 index a425b49d1b8..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ScatterPlot-selection.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Classification.png b/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Classification.png deleted file mode 100644 index df25aae4200..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Explorative.png b/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Explorative.png deleted file mode 100644 index cd0bfa4c104..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Explorative.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Ranking.png b/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Ranking.png deleted file mode 100644 index 71abf2be38d..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ScatterPlotExample-Ranking.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Scatterplot-ClassDensity.png b/doc/visual-programming/source/widgets/visualize/images/Scatterplot-ClassDensity.png deleted file mode 100644 index 618fc41269d..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Scatterplot-ClassDensity.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Scatterplot-Iris-stamped.png b/doc/visual-programming/source/widgets/visualize/images/Scatterplot-Iris-stamped.png deleted file mode 100644 index 5b4eb2a2fb8..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Scatterplot-Iris-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/Scatterplot-Iris.png b/doc/visual-programming/source/widgets/visualize/images/Scatterplot-Iris.png deleted file mode 100644 index 54381ba092c..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/Scatterplot-Iris.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example.png b/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example.png deleted file mode 100644 index 75e3b4ab549..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example1.PNG b/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example1.PNG deleted file mode 100644 index a3367df3cb0..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example1.PNG and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example2.PNG b/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example2.PNG deleted file mode 100644 index c29596ea17a..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Example2.PNG and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Titanic-age-survived.png b/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Titanic-age-survived.png deleted file mode 100644 index 7c0f459fb61..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Titanic-age-survived.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Titanic.png b/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Titanic.png deleted file mode 100644 index 842b68d93b3..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-Titanic.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-stamped.png b/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-stamped.png deleted file mode 100644 index df4fa036879..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram.png b/doc/visual-programming/source/widgets/visualize/images/SieveDiagram.png deleted file mode 100644 index 5877a05b4a6..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SieveDiagram.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot-Example.png b/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot-Example.png deleted file mode 100644 index dade8110ac3..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot-Example.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot-stamped.png b/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot-stamped.png deleted file mode 100644 index 4b0c560f822..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot.png b/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot.png deleted file mode 100644 index a4597123ef9..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/SilhouettePlot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/TreeViewer-classification.png b/doc/visual-programming/source/widgets/visualize/images/TreeViewer-classification.png deleted file mode 100644 index 49b4a32cf6a..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/TreeViewer-classification.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/TreeViewer-regression.png b/doc/visual-programming/source/widgets/visualize/images/TreeViewer-regression.png deleted file mode 100644 index 7af73f4bb9a..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/TreeViewer-regression.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/TreeViewer-selection.png b/doc/visual-programming/source/widgets/visualize/images/TreeViewer-selection.png deleted file mode 100644 index 4f66ccc9e9a..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/TreeViewer-selection.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/TreeViewer-stamped.png b/doc/visual-programming/source/widgets/visualize/images/TreeViewer-stamped.png deleted file mode 100644 index a35eecdcffb..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/TreeViewer-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/VennDiagram-Example1.png b/doc/visual-programming/source/widgets/visualize/images/VennDiagram-Example1.png deleted file mode 100644 index 5e5e6cfd9d5..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/VennDiagram-Example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/VennDiagram-Example2.png b/doc/visual-programming/source/widgets/visualize/images/VennDiagram-Example2.png deleted file mode 100644 index c2629cd2316..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/VennDiagram-Example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/VennDiagram-stamped.png b/doc/visual-programming/source/widgets/visualize/images/VennDiagram-stamped.png deleted file mode 100644 index 65b1d55a052..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/VennDiagram-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-boxplot.png b/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-boxplot.png deleted file mode 100644 index 0f2dbb53adb..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-boxplot.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-example1.png b/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-example1.png deleted file mode 100644 index 4bd2e612d27..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-example1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-example2.png b/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-example2.png deleted file mode 100644 index 91319de2cb3..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-example2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-stamped.png b/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-stamped.png deleted file mode 100644 index 7c0c7e119c2..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/ViolinPlot-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/freeviz-moveanchor.png b/doc/visual-programming/source/widgets/visualize/images/freeviz-moveanchor.png deleted file mode 100644 index 8d9f3a95644..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/freeviz-moveanchor.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/freeviz-zoo-stamped.png b/doc/visual-programming/source/widgets/visualize/images/freeviz-zoo-stamped.png deleted file mode 100644 index 6e30e022833..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/freeviz-zoo-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/heat-map-workflow.png b/doc/visual-programming/source/widgets/visualize/images/heat-map-workflow.png deleted file mode 100644 index fbb96235180..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/heat-map-workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/images/venn-workflow.png b/doc/visual-programming/source/widgets/visualize/images/venn-workflow.png deleted file mode 100644 index e407d7b14b6..00000000000 Binary files a/doc/visual-programming/source/widgets/visualize/images/venn-workflow.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/visualize/linearprojection.md b/doc/visual-programming/source/widgets/visualize/linearprojection.md deleted file mode 100644 index 9db33d7da6b..00000000000 --- a/doc/visual-programming/source/widgets/visualize/linearprojection.md +++ /dev/null @@ -1,56 +0,0 @@ -Linear Projection -================= - -A linear projection method with explorative data analysis. - -**Inputs** - -- Data: input dataset -- Data Subset: subset of instances -- Projection: custom projection vectors - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected -- Components: projection vectors - -This widget displays [linear projections](https://en.wikipedia.org/wiki/Projection_(linear_algebra)) of class-labeled data. It supports various types of projections such as circular, [linear discriminant analysis](https://en.wikipedia.org/wiki/Linear_discriminant_analysis), and [principal component analysis](https://en.wikipedia.org/wiki/Principal_component_analysis). - -Consider, for a start, a projection of the *Iris* dataset shown below. Notice that it is the sepal width and sepal length that already separate *Iris setosa* from the other two, while the petal length is the attribute best separating *Iris versicolor* from *Iris virginica*. - -![](images/LinearProjection-stamped.png) - -1. Axes in the projection that are displayed and other available axes. Optimize your projection by using **Suggest Features**. This feature scores attributes and returns the top scoring attributes with a simultaneous visualization update. Feature scoring computes the classification accuracy (for classification) or MSE (regression) of k-nearest neighbors classifier on the projected, two-dimensional data. The score reflects how well the classes in the projection are separated. -2. Choose the type of projection: - - Circular Placement - - [Linear Discriminant Analysis](https://en.wikipedia.org/wiki/Linear_discriminant_analysis) - - [Principal Component Analysis](https://en.wikipedia.org/wiki/Principal_component_analysis) -3. Set the color of the displayed points. Set shape, size, and label to differentiate between points. - *Label only selected points* labels only selected data instances. -4. Adjust plot properties: - - *Symbol size*: set the size of the points. - - *Opacity*: set the transparency of the points. - - *Jittering*: Randomly disperse points with [jittering](https://en.wikipedia.org/wiki/Jitter) to prevent them from overlapping. - - *Hide radius*: Axes inside the radius are hidden. Drag the slider to change the radius. -5. Additional plot properties: - - *Show color regions* colors the graph by class. - - *Show legend* displays a legend on the right. Click and drag the legend to move it. -6. *Select, zoom, pan* and *zoom to fit* are the options for exploring the graph. Manual selection of data instances works as an angular/square selection tool. Double click to move the projection. Scroll in or out for zoom. -7. If *Send automatically* is ticked, changes are communicated automatically. Alternatively, press *Send*. - -Example -------- - -The **Linear Projection** widget works just like other visualization widgets. Below, we connected it to the [File](../data/file.md) widget to see the set projected on a 2-D plane. Then we selected the data for further analysis and connected it to the [Data Table](../data/datatable.md) widget to see the details of the selected subset. - -![](images/LinearProjection-Example.png) - -References ----------- - -Koren Y., Carmel L. (2003). Visualization of labeled data using linear transformations. In Proceedings of IEEE Information Visualization 2003, (InfoVis'03). Available [here](http://citeseerx.ist.psu.edu/viewdoc/download;jsessionid=3DDF0DB68D8AB9949820A19B0344C1F3?doi=10.1.1.13.8657&rep=rep1&type=pdf). - -Boulesteix A.-L., Strimmer K. (2006). Partial least squares: a versatile tool for the analysis of high-dimensional genomic data. Briefings in Bioinformatics, 8(1), 32-44. Abstract [here](http://bib.oxfordjournals.org/content/8/1/32.abstract). - -Leban G., Zupan B., Vidmar G., Bratko I. (2006). VizRank: Data Visualization Guided by Machine Learning. Data Mining and Knowledge Discovery, 13, 119-136. Available [here](http://eprints.fri.uni-lj.si/210/2/1._G._Leban%2C_B._Zupan%2C_G._Vidmar%2C_I._Bratko%2C_Data_Mining_and_Knowledge_Discovery_13%2C_119-36_(2006)..pdf). diff --git a/doc/visual-programming/source/widgets/visualize/lineplot.md b/doc/visual-programming/source/widgets/visualize/lineplot.md deleted file mode 100644 index aa9a83bb720..00000000000 --- a/doc/visual-programming/source/widgets/visualize/lineplot.md +++ /dev/null @@ -1,37 +0,0 @@ -Line Plot -========= - -Visualization of data profiles (e.g., time series). - -**Inputs** - -- Data: input dataset -- Data Subset: subset of instances - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected - -[Line plot](https://en.wikipedia.org/wiki/Line_chart) a type of plot which displays the data as a series of points, connected by straight line segments. It only works for numerical data, while categorical can be used for grouping of the data points. - -![](images/LinePlot-stamped.png) - -1. Information on the input data. -2. Select what you wish to display: - - Lines show individual data instances in a plot. - - Range shows the range of data points between 10th and 90th percentile. - - Mean adds the line for mean value. If group by is selected, means will be displayed per each group value. - - Error bars show the standard deviation of each attribute. -3. Select a categorical attribute to use for grouping of data instances. Use None to show ungrouped data. -4. *Select, zoom, pan and zoom to fit* are the options for exploring the graph. The manual selection of data instances works as a line selection, meaning the data under the selected line plots will be sent on the output. Scroll in or out for zoom. When hovering over an individual axis, scrolling will zoom only by the hovered-on axis (vertical or horizontal zoom). -5. If *Send Automatically* is ticked, changes are communicated automatically. Alternatively, click *Send*. - -Example -------- - -**Line Plot** is a standard visualization widget, which displays data profiles, normally of ordered numerical data. In this simple example, we will display the *iris* data in a line plot, grouped by the iris attribute. The plot shows how petal length nicely separates between class values. - -If we observe this in a [Scatter Plot](../visualize/scatterplot.md), we can confirm this is indeed so. Petal length is an interesting attribute for separation of classes, especially when enhanced with petal width, which is also nicely separated in the line plot. - -![](images/LinePlot-Example.png) diff --git a/doc/visual-programming/source/widgets/visualize/mosaicdisplay.md b/doc/visual-programming/source/widgets/visualize/mosaicdisplay.md deleted file mode 100644 index 6fed3b73d23..00000000000 --- a/doc/visual-programming/source/widgets/visualize/mosaicdisplay.md +++ /dev/null @@ -1,31 +0,0 @@ -Mosaic Display -============== - -Display data in a mosaic plot. - -**Inputs** - -- Data: input dataset -- Data subset: subset of instances - -**Outputs** - -- Selected data: instances selected from the plot - -The **Mosaic plot** is a graphical representation of a two-way frequency table or a contingency table. It is used for visualizing data from two or more qualitative variables and was introduced in 1981 by Hartigan and Kleiner and expanded and refined by Friendly in 1994. It provides the user with the means to more efficiently recognize relationships between different variables. If you wish to read up on the history of Mosaic Display, additional reading is available [here](http://www.datavis.ca/papers/moshist.pdf). - -![](images/Mosaic-Display-stamped.png) - -1. Select the variables you wish to see plotted. -2. Select interior coloring. You can color the interior according to class or you can use the *Pearson residual*, which is the difference between observed and fitted values, divided by an estimate of the standard deviation of the observed value. If *Compare to total* is clicked, a comparison is made to all instances. -3. *Save image* saves the created image to your computer in a .svg or .png format. -4. Produce a report. - -Example -------- - -We loaded the *titanic* dataset and connected it to the **Mosaic Display** widget. We decided to focus on two variables, namely status, sex and survival. We colored the interiors according to Pearson residuals in order to demonstrate the difference between observed and fitted values. - -![](images/Mosaic-Display-Example.png) - -We can see that the survival rates for men and women clearly deviate from the fitted value. diff --git a/doc/visual-programming/source/widgets/visualize/nomogram.md b/doc/visual-programming/source/widgets/visualize/nomogram.md deleted file mode 100644 index 172e7c1653c..00000000000 --- a/doc/visual-programming/source/widgets/visualize/nomogram.md +++ /dev/null @@ -1,48 +0,0 @@ -Nomogram -======== - -Nomograms for visualization of Naive Bayes and Logistic Regression classifiers. - -**Inputs** - -- Classifier: trained classifier -- Data: input dataset - -**Outputs** - -- Features: selected variables, 10 by default - -The **Nomogram** enables some classifier's (more precisely Naive Bayes classifier and Logistic Regression classifier) visual representation. It offers an insight into the structure of the training data and effects of the attributes on the class probabilities. Besides visualization of the classifier, the widget offers interactive support for prediction of class probabilities. A snapshot below shows the nomogram of the Titanic dataset, that models the probability for a passenger not to survive the disaster of the Titanic. - -When there are too many attributes in the plotted dataset, only best ranked ones can be selected for display. It is possible to choose from 'No sorting', 'Name', 'Absolute importance', 'Positive influence' and 'Negative influence' for Naive Bayes representation and from 'No sorting', 'Name' and 'Absolute importance' for Logistic Regression representation. - -The probability for the chosen target class is computed by '1-vs-all' principle, which should be taken in consideration when dealing with multiclass data (alternating probabilities do not sum to 1). To avoid this inconvenience, you can choose to normalize probabilities. - -![](images/Nomogram-NaiveBayes.png) - -1. Select the target class you want to model the probability for. Select, whether you want to normalize the probabilities or not. -2. By default Scale is set to Log odds ration. For easier understanding and interpretation option *Point scale* can be used. The unit is obtained by re-scaling the log odds so that the maximal absolute log odds ratio in the nomogram represents 100 points. -3. Display all attributes or only the best ranked ones. Sort them and set the projection type. - -Continuous attributes can be plotted in 2D (only for Logistic Regression). - -![logreg](images/Nomogram-LogisticRegression.png) - -Examples --------- - -The **Nomogram** widget should be used immediately after trained classifier widget (e.g. [Naive Bayes](../model/naivebayes.md) or [Logistics Regression](../model/logisticregression.md)). It can also be passed a data instance using any widget that enables selection (e.g. [Data Table](../data/datatable.md)) as shown in the workflow below. - -![](images/Nomogram-Example.png) - -Referring to the Titanic dataset once again, 1490 (68%) passengers on Titanic out of 2201 died. To make a prediction, the contribution of each attribute is measured as a point score and the individual point scores are summed to determine the probability. When the value of the attribute is unknown, its contribution is 0 points. Therefore, not knowing anything about the passenger, the total point score is 0 and the corresponding probability equals the unconditional prior. The nomogram in the example shows the case when we know that the passenger is a male adult from the first class. The points sum to -0.36, with a corresponding probability of not surviving of about 53%. - -#### Features output - -The second example shows how to use the Features output. Let us use *heart_disease* data for this exercise and load it in the File widget. Now connect File to [Naive Bayes](../model/naivebayes.md) (or [Logistic Regression](../model/logisticregression.md)) and add Nomogram to Naive Bayes. Finally, connect File to [Select Columns](../data/selectcolumns.md). - -Select Columns selects a subset of variables, while Nomogram shows the top scoring variables for the trained classifier. To filter the data by the variables selected in the Nomogram, connect Nomogram to Select Columns as shown below. Nomogram will pass a list of selected variables to Select Columns, which will retain only the variables from the list. For this to work, you have to press *Use input features* in Select Columns (or tick it to always apply it). - -We have selected the top 5 variables in Nomogram and used Select Columns to retain only those variables. - -![](images/Nomogram-Features.png) diff --git a/doc/visual-programming/source/widgets/visualize/pythagoreanforest.md b/doc/visual-programming/source/widgets/visualize/pythagoreanforest.md deleted file mode 100644 index ce287583625..00000000000 --- a/doc/visual-programming/source/widgets/visualize/pythagoreanforest.md +++ /dev/null @@ -1,40 +0,0 @@ -Pythagorean Forest -================== - -Pythagorean forest for visualizing random forests. - -**Inputs** - -- Random Forest: tree models from random forest - -**Outputs** - -- Tree: selected tree model - -**Pythagorean Forest** shows all learned decision tree models from [Random Forest](../model/randomforest.md) widget. It displays them as Pythagorean trees, each visualization pertaining to one randomly constructed tree. In the visualization, you can select a tree and display it in [Pythagorean Tree](../visualize/pythagoreantree.md) widget. The best tree is the one with the shortest and most strongly colored branches. This means few attributes split the branches well. - -Widget displays both classification and regression results. Classification requires discrete target variable in the dataset, while regression requires a continuous target variable. Still, they both should be fed a [Tree](../model/tree.md) on the input. - -![](images/Pythagorean-Forest-stamped.png) - -1. Information on the input random forest model. -2. Display parameters: - - *Depth*: set the depth to which the trees are grown. - - *Target class*: set the target class for coloring the trees. If *None* is selected, the tree will be white. If the input is a classification tree, you can color the nodes by their respective class. If the input is a regression tree, the options are *Class mean*, which will color tree nodes by the class mean value and *Standard deviation*, which will color them by the standard deviation value of the node. - - *Size*: set the size of the nodes. *Normal* will keep the nodes the size of the subset in the node. *Square root* and *Logarithmic* are the respective transformations of the node size. - - *Zoom*: allows you to see the size of the tree visualizations. -3. *Save Image*: save the visualization to your computer as a *.svg* or *.png* file. *Report*: produce a report. - -Example -------- - -**Pythagorean Forest** is great for visualizing several built trees at once. In the example below, we've used *housing* dataset and plotted all 10 trees we've grown with [Random Forest](../model/randomforest.md). When changing the parameters in Random Forest, visualization in Pythagorean Forest will change as well. - -Then we've selected a tree in the visualization and inspected it further with [Pythagorean Tree](../visualize/pythagoreantree.md) widget. - -![](images/Pythagorean-Forest-Example.png) - -References ----------- - -Beck, F., Burch, M., Munz, T., Di Silvestro, L. and Weiskopf, D. (2014). Generalized Pythagoras Trees for Visualizing Hierarchies. In IVAPP '14 Proceedings of the 5th International Conference on Information Visualization Theory and Applications, 17-28. diff --git a/doc/visual-programming/source/widgets/visualize/pythagoreantree.md b/doc/visual-programming/source/widgets/visualize/pythagoreantree.md deleted file mode 100644 index 7d94336fa8b..00000000000 --- a/doc/visual-programming/source/widgets/visualize/pythagoreantree.md +++ /dev/null @@ -1,51 +0,0 @@ -Pythagorean Tree -================ - -Pythagorean tree visualization for classification or regression trees. - -**Inputs** - -- Tree: tree model -- Selected Data: instances selected from the tree - -**Pythagorean Trees** are plane fractals that can be used to depict general tree hierarchies as presented in an article by [Fabian Beck and co-authors](http://publications.fbeck.com/ivapp14-pythagoras.pdf). In our case, they are used for visualizing and exploring tree models, such as [Tree](../model/tree.md). - -![](images/Pythagorean-Tree1-stamped.png) - -1. Information on the input tree model. -2. Visualization parameters: - - *Depth*: set the depth of displayed trees. - - *Target class* (for classification trees): the intensity of the color for nodes of the tree will correspond to the probability of the target class. If *None* is selected, the color of the node will denote the most probable class. - - *Node color* (for regression trees): node colors can correspond to mean or standard deviation of class value of the training data instances in the node. - - *Size*: define a method to compute the size of the square representing the node. *Normal* will keep node sizes correspond to the size of training data subset in the node. *Square root* and *Logarithmic* are the respective transformations of the node size. - - *Log scale factor* is only enabled when *logarithmic* transformation is selected. You can set the log factor between 1 and 10. -3. Plot properties: - - *Enable tooltips*: display node information upon hovering. - - *Show legend*: shows color legend for the plot. -4. Reporting: - - *Save Image*: save the visualization to a SVG or PNG file. - - *Report*: add visualization to the report. - -Pythagorean Tree can visualize both classification and regression trees. Below is an example for regression tree. The only difference between the two is that regression tree doesn't enable coloring by class, but can color by class mean or standard deviation. - -![](images/Pythagorean-Tree1-continuous.png) - -Example -------- - -The workflow from the screenshot below demonstrates the difference between [Tree Viewer](../visualize/treeviewer.md) and Pythagorean Tree. They can both visualize [Tree](../model/tree.md), but Pythagorean visualization takes less space and is more compact, even for a small [Iris flower](https://en.wikipedia.org/wiki/Iris_flower_data_set) dataset. For both visualization widgets, we have hidden the control area on the left by clicking on the splitter between control and visualization area. - -![](images/Pythagorean-Tree-comparison.png) - -Pythagorean Tree is interactive: click on any of the nodes (squares) to select training data instances that were associated with that node. The following workflow explores these feature. - -![](images/Pythagorean-Tree-scatterplot-workflow.png) - -The selected data instances are shown as a subset in the [Scatter Plot](../visualize/scatterplot.md), sent to the [Data Table](../data/datatable.md) and examined in the [Box Plot](../visualize/boxplot.md). We have used brown-selected dataset in this example. The tree and scatter plot are shown below; the selected node in the tree has a black outline. - -![](images/Pythagorean-Tree-scatterplot.png) - -References ----------- - -Beck, F., Burch, M., Munz, T., Di Silvestro, L. and Weiskopf, D. (2014). [Generalized Pythagoras Trees for Visualizing Hierarchies](http://publications.fbeck.com/ivapp14-pythagoras.pdf). In IVAPP '14 Proceedings of the 5th International Conference on Information Visualization Theory and Applications, 17-28. diff --git a/doc/visual-programming/source/widgets/visualize/radviz.md b/doc/visual-programming/source/widgets/visualize/radviz.md deleted file mode 100644 index c0bc1b536ba..00000000000 --- a/doc/visual-programming/source/widgets/visualize/radviz.md +++ /dev/null @@ -1,38 +0,0 @@ -Radviz -====== - -Radviz vizualization with explorative data analysis and intelligent data -visualization enhancements. - -**Inputs** - -- Data: input dataset -- Data Subset: subset of instances - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected -- Components: Radviz vectors - -Radviz (Hoffman et al. 1997) is a non-linear multi-dimensional visualization technique that can display data defined by three or more variables in a 2-dimensional projection. The visualized variables are presented as anchor points equally spaced around the perimeter of a unit circle. Data instances are shown as points inside the circle, with their positions determined by a metaphor from physics: each point is held in place with springs that are attached at the other end to the variable anchors. The stiffness of each spring is proportional to the value of the corresponding variable and the point ends up at the position where the spring forces are in equilibrium. Prior to visualization, variable values are scaled to lie between 0 and 1. Data instances that are close to a set of variable anchors have higher values for these variables than for the others. - -The snapshot shown below shows a Radviz widget with a visualization of the dataset from functional genomics (Brown et al. 2000). In this particular visualization the data instances are colored according to the corresponding class, and the visualization space is colored according to the computed class probability. Notice that the particular visualization very nicely separates data instances of different class, making the visualization interesting and potentially informative. - -![](images/Radviz-Brown.png) - -Just like all point-based visualizations, this widget includes tools for intelligent data visualization (VizRank, see Leban et al. 2006) and an interface for explorative data analysis - selection of data points in visualization. Just like the [Scatter Plot](../visualize/scatterplot.md) widget, it can be used to find a set of variables that would result in an interesting visualization. The Radviz graph above is according to this definition an example of a very good visualization, while the one below - where we show an VizRank's interface (*Suggest features* button) -with a list of 3-attribute visualizations and their scores - is not. - -![](images/Radviz-Brown-2.png) - -References ----------- - -Hoffman, P. E. et al. (1997) DNA visual and analytic data mining. In the Proceedings of the IEEE Visualization. Phoenix, AZ, pp. 437-441. - -Brown, M. P., W. N. Grundy et al. (2000). "Knowledge-based analysis of microarray gene expression data by using support vector machines." Proc Natl Acad Sci U S A 97(1): 262-7. - -Leban, G., B. Zupan et al. (2006). "VizRank: Data Visualization Guided by Machine Learning." Data Mining and Knowledge Discovery 13(2): 119-136. - -Mramor, M., G. Leban, J. Demsar, and B. Zupan. Visualization-based cancer microarray data classification analysis. Bioinformatics 23(16): 2147-2154, 2007. diff --git a/doc/visual-programming/source/widgets/visualize/scatterplot.md b/doc/visual-programming/source/widgets/visualize/scatterplot.md deleted file mode 100644 index 240a1bd47ee..00000000000 --- a/doc/visual-programming/source/widgets/visualize/scatterplot.md +++ /dev/null @@ -1,82 +0,0 @@ -Scatter Plot -============ - -Scatter plot visualization with explorative analysis and intelligent data visualization enhancements. - -**Inputs** - -- Data: input dataset -- Data Subset: subset of instances -- Features: list of attributes - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected - -The **Scatter Plot** widget provides a 2-dimensional scatter plot visualization for continuous attributes. The data is displayed as a collection of points, each having the value of the x-axis attribute determining the position on the horizontal axis and the value of the y-axis attribute determining the position on the vertical axis. Various properties of the graph, like color, size and shape of the points, axis titles, maximum point size and jittering can be adjusted on the left side of the widget. A snapshot below shows the scatter plot of the *Iris* dataset with the coloring matching of the class attribute. - -![](images/Scatterplot-Iris-stamped.png) - -1. Select the x and y attribute. Optimize your projection by using **Rank Projections**. This feature scores attribute pairs by average classification accuracy and returns the top scoring pair with a simultaneous visualization update. Set [jittering](https://en.wikipedia.org/wiki/Jitter) to prevent the dots overlapping. If *Jitter continuous values* is ticked, continuous instances will be dispersed. -2. Set the color of the displayed points (you will get colors for discrete values and grey-scale points for continuous). Set label, shape and size to differentiate between points. Set symbol size and opacity for all data points. Set the desired colors scale. -3. Adjust *plot properties*: - - *Show legend* displays a legend on the right. Click and drag the legend to move it. - - *Show gridlines* displays the grid behind the plot. - - *Show all data on mouse hover* enables information bubbles if the cursor is placed on a dot. - - *Show class density* colors the graph by class (see the screenshot below). - - *Show regression line* draws the regression line for pair of continuous attributes. - - *Label only selected points* allows you to select individual data instances and label them. -4. *Select, zoom, pan and zoom to fit* are the options for exploring the graph. The manual selection of data instances works as an angular/square selection tool. Double click to move the projection. Scroll in or out for zoom. -5. If *Send automatically* is ticked, changes are communicated automatically. Alternatively, press *Send*. -6. *Save Image* saves the created image to your computer in a .svg or .png format. -7. Produce a report. - -Here is an example of the **Scatter Plot** widget if the *Show class density* and *Show regression line* boxes are ticked. - -![](images/Scatterplot-ClassDensity.png) - -Intelligent Data Visualization ------------------------------- - -If a dataset has many attributes, it is impossible to manually scan through all the pairs to find interesting or useful scatter plots. Orange implements intelligent data visualization with the **Find Informative Projections** option in the widget. - -If a categorical variable is selected in the Color section, the [score](http://eprints.fri.uni-lj.si/210/) is computed as follows. For each data instance, the method finds 10 nearest neighbors in the projected 2D space, that is, on the combination of attribute pairs. It then checks how many of them have the same color. The total score of the projection is then the average number of same-colored neighbors. - -Computation for continuous colors is similar, except that the [coefficient of determination](https://en.wikipedia.org/wiki/Coefficient_of_determination) is used for measuring the local homogeneity of the projection. - -To use this method, go to the *Find Informative Projections* option in the widget, open the subwindow and press *Start Evaluation*. The feature will return a list of attribute pairs by average classification accuracy score. - -Below, there is an example demonstrating the utility of ranking. The first scatter plot projection was set as the default sepal width to sepal length plot (we used the Iris dataset for simplicity). Upon running *Find Informative Projections* optimization, the scatter plot converted to a much better projection of petal width to petal length plot. - -![](images/ScatterPlotExample-Ranking.png) - -Selection ---------- - -Selection can be used to manually defined subgroups in the data. Use Shift modifier when selecting data instances to put them into a new group. Shift + Ctrl (or Shift + Cmd on macOs) appends instances to the last group. - -Signal data outputs a data table with an additional column that contains group indices. - -![](images/ScatterPlot-selection.png) - -Explorative Data Analysis -------------------------- - -The **Scatter Plot**, as the rest of Orange widgets, supports zooming-in and out of part of the plot and a manual selection of data instances. These functions are available in the lower left corner of the widget. - -The default tool is *Select*, which selects data instances within the chosen rectangular area. *Pan* enables you to move the scatter plot around the pane. With *Zoom* you can zoom in and out of the pane with a mouse scroll, while *Reset zoom* resets the visualization to its optimal size. An example of a simple schema, where we selected data instances from a rectangular region and sent them to the [Data Table](../data/datatable.md) widget, is shown below. Notice that the scatter plot doesn't show all 52 data instances, because some data instances overlap (they have the same values for both attributes used). - -![](images/ScatterPlotExample-Explorative.png) - -Example -------- - -The **Scatter Plot** can be combined with any widget that outputs a list of selected data instances. In the example below, we combine [Tree](../model/tree.md) and **Scatter Plot** to display instances taken from a chosen decision tree node (clicking on any node of the tree will send a set of selected data instances to the scatter plot and mark selected instances with filled symbols). - -![](images/ScatterPlotExample-Classification.png) - -References ----------- - -Gregor Leban and Blaz Zupan and Gaj Vidmar and Ivan Bratko (2006) VizRank: Data Visualization Guided by Machine Learning. Data Mining and Knowledge Discovery, 13 (2). pp. 119-136. Available [here](http://eprints.fri.uni-lj.si/210/). diff --git a/doc/visual-programming/source/widgets/visualize/sievediagram.md b/doc/visual-programming/source/widgets/visualize/sievediagram.md deleted file mode 100644 index cfb6829a1b4..00000000000 --- a/doc/visual-programming/source/widgets/visualize/sievediagram.md +++ /dev/null @@ -1,42 +0,0 @@ -Sieve Diagram -============= - -Plots a sieve diagram for a pair of attributes. - -**Inputs** - -- Data: input dataset - -A **Sieve Diagram** is a graphical method for visualizing frequencies in a two-way contingency table and comparing them to [expected frequencies](http://cnx.org/contents/d396c4ad-2fd7-47cd-be84-152b44880feb@2/What-is-an-expected-frequency) under assumption of independence. It was proposed by Riedwyl and Schüpbach in a technical report in 1983 and later called a parquet diagram (Riedwyl and Schüpbach 1994). In this display, the area of each rectangle is proportional to the expected frequency, while the observed frequency is shown by the number of squares in each rectangle. The difference between observed and expected frequency (proportional to the standard Pearson residual) appears as the density of shading, using color to indicate whether the deviation from independence is positive (blue) or negative (red). - -![](images/SieveDiagram-stamped.png) - -1. Select the attributes you want to display in the sieve plot. -2. Score combinations enables you to fin the best possible combination of attributes. -3. *Save Image* saves the created image to your computer in a .svg or .png format. -4. Produce a report. - -The snapshot below shows a sieve diagram for the *Titanic* dataset and has the attributes *sex* and *survived* (the latter is a class attribute in this dataset). The plot shows that the two variables are highly associated, as there are substantial differences between observed and expected frequencies in all of the four quadrants. For example, and as highlighted in the balloon, the chance for surviving the accident was much higher for female passengers than expected (0.06 vs. 0.15). - -![](images/SieveDiagram-Titanic.png) - -Pairs of attributes with interesting associations have a strong shading, such as the diagram shown in the above snapshot. For contrast, a sieve diagram of the least interesting pair (age vs. survival) is shown below. - -![](images/SieveDiagram-Titanic-age-survived.png) - -Example -------- - -Below, we see a simple schema using the *Titanic* dataset, where we use the -[Rank](../data/rank.md) widget to select the best attributes (the ones with the highest information gain, gain ratio or Gini index) and feed them into the **Sieve Diagram**. This displays the sieve plot for the two best attributes, which in our case are sex and status. We see that the survival rate on the Titanic was very high for women of the first class and very low for female crew members. - -![](images/SieveDiagram-Example2.PNG) - -The **Sieve Diagram** also features the *Score Combinations* option, which makes the ranking of attributes even easier. - -![](images/SieveDiagram-Example1.PNG) - -References ----------- - -Riedwyl, H., and Schüpbach, M. (1994). Parquet diagram to plot contingency tables. In Softstat '93: Advances in Statistical Software, F. Faulbaum (Ed.). New York: Gustav Fischer, 293-299. diff --git a/doc/visual-programming/source/widgets/visualize/silhouetteplot.md b/doc/visual-programming/source/widgets/visualize/silhouetteplot.md deleted file mode 100644 index 98fa1eac75c..00000000000 --- a/doc/visual-programming/source/widgets/visualize/silhouetteplot.md +++ /dev/null @@ -1,41 +0,0 @@ -Silhouette Plot -=============== - -A graphical representation of consistency within clusters of data. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected - -The **Silhouette Plot** widget offers a graphical representation of consistency within clusters of data and provides the user with the means to visually assess cluster quality. The silhouette score is a measure of how similar an object is to its own cluster in comparison to other clusters and is crucial in the creation of a silhouette plot. The silhouette score close to 1 indicates that the data instance is close to the center of the cluster and instances possessing the silhouette scores close to 0 are on the border between two clusters. - -![](images/SilhouettePlot-stamped.png) - -1. Choose the distance metric. You can choose between: - - [Euclidean](https://en.wikipedia.org/wiki/Euclidean_distance) ("straight line" distance between two points) - - [Manhattan](https://en.wiktionary.org/wiki/Manhattan_distance) (the sum of absolute differences for all attributes) - - [Cosine](https://en.wiktionary.org/wiki/Cosine_similarity) (1 - cosine of the angle between two vectors) -2. Select the cluster label. You can decide whether to group the instances by cluster or not. -3. Display options: - - *Choose bar width*. - - *Annotations*: annotate the silhouette plot. -4. *Save Image* saves the created silhouette plot to your computer in a *.png* or *.svg* format. -5. Produce a report. -6. Output: - - *Add silhouette scores* (good clusters have higher silhouette scores) - - By clicking *Commit*, changes are communicated to the output of the widget. Alternatively, tick the box on the left and changes will be communicated automatically. -7. The created silhouette plot. - -Example -------- - -In the snapshot below, we have decided to use the **Silhouette Plot** on the *iris* dataset. We selected data instances with low silhouette scores and passed them on as a subset to the [Scatter Plot](../visualize/scatterplot.md) widget. This visualization only confirms the accuracy of the **Silhouette Plot** widget, as you can clearly see that the subset lies in the border between two clusters. - -![](images/SilhouettePlot-Example.png) - -If you are interested in other uses of the **Silhouette Plot** widget, feel free to explore our [blog post](http://blog.biolab.si/2016/03/23/all-i-see-is-silhouette/). diff --git a/doc/visual-programming/source/widgets/visualize/treeviewer.md b/doc/visual-programming/source/widgets/visualize/treeviewer.md deleted file mode 100644 index 83c07bc4236..00000000000 --- a/doc/visual-programming/source/widgets/visualize/treeviewer.md +++ /dev/null @@ -1,53 +0,0 @@ -Tree Viewer -=========== - -A visualization of classification and regression trees. - -**Inputs** - -- Tree: decision tree - -**Outputs** - -- Selected Data: instances selected from the tree node -- Data: data with an additional column showing whether a point is selected - -This is a versatile widget with 2-D visualization of [classification and regression trees](https://en.wikipedia.org/wiki/Decision_tree_learning). The user can select a node, instructing the widget to output the data associated with the node, thus enabling explorative data analysis. - -![](images/TreeViewer-stamped.png) - -1. Information on the input. -2. Display options: - - Zoom in and zoom out - - Select the tree width. The nodes display information bubbles when hovering over them. - - Select the depth of your tree. - - Select edge width. The edges between the nodes in the tree graph are drawn based on the selected edge width. - - All the edges will be of equal width if *Fixed* is chosen. - - When *Relative to root* is selected, the width of the edge will - correspond to the proportion of instances in the corresponding - node with respect to all the instances in the training data. Under - this selection, the edge will get thinner and thinner when - traversing toward the bottom of the tree. - - *Relative to parent* makes the edge width correspond to the proportion - of instances in the nodes with respect to the instances in their - parent node. - - Define the target class, which you can change based on classes in the data. -3. Press *Save image* to save the created tree graph to your computer as a *.svg* or *.png* file. -4. Produce a report. - -Examples --------- - -Below, is a simple classification schema, where we have read the data, constructed the decision tree and viewed it in our **Tree Viewer**. If both the viewer and [Tree](../model/tree.md) are open, any re-run of the tree induction algorithm will immediately affect the visualization. You can thus use this combination to explore how the parameters of the induction algorithm influence the structure of the resulting tree. - -![](images/TreeViewer-classification.png) - -Clicking on any node will output the related data instances. This is explored in the schema below that shows the subset in the data table and in the [Scatter Plot](../visualize/scatterplot.md). Make sure that the tree data is passed as a data subset; this can be done by connecting the **Scatter Plot** to the [File](../data/file.md) widget first, and connecting it to the **Tree Viewer** widget next. Selected data will be displayed as bold dots. - -**Tree Viewer** can also export labeled data. Connect [Data Table](../data/datatable.md) to **Tree Viewer** and set the link between widgets to *Data* instead of *Selected Data*. This will send the entire data to **Data Table** with an additional meta column labeling selected data instances (*Yes* for selected and *No* for the remaining). - -![](images/TreeViewer-selection.png) - -Finally, **Tree Viewer** can be used also for visualizing regression trees. Connect [Random Forest](../model/randomforest.md) to [File](../data/file.md) widget using *housing.tab* dataset. Then connect [Pythagorean Forest](../visualize/pythagoreanforest.md) to **Random Forest**. In **Pythagorean Forest** select a regression tree you wish to further analyze and pass it to the **Tree Viewer**. The widget will display the constructed tree. For visualizing larger trees, especially for regression, [Pythagorean Tree](../visualize/pythagoreantree.md) could be a better option. - -![](images/TreeViewer-regression.png) diff --git a/doc/visual-programming/source/widgets/visualize/venndiagram.md b/doc/visual-programming/source/widgets/visualize/venndiagram.md deleted file mode 100644 index bc6b33ab2d9..00000000000 --- a/doc/visual-programming/source/widgets/visualize/venndiagram.md +++ /dev/null @@ -1,40 +0,0 @@ -Venn Diagram -============ - -Plots a [Venn diagram](http://en.wikipedia.org/wiki/Venn_diagram) for two or more data subsets. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: entire data with a column indicating whether an instance was selected or not - -The **Venn Diagram** widget displays logical relations between datasets by showing the number of common data instances (rows) or the number of shared features (columns). Selecting a part of the visualization outputs the corresponding instances or features. - -![](images/venn-workflow.png) - -![](images/VennDiagram-stamped.png) - -1. Select whether to count common features or instances. -2. Select whether to include duplicates or to output only unique rows; applicable only when matching instances by values of variables. - -Rows can be matched -- by their identity, e.g. rows from different data sets match if they came from the same row in a file, -- by equality, if all tables contain the same variables, -- or by values of a string variable that appears in all tables. - -Examples --------- - -The easiest way to use the **Venn Diagram** is to select data subsets and find matching instances in the visualization. We use the *breast-cancer* dataset to select two subsets with [Select Rows](../data/selectrows.md) widget - the first subset is that of breast cancer patients aged between 40 and 49 and the second is that of patients with a tumor size between 20 and 29. The **Venn Diagram** helps us find instances that correspond to both criteria, which can be found in the intersection of the two circles. - -![](images/VennDiagram-Example1.png) - -The **Venn Diagram** widget can be also used for exploring different prediction models. In the following example, we analysed 3 prediction methods, namely [Naive Bayes](../model/naivebayes.md), [SVM](../model/svm.md) and [Random Forest](../model/randomforest.md), according to their misclassified instances. - -By selecting misclassifications in the three [Confusion Matrix](../evaluate/confusionmatrix.md) widgets and sending them to Venn diagram, we can see all the misclassification instances visualized per method used. Then we open **Venn Diagram** and select, for example, the misclassified instances that were identified by all three methods. This is represented as an intersection of all three circles. Click on the intersection to see this two instances marked in the [Scatter Plot](../visualize/scatterplot.md) widget. Try selecting different diagram sections to see how the scatter plot visualization changes. - -![](images/VennDiagram-Example2.png) diff --git a/doc/visual-programming/source/widgets/visualize/violinplot.md b/doc/visual-programming/source/widgets/visualize/violinplot.md deleted file mode 100644 index 37127899d72..00000000000 --- a/doc/visual-programming/source/widgets/visualize/violinplot.md +++ /dev/null @@ -1,45 +0,0 @@ -Violin Plot -=========== - -Visualize the distribution of feature values in a violin plot. - -**Inputs** - -- Data: input dataset - -**Outputs** - -- Selected Data: instances selected from the plot -- Data: data with an additional column showing whether a point is selected - -The **Violin Plot** widget plays a similar role as a [Box Plot](boxplot.md). It shows the distribution of quantitative data across several levels of a categorical variable such that those distributions can be compared. Unlike the Box Plot, in which all of the plot components correspond to actual data points, the Violin Plot features a kernel density estimation of the underlying distribution. - -![](images/ViolinPlot-stamped.png) - - -1. Select the variable you want to plot. Tick *Order by relevance to subgroups* to order variables by Chi2 or ANOVA over the selected subgroup. -2. Choose *Subgroups* to see [violin plots](https://en.wikipedia.org/wiki/Violin_plot) displayed by a discrete subgroup. Tick *Order by relevance to variable* to order subgroups by Chi2 or ANOVA over the selected variable. -3. *Box plot*: Tick to show the underlying box plot. - ![](images/ViolinPlot-boxplot.png) - - *Strip plot*: Tick to show the underlying data represented by points. - - *Rug plot*: Tick to show the underlying data represented by lines. - - *Order subgroups*: Tick to order violins by *median* (ascending). - - *Orientation*: Determine violin orientation. -4. *Kernel*: Select the kernel used to estimate the density. Possible kernels are: *Normal*, *Epanechnikov* and *Linear*. - - *Scale*: Select the method used to scale the width of each violin. If *area* is selected, each violin will have the same area. If *count* is selected, the width of the violins will be scaled by the number of observations in that bin. If *width* is selected, each violin will have the same width. - -Examples --------- - -The **Violin Plot** widget is most commonly used immediately after the [File](../data/file.md) widget to observe the statistical properties of a dataset. In the first example, we have used *heart-disease* data to inspect our variables. - -![](images/ViolinPlot-example1.png) - -The **Violin Plot** could also be used for *outlier detection*. In the next example we eliminate the outliers by selecting only instances that fall inside the [Q1 − 1.5 and Q3 + 1.5 IQR](https://en.wikipedia.org/wiki/Interquartile_range). - -![](images/ViolinPlot-example2.png) diff --git a/doc/widgets.json b/doc/widgets.json index bdd57b39425..022038639f6 100644 --- a/doc/widgets.json +++ b/doc/widgets.json @@ -20,6 +20,7 @@ "icon": "../Orange/widgets/data/icons/CSVFile.svg", "background": "#FFD39F", "keywords": [ + "csv file import", "file", "load", "read", @@ -33,8 +34,10 @@ "icon": "../Orange/widgets/data/icons/DataSets.svg", "background": "#FFD39F", "keywords": [ + "datasets", "online", - "data sets" + "data", + "sets" ] }, { @@ -43,6 +46,7 @@ "icon": "../Orange/widgets/data/icons/SQLTable.svg", "background": "#FFD39F", "keywords": [ + "sql table", "load" ] }, @@ -51,7 +55,10 @@ "doc": "visual-programming/source/widgets/data/datatable.md", "icon": "../Orange/widgets/data/icons/Table.svg", "background": "#FFD39F", - "keywords": [] + "keywords": [ + "data table", + "view" + ] }, { "text": "Paint Data", @@ -59,6 +66,7 @@ "icon": "../Orange/widgets/data/icons/PaintData.svg", "background": "#FFD39F", "keywords": [ + "paint data", "create", "draw" ] @@ -69,32 +77,76 @@ "icon": "../Orange/widgets/data/icons/DataInfo.svg", "background": "#FFD39F", "keywords": [ + "data info", "information", "inspect" ] }, { - "text": "Aggregate Columns", - "doc": "visual-programming/source/widgets/data/aggregatecolumns.md", - "icon": "../Orange/widgets/data/icons/AggregateColumns.svg", + "text": "Rank", + "doc": "visual-programming/source/widgets/data/rank.md", + "icon": "../Orange/widgets/data/icons/Rank.svg", "background": "#FFD39F", "keywords": [ - "aggregate", - "sum", - "product", - "max", - "min", - "mean", - "median", - "variance" + "rank", + "filter" ] }, + { + "text": "Edit Domain", + "doc": "visual-programming/source/widgets/data/editdomain.md", + "icon": "../Orange/widgets/data/icons/EditDomain.svg", + "background": "#FFD39F", + "keywords": [ + "edit domain", + "rename", + "drop", + "reorder", + "order" + ] + }, + { + "text": "Color", + "doc": "visual-programming/source/widgets/data/color.md", + "icon": "../Orange/widgets/data/icons/Colors.svg", + "background": "#FFD39F", + "keywords": [ + "palette", + "legend" + ] + }, + { + "text": "Column Statistics", + "doc": "visual-programming/source/widgets/data/featurestatistics.md", + "icon": "../Orange/widgets/data/icons/FeatureStatistics.svg", + "background": "#FFD39F", + "keywords": [ + "feature", + "variable" + ] + }, + { + "text": "Save Data", + "doc": "visual-programming/source/widgets/data/save.md", + "icon": "../Orange/widgets/data/icons/Save.svg", + "background": "#FFD39F", + "keywords": [ + "save data", + "export" + ] + } + ] + ], + [ + "Transform", + [ { "text": "Data Sampler", "doc": "visual-programming/source/widgets/data/datasampler.md", "icon": "../Orange/widgets/data/icons/DataSampler.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [ + "data sampler", "random" ] }, @@ -102,8 +154,9 @@ "text": "Select Columns", "doc": "visual-programming/source/widgets/data/selectcolumns.md", "icon": "../Orange/widgets/data/icons/SelectColumns.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [ + "select columns", "filter", "attributes", "target", @@ -114,42 +167,45 @@ "text": "Select Rows", "doc": "visual-programming/source/widgets/data/selectrows.md", "icon": "../Orange/widgets/data/icons/SelectRows.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [ + "select rows", "filter" ] }, { - "text": "Pivot Table", - "doc": "visual-programming/source/widgets/data/pivot.md", - "icon": "../Orange/widgets/data/icons/Pivot.svg", - "background": "#FFD39F", + "text": "Transpose", + "doc": "visual-programming/source/widgets/data/transpose.md", + "icon": "../Orange/widgets/data/icons/Transpose.svg", + "background": "#FF9D5E", "keywords": [ - "pivot", - "group", - "aggregate" + "transpose" ] }, { - "text": "Rank", - "doc": "visual-programming/source/widgets/data/rank.md", - "icon": "../Orange/widgets/data/icons/Rank.svg", - "background": "#FFD39F", - "keywords": [] - }, - { - "text": "Correlations", - "doc": "visual-programming/source/widgets/data/correlations.md", - "icon": "../Orange/widgets/data/icons/Correlations.svg", - "background": "#FFD39F", - "keywords": [] + "text": "Split", + "doc": null, + "icon": "../Orange/widgets/data/icons/Split.svg", + "background": "#FF9D5E", + "keywords": [ + "text", + "columns", + "word", + "encoding", + "questionnaire", + "survey", + "term", + "counts", + "indicator" + ] }, { "text": "Merge Data", "doc": "visual-programming/source/widgets/data/mergedata.md", "icon": "../Orange/widgets/data/icons/MergeData.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [ + "merge data", "join" ] }, @@ -157,8 +213,9 @@ "text": "Concatenate", "doc": "visual-programming/source/widgets/data/concatenate.md", "icon": "../Orange/widgets/data/icons/Concatenate.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [ + "concatenate", "append", "join", "extend" @@ -168,105 +225,99 @@ "text": "Select by Data Index", "doc": "visual-programming/source/widgets/data/select-by-data-index.md", "icon": "../Orange/widgets/data/icons/SelectByDataIndex.svg", - "background": "#FFD39F", - "keywords": [] - }, - { - "text": "Transpose", - "doc": "visual-programming/source/widgets/data/transpose.md", - "icon": "../Orange/widgets/data/icons/Transpose.svg", - "background": "#FFD39F", - "keywords": [] - }, - { - "text": "Randomize", - "doc": "visual-programming/source/widgets/data/randomize.md", - "icon": "../Orange/widgets/data/icons/Random.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [] }, { - "text": "Preprocess", - "doc": "visual-programming/source/widgets/data/preprocess.md", - "icon": "../Orange/widgets/data/icons/Preprocess.svg", - "background": "#FFD39F", + "text": "Unique", + "doc": "visual-programming/source/widgets/data/unique.md", + "icon": "../Orange/widgets/data/icons/Unique.svg", + "background": "#FF9D5E", "keywords": [ - "process" + "unique", + "distinct", + "remove", + "duplicates", + "filter" ] }, { - "text": "Apply Domain", - "doc": "visual-programming/source/widgets/data/applydomain.md", - "icon": "../Orange/widgets/data/icons/Transform.svg", - "background": "#FFD39F", + "text": "Aggregate Columns", + "doc": "visual-programming/source/widgets/data/aggregatecolumns.md", + "icon": "../Orange/widgets/data/icons/AggregateColumns.svg", + "background": "#FF9D5E", "keywords": [ - "transform" + "aggregate columns", + "aggregate", + "sum", + "product", + "max", + "min", + "mean", + "median", + "variance" ] }, { - "text": "Impute", - "doc": "visual-programming/source/widgets/data/impute.md", - "icon": "../Orange/widgets/data/icons/Impute.svg", - "background": "#FFD39F", + "text": "Group by", + "doc": "visual-programming/source/widgets/data/groupby.md", + "icon": "../Orange/widgets/data/icons/GroupBy.svg", + "background": "#FF9D5E", "keywords": [ - "substitute", - "missing" + "aggregate", + "group by" ] }, { - "text": "Outliers", - "doc": "visual-programming/source/widgets/data/outliers.md", - "icon": "../Orange/widgets/data/icons/Outliers.svg", - "background": "#FFD39F", + "text": "Pivot Table", + "doc": "visual-programming/source/widgets/data/pivot.md", + "icon": "../Orange/widgets/data/icons/Pivot.svg", + "background": "#FF9D5E", "keywords": [ - "inlier" + "pivot table", + "pivot", + "group", + "aggregate" ] }, { - "text": "Edit Domain", - "doc": "visual-programming/source/widgets/data/editdomain.md", - "icon": "../Orange/widgets/data/icons/EditDomain.svg", - "background": "#FFD39F", + "text": "Apply Domain", + "doc": "visual-programming/source/widgets/data/applydomain.md", + "icon": "../Orange/widgets/data/icons/Transform.svg", + "background": "#FF9D5E", "keywords": [ - "rename", - "drop", - "reorder", - "order" + "apply domain", + "transform" ] }, { - "text": "Python Script", - "doc": "visual-programming/source/widgets/data/pythonscript.md", - "icon": "../Orange/widgets/data/icons/PythonScript.svg", - "background": "#FFD39F", + "text": "Preprocess", + "doc": "visual-programming/source/widgets/data/preprocess.md", + "icon": "../Orange/widgets/data/icons/Preprocess.svg", + "background": "#FF9D5E", "keywords": [ - "file", - "program", - "function" + "preprocess", + "process" ] }, { - "text": "Create Instance", - "doc": "visual-programming/source/widgets/data/createinstance.md", - "icon": "../Orange/widgets/data/icons/CreateInstance.svg", - "background": "#FFD39F", + "text": "Impute", + "doc": "visual-programming/source/widgets/data/impute.md", + "icon": "../Orange/widgets/data/icons/Impute.svg", + "background": "#FF9D5E", "keywords": [ - "simulator" + "impute", + "substitute", + "missing" ] }, - { - "text": "Color", - "doc": "visual-programming/source/widgets/data/color.md", - "icon": "../Orange/widgets/data/icons/Colors.svg", - "background": "#FFD39F", - "keywords": [] - }, { "text": "Continuize", "doc": "visual-programming/source/widgets/data/continuize.md", "icon": "../Orange/widgets/data/icons/Continuize.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [ + "continuize", "encode", "dummy", "numeric", @@ -276,19 +327,13 @@ "contrast" ] }, - { - "text": "Create Class", - "doc": "visual-programming/source/widgets/data/createclass.md", - "icon": "../Orange/widgets/data/icons/CreateClass.svg", - "background": "#FFD39F", - "keywords": [] - }, { "text": "Discretize", "doc": "visual-programming/source/widgets/data/discretize.md", "icon": "../Orange/widgets/data/icons/Discretize.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [ + "discretize", "bin", "categorical", "nominal", @@ -296,66 +341,79 @@ ] }, { - "text": "Feature Constructor", - "doc": "visual-programming/source/widgets/data/featureconstructor.md", - "icon": "../Orange/widgets/data/icons/FeatureConstructor.svg", - "background": "#FFD39F", + "text": "Randomize", + "doc": "visual-programming/source/widgets/data/randomize.md", + "icon": "../Orange/widgets/data/icons/Random.svg", + "background": "#FF9D5E", "keywords": [ - "function", - "lambda" + "randomize", + "random" ] }, { - "text": "Feature Statistics", - "doc": "visual-programming/source/widgets/data/featurestatistics.md", - "icon": "../Orange/widgets/data/icons/FeatureStatistics.svg", - "background": "#FFD39F", - "keywords": [] + "text": "Purge Domain", + "doc": "visual-programming/source/widgets/data/purgedomain.md", + "icon": "../Orange/widgets/data/icons/PurgeDomain.svg", + "background": "#FF9D5E", + "keywords": [ + "remove", + "delete", + "unused" + ] }, { "text": "Melt", "doc": "visual-programming/source/widgets/data/melt.md", "icon": "../Orange/widgets/data/icons/Melt.svg", - "background": "#FFD39F", + "background": "#FF9D5E", "keywords": [ + "melt", "shopping list", "wide", "narrow" ] }, { - "text": "Neighbors", - "doc": "visual-programming/source/widgets/data/neighbors.md", - "icon": "../Orange/widgets/data/icons/Neighbors.svg", - "background": "#FFD39F", - "keywords": [] + "text": "Formula", + "doc": "visual-programming/source/widgets/data/formula.md", + "icon": "../Orange/widgets/data/icons/FeatureConstructor.svg", + "background": "#FF9D5E", + "keywords": [ + "feature constructor", + "function", + "lambda", + "calculation" + ] }, { - "text": "Purge Domain", - "doc": "visual-programming/source/widgets/data/purgedomain.md", - "icon": "../Orange/widgets/data/icons/PurgeDomain.svg", - "background": "#FFD39F", + "text": "Create Class", + "doc": "visual-programming/source/widgets/data/createclass.md", + "icon": "../Orange/widgets/data/icons/CreateClass.svg", + "background": "#FF9D5E", "keywords": [ - "remove", - "delete", - "unused" + "create", + "class" ] }, { - "text": "Save Data", - "doc": "visual-programming/source/widgets/data/save.md", - "icon": "../Orange/widgets/data/icons/Save.svg", - "background": "#FFD39F", + "text": "Create Instance", + "doc": "visual-programming/source/widgets/data/createinstance.md", + "icon": "../Orange/widgets/data/icons/CreateInstance.svg", + "background": "#FF9D5E", "keywords": [ - "export" + "create instance", + "simulator" ] }, { - "text": "Unique", - "doc": "visual-programming/source/widgets/data/unique.md", - "icon": "../Orange/widgets/data/icons/Unique.svg", - "background": "#FFD39F", - "keywords": [] + "text": "Python Script", + "doc": "visual-programming/source/widgets/data/pythonscript.md", + "icon": "../Orange/widgets/data/icons/PythonScript.svg", + "background": "#FF9D5E", + "keywords": [ + "program", + "function" + ] } ] ], @@ -367,7 +425,10 @@ "doc": "visual-programming/source/widgets/visualize/treeviewer.md", "icon": "../Orange/widgets/visualize/icons/TreeViewer.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "tree", + "viewer" + ] }, { "text": "Box Plot", @@ -375,6 +436,7 @@ "icon": "../Orange/widgets/visualize/icons/BoxPlot.svg", "background": "#FFB7B1", "keywords": [ + "box plot", "whisker" ] }, @@ -384,6 +446,7 @@ "icon": "../Orange/widgets/visualize/icons/ViolinPlot.svg", "background": "#FFB7B1", "keywords": [ + "violin plot", "kernel", "density" ] @@ -394,6 +457,7 @@ "icon": "../Orange/widgets/visualize/icons/Distribution.svg", "background": "#FFB7B1", "keywords": [ + "distributions", "histogram" ] }, @@ -402,14 +466,20 @@ "doc": "visual-programming/source/widgets/visualize/scatterplot.md", "icon": "../Orange/widgets/visualize/icons/ScatterPlot.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "scatter", + "plot" + ] }, { "text": "Line Plot", "doc": "visual-programming/source/widgets/visualize/lineplot.md", "icon": "../Orange/widgets/visualize/icons/LinePlot.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "line", + "plot" + ] }, { "text": "Bar Plot", @@ -417,6 +487,7 @@ "icon": "../Orange/widgets/visualize/icons/BarPlot.svg", "background": "#FFB7B1", "keywords": [ + "bar plot", "chart" ] }, @@ -425,14 +496,20 @@ "doc": "visual-programming/source/widgets/visualize/sievediagram.md", "icon": "../Orange/widgets/visualize/icons/SieveDiagram.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "sieve", + "diagram" + ] }, { "text": "Mosaic Display", "doc": "visual-programming/source/widgets/visualize/mosaicdisplay.md", "icon": "../Orange/widgets/visualize/icons/MosaicDisplay.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "mosaic", + "display" + ] }, { "text": "FreeViz", @@ -440,6 +517,7 @@ "icon": "../Orange/widgets/visualize/icons/Freeviz.svg", "background": "#FFB7B1", "keywords": [ + "freeviz", "viz" ] }, @@ -448,7 +526,10 @@ "doc": "visual-programming/source/widgets/visualize/linearprojection.md", "icon": "../Orange/widgets/visualize/icons/LinearProjection.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "linear", + "projection" + ] }, { "text": "Radviz", @@ -456,6 +537,7 @@ "icon": "../Orange/widgets/visualize/icons/Radviz.svg", "background": "#FFB7B1", "keywords": [ + "radviz", "viz" ] }, @@ -464,21 +546,30 @@ "doc": "visual-programming/source/widgets/visualize/heatmap.md", "icon": "../Orange/widgets/visualize/icons/Heatmap.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "heat", + "map" + ] }, { "text": "Venn Diagram", "doc": "visual-programming/source/widgets/visualize/venndiagram.md", "icon": "../Orange/widgets/visualize/icons/VennDiagram.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "venn", + "diagram" + ] }, { "text": "Silhouette Plot", "doc": "visual-programming/source/widgets/visualize/silhouetteplot.md", "icon": "../Orange/widgets/visualize/icons/SilhouettePlot.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "silhouette", + "plot" + ] }, { "text": "Pythagorean Tree", @@ -486,6 +577,7 @@ "icon": "../Orange/widgets/visualize/icons/PythagoreanTree.svg", "background": "#FFB7B1", "keywords": [ + "pythagorean tree", "fractal" ] }, @@ -495,6 +587,7 @@ "icon": "../Orange/widgets/visualize/icons/PythagoreanForest.svg", "background": "#FFB7B1", "keywords": [ + "pythagorean forest", "fractal" ] }, @@ -503,14 +596,31 @@ "doc": "visual-programming/source/widgets/visualize/cn2ruleviewer.md", "icon": "../Orange/widgets/visualize/icons/CN2RuleViewer.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "cn2", + "rule", + "viewer" + ] }, { "text": "Nomogram", "doc": "visual-programming/source/widgets/visualize/nomogram.md", "icon": "../Orange/widgets/visualize/icons/Nomogram.svg", "background": "#FFB7B1", - "keywords": [] + "keywords": [ + "nomogram" + ] + }, + { + "text": "Scoring Sheet Viewer", + "doc": "visual-programming/source/widgets/visualize/scoringsheetviewer.md", + "icon": "../Orange/widgets/visualize/icons/ScoringSheetViewer.svg", + "background": "#FFB7B1", + "keywords": [ + "scoring", + "sheet", + "viewer" + ] } ] ], @@ -523,6 +633,7 @@ "icon": "../Orange/widgets/model/icons/Constant.svg", "background": "#FAC1D9", "keywords": [ + "constant", "majority", "mean" ] @@ -532,7 +643,11 @@ "doc": "visual-programming/source/widgets/model/cn2ruleinduction.md", "icon": "../Orange/widgets/model/icons/CN2RuleInduction.svg", "background": "#FAC1D9", - "keywords": [] + "keywords": [ + "cn2", + "rule", + "induction" + ] }, { "text": "Calibrated Learner", @@ -540,6 +655,7 @@ "icon": "../Orange/widgets/model/icons/CalibratedLearner.svg", "background": "#FAC1D9", "keywords": [ + "calibrated learner", "calibration", "threshold" ] @@ -550,6 +666,7 @@ "icon": "../Orange/widgets/model/icons/KNN.svg", "background": "#FAC1D9", "keywords": [ + "knn", "k nearest", "knearest", "neighbor", @@ -562,7 +679,8 @@ "icon": "../Orange/widgets/model/icons/Tree.svg", "background": "#FAC1D9", "keywords": [ - "Classification Tree" + "tree", + "classification tree" ] }, { @@ -570,7 +688,10 @@ "doc": "visual-programming/source/widgets/model/randomforest.md", "icon": "../Orange/widgets/model/icons/RandomForest.svg", "background": "#FAC1D9", - "keywords": [] + "keywords": [ + "random", + "forest" + ] }, { "text": "Gradient Boosting", @@ -578,6 +699,7 @@ "icon": "../Orange/widgets/model/icons/GradientBoosting.svg", "background": "#FAC1D9", "keywords": [ + "gradient boosting", "catboost", "gradient", "boost", @@ -594,6 +716,7 @@ "icon": "../Orange/widgets/model/icons/SVM.svg", "background": "#FAC1D9", "keywords": [ + "svm", "support vector machines" ] }, @@ -603,6 +726,7 @@ "icon": "../Orange/widgets/model/icons/LinearRegression.svg", "background": "#FAC1D9", "keywords": [ + "linear regression", "ridge", "lasso", "elastic net" @@ -613,14 +737,30 @@ "doc": "visual-programming/source/widgets/model/logisticregression.md", "icon": "../Orange/widgets/model/icons/LogisticRegression.svg", "background": "#FAC1D9", - "keywords": [] + "keywords": [ + "logistic", + "regression" + ] }, { "text": "Naive Bayes", "doc": "visual-programming/source/widgets/model/naivebayes.md", "icon": "../Orange/widgets/model/icons/NaiveBayes.svg", "background": "#FAC1D9", - "keywords": [] + "keywords": [ + "naive", + "bayes" + ] + }, + { + "text": "Scoring Sheet", + "doc": "visual-programming/source/widgets/model/scoringsheet.md", + "icon": "../Orange/widgets/model/icons/ScoringSheet.svg", + "background": "#FAC1D9", + "keywords": [ + "scoring", + "sheet" + ] }, { "text": "AdaBoost", @@ -628,15 +768,38 @@ "icon": "../Orange/widgets/model/icons/AdaBoost.svg", "background": "#FAC1D9", "keywords": [ + "adaboost", "boost" ] }, + { + "text": "PLS", + "doc": "visual-programming/source/widgets/model/pls.md", + "icon": "../Orange/widgets/model/icons/PLS.svg", + "background": "#FAC1D9", + "keywords": [ + "partial", + "least", + "squares" + ] + }, + { + "text": "Curve Fit", + "doc": "visual-programming/source/widgets/model/curvefit.md", + "icon": "../Orange/widgets/model/icons/CurveFit.svg", + "background": "#FAC1D9", + "keywords": [ + "curve fit", + "function" + ] + }, { "text": "Neural Network", "doc": "visual-programming/source/widgets/model/neuralnetwork.md", "icon": "../Orange/widgets/model/icons/NN.svg", "background": "#FAC1D9", "keywords": [ + "neural network", "mlp" ] }, @@ -646,6 +809,7 @@ "icon": "../Orange/widgets/model/icons/SGD.svg", "background": "#FAC1D9", "keywords": [ + "stochastic gradient descent", "sgd" ] }, @@ -654,14 +818,20 @@ "doc": "visual-programming/source/widgets/model/stacking.md", "icon": "../Orange/widgets/model/icons/Stacking.svg", "background": "#FAC1D9", - "keywords": [] + "keywords": [ + "stacking", + "ensemble" + ] }, { "text": "Save Model", "doc": "visual-programming/source/widgets/model/savemodel.md", "icon": "../Orange/widgets/model/icons/SaveModel.svg", "background": "#FAC1D9", - "keywords": [] + "keywords": [ + "save model", + "save" + ] }, { "text": "Load Model", @@ -669,6 +839,7 @@ "icon": "../Orange/widgets/model/icons/LoadModel.svg", "background": "#FAC1D9", "keywords": [ + "load model", "file", "open", "model" @@ -685,8 +856,9 @@ "icon": "../Orange/widgets/evaluate/icons/TestLearners1.svg", "background": "#C3F3F3", "keywords": [ - "Cross Validation", - "CV" + "test and score", + "cross validation", + "cv" ] }, { @@ -694,30 +866,52 @@ "doc": "visual-programming/source/widgets/evaluate/predictions.md", "icon": "../Orange/widgets/evaluate/icons/Predictions.svg", "background": "#C3F3F3", - "keywords": [] + "keywords": [ + "predictions" + ] + }, + { + "text": "Feature as Predictor", + "doc": "visual-programming/source/widgets/evaluate/featureaspredictor.md", + "icon": "../Orange/widgets/evaluate/icons/FeatureAsPredictor.svg", + "background": "#C3F3F3", + "keywords": [ + "column", + "predictor" + ] }, { "text": "Confusion Matrix", "doc": "visual-programming/source/widgets/evaluate/confusionmatrix.md", "icon": "../Orange/widgets/evaluate/icons/ConfusionMatrix.svg", "background": "#C3F3F3", - "keywords": [] + "keywords": [ + "confusion", + "matrix" + ] }, { "text": "ROC Analysis", "doc": "visual-programming/source/widgets/evaluate/rocanalysis.md", "icon": "../Orange/widgets/evaluate/icons/ROCAnalysis.svg", "background": "#C3F3F3", - "keywords": [] + "keywords": [ + "roc analysis", + "analyse" + ] }, { - "text": "Lift Curve", - "doc": "visual-programming/source/widgets/evaluate/liftcurve.md", + "text": "Performance Curve", + "doc": "visual-programming/source/widgets/evaluate/performancecurve.md", "icon": "../Orange/widgets/evaluate/icons/LiftCurve.svg", "background": "#C3F3F3", "keywords": [ + "performance curve", "lift", - "cumulative gain" + "cumulative gain", + "precision", + "recall", + "curve" ] }, { @@ -725,7 +919,28 @@ "doc": "visual-programming/source/widgets/evaluate/calibrationplot.md", "icon": "../Orange/widgets/evaluate/icons/CalibrationPlot.svg", "background": "#C3F3F3", + "keywords": [ + "calibration", + "plot" + ] + }, + { + "text": "Permutation Plot", + "doc": "visual-programming/source/widgets/evaluate/permutationplot.md", + "icon": "../Orange/widgets/evaluate/icons/PermutationPlot.svg", + "background": "#C3F3F3", "keywords": [] + }, + { + "text": "Parameter Fitter", + "doc": "visual-programming/source/widgets/evaluate/parameterfitter.md", + "icon": "../Orange/widgets/evaluate/icons/ParameterFitter.svg", + "background": "#C3F3F3", + "keywords": [ + "parameter", + "fitter", + "tuning" + ] } ] ], @@ -738,6 +953,7 @@ "icon": "../Orange/widgets/unsupervised/icons/DistanceFile.svg", "background": "#CAE1EF", "keywords": [ + "distance file", "load", "read", "open" @@ -748,7 +964,10 @@ "doc": "visual-programming/source/widgets/unsupervised/distancematrix.md", "icon": "../Orange/widgets/unsupervised/icons/DistanceMatrix.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "distance", + "matrix" + ] }, { "text": "t-SNE", @@ -756,22 +975,39 @@ "icon": "../Orange/widgets/unsupervised/icons/TSNE.svg", "background": "#CAE1EF", "keywords": [ + "t-sne", "tsne" ] }, + { + "text": "Correlations", + "doc": "visual-programming/source/widgets/unsupervised/correlations.md", + "icon": "../Orange/widgets/data/icons/Correlations.svg", + "background": "#CAE1EF", + "keywords": [ + "pearson", + "spearman" + ] + }, { "text": "Distance Map", "doc": "visual-programming/source/widgets/unsupervised/distancemap.md", "icon": "../Orange/widgets/unsupervised/icons/DistanceMap.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "distance", + "map" + ] }, { "text": "Hierarchical Clustering", "doc": "visual-programming/source/widgets/unsupervised/hierarchicalclustering.md", "icon": "../Orange/widgets/unsupervised/icons/HierarchicalClustering.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "hierarchical", + "clustering" + ] }, { "text": "k-Means", @@ -779,6 +1015,7 @@ "icon": "../Orange/widgets/unsupervised/icons/KMeans.svg", "background": "#CAE1EF", "keywords": [ + "k-means", "kmeans", "clustering" ] @@ -788,21 +1025,39 @@ "doc": "visual-programming/source/widgets/unsupervised/louvainclustering.md", "icon": "../Orange/widgets/unsupervised/icons/LouvainClustering.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "community" + ] }, { "text": "DBSCAN", "doc": "visual-programming/source/widgets/unsupervised/DBSCAN.md", "icon": "../Orange/widgets/unsupervised/icons/DBSCAN.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "density based clustering", + "clustering" + ] }, { "text": "Manifold Learning", "doc": "visual-programming/source/widgets/unsupervised/manifoldlearning.md", "icon": "../Orange/widgets/unsupervised/icons/Manifold.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "manifold", + "learning" + ] + }, + { + "text": "Outliers", + "doc": "visual-programming/source/widgets/data/outliers.md", + "icon": "../Orange/widgets/data/icons/Outliers.svg", + "background": "#CAE1EF", + "keywords": [ + "outliers", + "inlier" + ] }, { "text": "PCA", @@ -810,30 +1065,51 @@ "icon": "../Orange/widgets/unsupervised/icons/PCA.svg", "background": "#CAE1EF", "keywords": [ + "pca", "principal component analysis", "linear transformation" ] }, + { + "text": "Neighbors", + "doc": "visual-programming/source/widgets/unsupervised/neighbors.md", + "icon": "../Orange/widgets/data/icons/Neighbors.svg", + "background": "#CAE1EF", + "keywords": [ + "knn", + "nearest neighbors", + "distance", + "similarity" + ] + }, { "text": "Correspondence Analysis", "doc": "visual-programming/source/widgets/unsupervised/correspondenceanalysis.md", "icon": "../Orange/widgets/unsupervised/icons/CorrespondenceAnalysis.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "correspondence", + "analysis" + ] }, { "text": "Distances", "doc": "visual-programming/source/widgets/unsupervised/distances.md", "icon": "../Orange/widgets/unsupervised/icons/Distance.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "distances" + ] }, { "text": "Distance Transformation", "doc": "visual-programming/source/widgets/unsupervised/distancetransformation.md", "icon": "../Orange/widgets/unsupervised/icons/DistancesTransformation.svg", "background": "#CAE1EF", - "keywords": [] + "keywords": [ + "distance", + "transformation" + ] }, { "text": "MDS", @@ -841,6 +1117,7 @@ "icon": "../Orange/widgets/unsupervised/icons/MDS.svg", "background": "#CAE1EF", "keywords": [ + "mds", "multidimensional scaling", "multi dimensional scaling" ] @@ -851,6 +1128,7 @@ "icon": "../Orange/widgets/unsupervised/icons/SaveDistances.svg", "background": "#CAE1EF", "keywords": [ + "save distance matrix", "distance matrix", "save" ] @@ -861,7 +1139,8 @@ "icon": "../Orange/widgets/unsupervised/icons/SOM.svg", "background": "#CAE1EF", "keywords": [ - "SOM" + "self-organizing map", + "som" ] } ] diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 00000000000..b763c74c3ef --- /dev/null +++ b/i18n/README.md @@ -0,0 +1,31 @@ +This directory contains messsage files and configuration files for translating +Orange to other languages. Currently, Orange is translated to Slovenian. + +Orange is translated using `trubar`, which is pip-installable. + +### Updating translations + +If CI tests report that the translation is out of date, you can update it if you wish. + +From directory orange3, run `trubar collect -s Orange i18n/si/msgs.jaml` and see the changes in the message file, `i18n/si/msgs.jaml` e.g. using `git diff`. + +Message files are in [a simplified version of YAML](http://janezd.github.io/trubar/message-files/). Obsolete messages will be removed and new messages will be added. You will need to translate the latter or mark them as not needing translation. To do so, change `null`'s to: + +- a translation, +- `true` if translation is OK for Slovenian, but the string may need to be changed for some other languages, +- `false` if the string must not be translated, most often because it is a string literal. + +When in doubt, do nothing and someone else will look into it some day. + +### Translating Orange + +#### Preparation (only once) + +1. Copy Orange to another directory, say ~/dev/si/orange3, or clone it from GitHub. +2. Create a virtual environment that uses Orange from that directory. + +#### Translating + +1. Copy the newest Orange sources to your new directory, e.g. ~/dev/si/orange3. +2. `cd` to `i18n` and run `./trans.sh `, e.g. `./trans.sh si ~/dev/si/orange3`. +3. Activate the appropriate virtual environment and run Orange. diff --git a/i18n/si/msgs.jaml b/i18n/si/msgs.jaml new file mode 100644 index 00000000000..ac558887d04 --- /dev/null +++ b/i18n/si/msgs.jaml @@ -0,0 +1,16090 @@ +__init__.py: + orange.addons: false + classification: false + clustering: false + distance: false + ensembles: false + evaluation: false + misc: false + modelling: false + preprocess: false + projection: false + regression: false + statistics: false + version: false + widgets: false + libGL.so.1: false +base.py: + Learner: false + Model: false + SklLearner: false + SklModel: false + ReprableWithPreprocessors: false + class `ReprableWithPreprocessors`: + def `_reprable_omit_param`: + preprocessors: false + class `Learner`: + def `fit`: + Descendants of Learner must overload method fit or fit_storage: false + def `__call__`: + Preprocessing...: Predprocesiranje... + "A keyword argument 'progress_callback' has been ": false + 'added to the preprocess() signature. Implementing ': false + 'the method without the argument is deprecated and ': false + will result in an error in the future.: false + %s doesn't support multiple class variables: false + Fitting...: Učenje... + domain: false + def `name`: + Learner: false + Fitter: false + Skl: false + learner: false + ([a-z0-9])([A-Z]): false + \1 \2: false + (.)([A-Z][a-z]+): false + class `Model`: + def `predict`: + Descendants of Model must overload method predict: false + def `predict_storage`: + Unrecognized argument (instance of '{}'): false + def `get_backmappers`: + Mismatching number of model's classes and data classes: false + "Model for '{modelclass.name}' ": false + cannot predict '{dataclass.name}': false + "Variables '{modelclass.name}' in the model is ": false + 'incompatible with the variable of the same name ': false + in the data.: false + def `data_to_model_domain`: + domain transformation produced no defined values: false + def `__call__`: + invalid value of argument 'ret': false + cannot predict continuous distributions: false + Unrecognized argument (instance of '{}'): false + model returned a {prediction.ndim}-dimensional array: false + def `__getstate__`: + original_data: false + class `SklModel`: + def `predict`: + probability: false + predict_proba: false + def `__repr__`: + ' # params=': false + class `SklLearner`: + def `_get_sklparams`: + self: false + Wrapper does not define '__wraps__': false + def `preprocess`: + 'Wrapped scikit-learn methods do not support ': false + multinomial variables.: false + class `KNNBase`: + def `__init__`: + euclidean: false + uniform: false + auto: false + def `_initialize_wrapped`: + metric: false + cosine: false + euclidean: false + def `fit`: + metric_params: false + metric: false + mahalanobis: false + V: false + cosine: false + l2: false + class `NNBase`: + def `__init__`: + relu: false + adam: false + auto: false + constant: false + class `CatGBModel`: + def `predict`: + predict_proba: false + def `__repr__`: + ' # params=': false +tree.py: + class `MappedDiscreteNode`: + def `branches_from_mapping`: + {:>0{}b}: false + def `_set_child_descriptions`: + (unreachable): (nedosegljivo) + {} or {}: {} ali {} + ', ': false + class `NumericNode`: + def `_set_child_descriptions`: + {} {}: false + ≤>: false + class `TreeModel`: + def `rule`: + {} > {}: false + {} ≤ {}: false + {} < {} ≤ {}: false + '{}: {}': false + def `print_tree`: + {:>20} {}{} {}\n: false + ' ': false +util.py: + default: false + ORANGE_DEPRECATIONS_ERROR: false + error: false + def `log_warnings`: + nested log_warnings: false + def `resource_filename`: + Orange: false + def `deprecated`: + ; use {obj} instead: false + def `decorator`: + def `wrapper`: + __self__: false + {func.__self__.__class__}.{name}: false + Call to deprecated {name}{alternative}: false + class `allot`: + def `call`: + skippable function cannot return a result: false + def `literal_eval`: + set(): false + ==: false + >=: false + <=: false + >: false + <: false + _Requirement: false + name: false + op: false + value: false + True: false + true: false + False: false + false: false + def `requirementsSatisfied`: + 'Invalid requirement specification: %s': false + A: false + B: false + class `Registry`: + def `__new__`: + registry: false + def `__str__`: + {cls.__name__}({{{", ".join(cls.registry)}}}): false + def `namegen`: + _: false + def `export_globals`: + __name__: false + _: false + def `color_to_hex`: + '#{:02X}{:02X}{:02X}': false + def `Reprable_repr_pretty`: + {name}(...): false + def `printitem`: + =: false + def `printsep`: + ,: false + {name}(: false + ): false + class `_Undef`: + def `__repr__`: + : false + class `Reprable`: + def `_reprable_fields`: + self: false + def `_reprable_items`: + error: false + def `_repr_pretty_`: + .: false + def `__repr__`: + .: false + ', ': false + {f}={repr(v)}: false + {name}({items}): false + def `frompyfunc`: + DTypeLike: false + def `funcv`: + unsafe: false +version.py: + 3.41.0: false + 3.41.0.dev0+58e68e8: false + 58e68e8bc9a6b560967ca49f37f89a3dd0d213b8: false + .dev: false +canvas/__main__.py: + ORANGE_STATISTICS_API_URL: false + https://orange.biolab.si/usage-statistics: false + def `ua_string`: + Continuum: false + conda: false + Orange{orange_version}:Python{py_version}:{platform}:{conda}: false + .: false + Anaconda: false + def `make_sql_logger`: + sql_log: false + sql.log: false + def `check_for_updates`: + startup/check-updates: false + startup/last-update-check-time: false + class `GetLatestVersion`: + def `run`: + https://orange.biolab.si/version/: false + Accept: false + text/plain: false + Accept-Encoding: false + identity: false + Connection: false + close: false + User-Agent: false + Failed to check for updates: false + def `compare_versions`: + startup/latest-skipped-version: false + Orange Update Available: Na voljo je nova različica + 'Current version: {}
      ': Trenutna različica: {}
      + 'Latest version: {}': Najnovejša različica: {}
      + Download: Namesti + Skip this Version: Preskoči to različico + canvas/icons/update.png: false + def `handle_click`: + startup/latest-skipped-version: false + https://orange.biolab.si/download/: false + def `open_link`: + orange: false + enable-statistics: false + reporting/send-statistics: false + reporting/machine-id: false + !Notification: false + def `pull_notifications`: + notifications/check-notifications: false + -1: false + notifications/displayed: false + set(): false + class `GetNotifFeed`: + def `run`: + https://orange.biolab.si/notification-feed: false + Accept: false + text/plain: false + Accept-Encoding: false + identity: false + Connection: false + close: false + User-Agent: false + Cache-Control: false + no-cache: false + Pragma: false + Failed to pull notification feed: false + def `parse_yaml_notification`: + notifications/announcements: false + notifications/blog: false + notifications/new-features: false + announcement: false + blog: false + new-features: false + installed: false + local_config: false + canvas/icons/: false + .png: false + def `remember_notification`: + notifications/displayed: false + def `send_usage_statistics`: + def `send_statistics`: + reporting/send-statistics: false + Not sending usage statistics (preferences setting).: false + Not sending usage statistics (disabled).: false + reporting/machine-id: false + Continuum: false + conda: false + Orange Version: false + Application Version: false + Anaconda: false + UUID: false + file: false + 'Error communicating with server while attempting to send ': false + usage statistics. Status code %d: false + Usage statistics sent.: false + w: false + utf-8: false + Connection error while attempting to send usage statistics.: false + Failed to send usage statistics.: false + class `SendUsageStatistics`: + def `run`: + Failed to send usage statistics.: false + class `OMain`: + Orange.canvas.config.Config: false + def `run`: + exitCleanup: false + def `argument_parser`: + --clear-widget-settings: false + store_true: false + Clear stored widget setting/defaults: false + --clear-all: false + Clear all settings and caches: false + def `_rm_tree`: + rmtree '%s': false + def `clear_widget_settings`: + Clearing widget settings: false + def `clear_caches`: + Clearing caches: false + Clearing data: false + def `clear_application_settings`: + Clearing application settings: false + clear '%s': false + def `setup_application`: + DELETE_ON_START: false + startup/launch-count: false + reporting/send-statistics: false + def `onPaletteChange`: + Setting pyqtgraph background to %s: false + background: false + Setting pyqtgraph foreground to %s: false + foreground: false + darkMode: false + def `show_splash_message`: + '#FFFFFF': false + __main__: false +canvas/config.py: + https://orange.biolab.si/addons/list: false + orange.widgets: false + startup/check-updates: false + Check for updates: false + startup/launch-count: false + reporting/machine-id: false + reporting/send-statistics: false + reporting/permission-requested: false + notifications/check-notifications: false + Check for notifications: false + notifications/announcements: false + Show notifications about Biolab announcements: false + notifications/blog: false + Show notifications about blog posts: false + notifications/new-features: false + Show notifications about new features: false + notifications/displayed: false + set(): false + Serialized set of notification IDs which have already been displayed: false + class `Config`: + biolab.si: false + Orange: false + Biolab.Orange: false + def `init`: + widget_settings_dir: false + canvas_settings_dir: false + def `application_icon`: + icons/orange-256.png: false + png: false + def `splash_screen`: + icons/orange-splash-screen-{splash_n:02}.png: false + png: false + .: false + Helvetica: false + '#000000': false + def `widgets_entry_points`: + orange3: false + def `core_packages`: + Orange3 >=3.20,<4.0a: false + def `examples_entry_points`: + 000-Orange3: false + Orange.canvas.workflows: false + orange.widgets.tutorials: false + Bug Report: false + https://github.com/biolab/orange3/issues: false + Quick Start: false + https://orange.biolab.si/getting-started/: false + Documentation: false + https://orange.biolab.si/widget-catalog/: false + Screencasts: false + https://www.youtube.com/watch: false + ?v=HXjnDIgGDuI&list=PLmNPvQr9Tf-ZSDLwOzxpvY-HrE0yv-8Fy&index=1: false + Donate: Doniraj + https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=A76TAX87ZVR3J: false + FAQ: Pogosta vprašanja + https://orangedatamining.com/faq/: false + def `init`: + This is not the init you are looking for.: false + def `data_dir`: + canvas: false + def `cache_dir`: + canvas: false + def `log_dir`: + darwin: false + ~/Library/Logs: false + log_dir: false + def `widget_settings_dir`: + "'{__name__}.widget_settings_dir' is deprecated.": false +canvas/mainwindow.py: + class `OUserSettingsDialog`: + def `__init__`: + Automatically check for updates: Samodejno preverjaj, ali je na voljo novejša različica + Updates: Nove različice + checked: false + startup/check-updates: false + Reporting: Poročanje + Settings related to reporting: Nastavitve, povezane s poročanjem + text: false + reporting/machine-id: false + Machine ID:: ID računalnika: + Share: Pošiljaj + Share anonymous usage statistics to improve Orange: Pošiljaj anonimizirano statistiko, koristno za izboljšave programa + reporting/send-statistics: false + Anonymous Statistics: Anonimizirana statistika + ': false + More info...: Več informacij... + : false + Notifications: Obvestila + Settings related to notifications: Nastavitve, povezane z obvestili + Enable notifications: Vključi obvestila + Pull and display a notification feed.: Prejemaj in kaži obvestila + notifications/check-notifications: false + On startup: Ob zagonu + notifications-group: false + Announcements: Najave + Show notifications about Biolab announcements.\n: Pokaži obvestila o naših najavah.\n + 'This entails events and courses hosted by the developers of ': 'To vključuje dogodke in tečaje, ki jih gostimo ' + Orange.: razvijalci programa Orange. + Blog posts: Objave v blogu + Show notifications about blog posts.\n: Pokaži obvestila o objavah v blogu.\n + We'll only send you the highlights.: Pošiljali bomo le izbrane objave. + New features: Nove zmožnosti + 'Show notifications about new features in Orange when a new ': 'Pokaži obvestila o novih zmožnostih programa ' + version is downloaded and installed,\n: po namestitvi novejše različice. + should the new version entail notable updates.: "" + notifications/announcements: false + notifications/blog: false + notifications/new-features: false + Show notifications about: Pokaži obvestila o + class `MainWindow`: + def `__init__`: + orange.canvas.drophandler: false + def `open_canvas_settings`: + Preferences: Nastavitve +canvas/report.py: + "'{__name__}' is deprecated and will be removed in the future.\n": false + "The contents of this package were moved to 'Orange.widgets.report'. ": false + Please update the imports accordingly.: false +canvas/run.py: + def `main`: + 'Run an orange workflow without showing a GUI and exit ': false + when it completes.\n\n: false + 'WARNING: This is experimental as Orange is not designed to run ': false + non-interactive.: false + --log-level: false + -l: false + LEVEL: false + log_level: false + --config: false + Orange.canvas.config.Config: false + file: false + basedir: false + rb: false + __main__: false +canvas/utils/environ.py: + "'{__name__}' is deprecated and will be removed on the future. ": false + Use 'Orange.misc.environ' instead: false +classification/base_classification.py: + LearnerClassification: false + ModelClassification: false + SklModelClassification: false + SklLearnerClassification: false + class `LearnerClassification`: + def `incompatibility_reason`: + Too many target variables.: Preveč ciljnih spremenljivk. + Categorical class variable expected.: Pričakujem kategorično ciljno spremenljivko. +classification/calibration.py: + ThresholdClassifier: false + ThresholdLearner: false + CalibratedLearner: false + CalibratedClassifier: false + class `ThresholdClassifier`: + def `__init__`: + ThresholdClassifier requires a binary class: ThresholdClassifier pričakuje dvojiški razred. + {base_model.name}, thresh={threshold:.2f}: false + def `__call__`: + ignore: false + class `ThresholdLearner`: + def `fit_storage`: + ThresholdLearner requires a binary class: ThresholdLearner pričakuje dvojiški razred. + class `CalibratedClassifier`: + def `__init__`: + CalibratedClassifier requires a discrete target: ThresholdClassifier pričakuje kategorično ciljno spremenljivko. + {base_model.name}, calibrated: false + def `calibrated_probs`: + ignore: false + class `CalibratedLearner`: + def `get_model`: + clip: false +classification/catgb.py: + CatGBClassifier: false +classification/gb.py: + GBClassifier: false + class `GBClassifier`: + def `__init__`: + log_loss: false + friedman_mse: false + deprecated: false +classification/knn.py: + KNNLearner: false +classification/logistic_regression.py: + LogisticRegressionLearner: false + class `LogisticRegressionLearner`: + def `__init__`: + l2: false + auto: false + def `_initialize_wrapped`: + solver: false + penalty: false + auto: false + l1: false + saga: false + lbfgs: false +classification/majority.py: + MajorityLearner: false + class `MajorityLearner`: + def `fit_storage`: + 'classification.MajorityLearner expects a domain ': false + with a (single) categorical variable: false + class `ConstantModel`: + def `__str__`: + ConstantModel {}: false +classification/naive_bayes.py: + NaiveBayesLearner: false + class `NaiveBayesLearner`: + naive bayes: false + def `fit_storage`: + Data is not a subclass of Orange.data.Storage.: false + 'Only categorical variables are ': Podprte so samo kategorične spremenljivke. + supported.: "" + Data has no defined target values.: Podatki nimajo ciljne spremenljivke. +classification/neural_network.py: + NNClassificationLearner: false + class `NNClassificationLearner`: + def `_initialize_wrapped`: + callback: false +classification/outlier_detection.py: + LocalOutlierFactorLearner: false + IsolationForestLearner: false + EllipticEnvelopeLearner: false + OneClassSVMLearner: false + class `_OutlierModel`: + def `__call__`: + Predicting...: Napovedujem... + class `_OutlierLearner`: + def `_fit_model`: + Outlier: Osamelec + Yes: Da + No: Ne + class `OneClassSVMLearner`: + One class SVM: Enorazredni SVM + def `__init__`: + rbf: false + auto: false + class `LocalOutlierFactorLearner`: + Local Outlier Factor: Lokalni faktor odstopanja + def `__init__`: + auto: false + minkowski: false + class `IsolationForestLearner`: + Isolation Forest: Izolacijski gozd + def `__init__`: + auto: false + deprecated: false + class `EllipticEnvelopeLearner`: + Covariance Estimator: Ocena kovariance + def `_fit_model`: + Mahalanobis: false +classification/random_forest.py: + RandomForestLearner: false + class `RandomForestClassifier`: + def `trees`: + def `wrap`: + {} - tree {}: {} - drevo {} + instances: false + class `RandomForestLearner`: + def `__init__`: + gini: false + sqrt: false +classification/rules.py: + CN2Learner: false + CN2UnorderedLearner: false + CN2SDLearner: false + CN2SDUnorderedLearner: false + def `argmaxrnd`: + argmaxrnd only accepts arrays of up to 2 dim: false + class `TopDownSearchStrategy`: + def `find_new_selectors`: + ==: false + !=: false + <=: false + >=: false + class `Selector`: + Selector: false + column, op, value: false + ==: false + !=: false + <=: false + >=: false + class `Rule`: + def `__str__`: + ' AND ': ' IN ' + TRUE: SICER + =: false + _: false + 'IF {} THEN {} ': 'ČE {} POTEM {} ' + class `CN2UnorderedLearner`: + CN2 unordered inducer: false + class `CN2SDLearner`: + CN2-SD inducer: false + class `CN2SDUnorderedLearner`: + CN2-SD unordered inducer: false + def `main`: + titanic: false + iris.tab: false + __main__: false +classification/scoringsheet.py: + class `ScoringSheetModel`: + def `predict_storage`: + Data is not a subclass of Orange.data.Storage.: false + class `ScoringSheetLearner`: + def `incompatibility_reason`: + Too many target variables.: Preveč ciljnih spremenljivk + Categorical class variable expected.: Pričakujem kategorično ciljno spremenljivko + Too many target variable values.: Ciljna spremenljivka ima preveč vrednosti + def `fit_storage`: + Data is not a subclass of Orange.data.Storage.: false + Class variable contains missing values.: Ciljna spremenljivka ima manjkajoče vrednosti. + def `_optimize_decision_params_adjustment`: + The number of input features is too low for the current settings.: Število vhodnih spremenljvik je prenizko za trenutne nastavitve. + __main__: false + https://datasets.biolab.si/core/heart_disease.tab: false +classification/sgd.py: + SGDClassificationLearner: false + class `SGDClassificationLearner`: + sgd: false + def `__init__`: + hinge: false + l2: false + invscaling: false +classification/simple_random_forest.py: + SimpleRandomForestLearner: false + class `SimpleRandomForestLearner`: + simple rf class: false + def `__init__`: + sqrt: false +classification/simple_tree.py: + SimpleTreeLearner: false + type: false + children_size: false + split_attr: false + split: false + children: false + dist: false + n: false + sum: false + class `SimpleTreeLearner`: + simple tree: false + class `SimpleTreeModel`: + def `__init__`: + 'Number of classes should be 1: {}': Pričakujem eno ciljno razredno spremenljivko, ne {} + 'Only Continuous and Discrete ': Podprte so samo numerične in kategorične spremenljivke + variables are supported: "" + sqrt: false + log2: false + 'skip_prob not valid: {}': false + def `predict`: + Invalid prediction type: false + def `__del__`: + node: false + def `__getstate__`: + node: false + def `dumps_tree`: + {: false + {:.5f}: false + {:.2f}: false + {:.5f} {:.5f}: false + }: false + ' ': false + def `to_string`: + (null node): false + '({self.domain.class_var.format_str}: %s)': false + ' --> ': false + '%s ': false + ' --> %s (%s)': false + \n: false + ' ': false + %s (%s): false + <=: false + >: false + ': %s': false +classification/softmax_regression.py: + SoftmaxRegressionLearner: false + class `SoftmaxRegressionLearner`: + softmax: false + def `fit`: + 'Softmax regression does not support ': 'Softmax regresija ne podpira ' + multi-label classification: več ciljnih spremenljivk. + unknown values: neznanih vrednosti. + __main__: false + iris: false +classification/svm.py: + SVMLearner: false + LinearSVMLearner: false + NuSVMLearner: false + class `SVMLearner`: + def `__init__`: + rbf: false + auto: false + class `LinearSVMLearner`: + def `__init__`: + l2: false + squared_hinge: false + ovr: false + class `NuSVMLearner`: + def `__init__`: + rbf: false + auto: false + __main__: false + iris: false + 'learner: {}\nCA: {}\n': false +classification/tree.py: + SklTreeLearner: false + TreeLearner: false + class `TreeLearner`: + def `__init__`: + binarize: false + min_samples_leaf: false + min_samples_split: false + sufficient_majority: false + max_depth: false + def `fit_storage`: + 'Exhaustive binarization does not handle ': 'Binarizacija ne zmore kategoričnih spremenljivk ' + attributes with more than {} values: z več kot {} različnimi vrednostmi. + class `SklTreeLearner`: + tree: false + def `__init__`: + gini: false + best: false +classification/xgb.py: + XGBClassifier: false + XGBRFClassifier: false + class `XGBClassifier`: + def `__init__`: + binary:logistic: false + gain: false + class `XGBRFClassifier`: + def `__init__`: + binary:logistic: false + gain: false +classification/utils/fasterrisk/base_model.py: + class `logRegModel`: + def `warm_start_from_original_beta0_betas`: + warmstart solution in normalized space is {} and {}: false +classification/utils/fasterrisk/fasterrisk.py: + class `RiskScoreOptimizer`: + def `__init__`: + input y must have 1-D shape!: false + input y must have only 2 labels: false + input y must be equal to only +1 or -1: false + input X must have 2-D shape!: false + number of rows from input X must be equal to the number of elements from input y!: false + lb: false + ub: false + group_sparsity needs to be an integer: false + group_sparsity needs to be > 0!: false + featureIndex_to_groupIndex must be provided if group_sparsity is not None: false + featureIndex_to_groupIndex needs to be a NumPy integer array: false + class `RiskScoreClassifier`: + def `_print_score_calculation_table`: + please pass the featureNames to the model by using the function .reset_featureNames(featureNames): false + {0}. {1:>%d} {2:>2} point(s) | + ...: false + The Risk Score is:: false + +: false + ' ': false + 'SCORE | = ': false + def `_print_score_risk_row`: + SCORE |: false + RISK |: false + ' {0:>4} |': false + ' {0:>5}% |': false + def `_print_score_risk_table`: + There are more than 10 nonzero coefficients for the risk scoring system. The number of possible total scores is too many!\n\nPlease consider re-initialize your RiskScoreClassifier_m by providing the training dataset features X_train as follows:\n\n RiskScoreClassifier_m = RiskScoreClassifier(multiplier, intercept, coefficients, X_train = X_train): false + closest_observation: false +classification/utils/fasterrisk/rounding.py: + class `starRaySearchModel`: + def `get_multipliers_for_line_search`: + betas needs to have at least one nonzero entries!: false +classification/utils/fasterrisk/utils.py: + def `download_file_from_google_drive`: + https://docs.google.com/uc?export=download: false + id: false + confirm: false + def `get_confirm_token`: + download_warning: false + def `save_response_content`: + wb: false + def `check_bounds`: + ub: false + {bound_name} needs to be >= 0: false + {bound_name} needs to be <= 0: false + {bound_name}s for the features need to have the same length as the number of features: false + all of {bound_name}s needs to be >= 0: false + all of {bound_name}s needs to be <= 0: false + {bound_name} needs to be a float, int, or list: false +clustering/clustering.py: + class `ClusteringModel`: + def `__call__`: + domain transformation produced no defined values: false + Unrecognized argument (instance of '{}'): false + def `predict`: + This clustering algorithm does not support predicting.: false + class `Clustering`: + def `__init__`: + self: false + preprocessors: false + __class__: false +clustering/dbscan.py: + DBSCAN: false + class `DBSCAN`: + def `__init__`: + euclidean: false + auto: false + __main__: false + iris: false +clustering/hierarchical.py: + HierarchicalClustering: false + single: false + average: false + complete: false + weighted: false + ward: false + def `condensedform`: + upper: false + lower: false + invalid mode: false + def `squareform`: + upper: false + lower: false + def `sample_clustering`: + euclidean: false + class `Tree`: + __value: false + __branches: false + __hash: false + def `__repr__`: + {0.__name__}(value={1!r}, branches={2!r}): false + {0.__name__}(...): false + _Tree__value: false + _Tree__branches: false + Cluster: false + range: false + height: false + Singleton: false + index: false + def `tree_from_linkage`: + linkage: false + def `postorder`: + branches: false + def `preorder`: + branches: false + def `leaves`: + branches: false + def `prune`: + At least one pruning argument must be supplied: false + def `optimal_leaf_ordering`: + "'progress_callback' parameter is deprecated and ignored. ": false + Passing it will raise an error in the future.: false +clustering/kmeans.py: + KMeans: false + class `KMeansModel`: + def `k`: + n_clusters: false + class `KMeans`: + def `__init__`: + k-means++: false + 'compute_silhouette_score is deprecated. Please use ': false + sklearn.metrics.silhouette_score to compute silhouettes.: false + compute_silhouette_score: false + __main__: false + iris: false +clustering/louvain.py: + Louvain: false + matrix_to_knn_graph: false + def `matrix_to_knn_graph`: + cosine: false + euclidean: false + class `LouvainMethod`: + def `__init__`: + l2: false + class `Louvain`: + def `__init__`: + l2: false + __main__: false + iris: false +data/aggregate.py: + class `OrangeTableGroupBy`: + def `_compute_aggregation`: + {col.name} - {name}: false +data/domain.py: + DomainConversion: false + Domain: false + def `filter_visible`: + hidden: false + class `Domain`: + def `__init__`: + 'descriptors must be instances of Variable, ': false + not '%s': false + All variables in the domain should have: false + ' unique names.': false + variables must be primitive: false + def `__getstate__`: + _variables: false + _indices: false + _hash: false + _eq_cache: false + def `from_numpy`: + def `get_name`: + {} {:0{}}: false + X must be a 2-dimensional array: false + Feature: false + Y has invalid shape: false + Class: false + v1: false + v2: false + Target: false + Meta: false + def `__bool__`: + Domain.__bool__ is ambiguous; use 'is None' or 'empty' instead: false + def `__str__`: + [: false + ', ': false + ' | ': false + ]: false + ' {': false + }: false + def `index`: + "'%s' is not in domain": false + def `convert`: + invalid data length for domain: false +data/filter.py: + IsDefined: false + HasClass: false + Random: false + SameValue: false + Values: false + FilterDiscrete: false + FilterContinuous: false + FilterString: false + FilterStringList: false + FilterRegex: false + class `Values`: + def `__init__`: + Filter with no conditions.: false + class `FilterContinuous`: + FilterContinuous: false + Equal, NotEqual, Less, LessEqual, Greater,: false + GreaterEqual, Between, Outside, IsDefined: false + def `__call__`: + invalid operator: false + def `__str__`: + feature({}): false + =: false + ≠: false + <: false + ≤: false + >: false + ≥: false + {} {} {}: false + {} ≤ {} ≤ {}: false + not {} ≤ {} ≤ {}: false + {} is defined: false + invalid operator: false + class `FilterString`: + FilterString: false + Equal, NotEqual, Less, LessEqual, Greater,: false + GreaterEqual, Between, Outside, Contains, NotContain,: false + StartsWith, NotStartsWith, EndsWith, NotEndsWith, IsDefined, NotIsDefined: false + def `__init__`: + min: false + FilterContinuous got unexpected keyword arguments: false + def `__call__`: + invalid operator: false +data/instance.py: + Instance: false + class `Instance`: + def `__setitem__`: + Expected primitive value, got '%s': false + def `str_values`: + ', ': false + , ...: false + def `_str`: + [: false + ' | ': false + ]: false + ' {': false + }: false + def `__hash__`: + "unhashable type: '{type(cls.__name__)}'": false + def `_check_single_class`: + Domain has no class variable: false + Domain has multiple class variables: false +data/io.py: + Flags: false + FileFormat: false + def `class_from_qualified_name`: + .: false + class `CSVReader`: + .csv: false + Comma-separated values: Vrednosti, ločene z ločilom + ',;:\t$ ': false + def `read`: + us-ascii: false + utf-8: false + ignore: false + rt: false + 'Skipped invalid byte(s) in position ': false + {}{}: false + -: false + 'Cannot parse dataset {}: {}': false + def `write_file`: + wt: false + utf-8: false + class `TabReader`: + .tab: false + .tsv: false + Tab-separated values: Vrednosti, ločene s tabulatorjem + \t: false + class `PickleReader`: + .pkl: false + .pickle: false + Pickled Orange data: Pickle s podatki + def `read`: + rb: false + file does not contain a data table: Podatki ne vsebujejo tabele + def `write_file`: + wb: false + class `BasketReader`: + .basket: false + .bsk: false + Basket file: Datoteka s košarico + def `read`: + def `constr_vars`: + utf-8: false + class `_BaseExcelReader`: + def `read`: + -: false + "Couldn't load spreadsheet from ": 'Ne morem prebrati preglednice iz ' + class `ExcelReader`: + .xlsx: false + Microsoft Excel spreadsheet: Excelova preglednica + '#VALUE!': false + '#DIV/0!': false + '#REF!': false + '#NUM!': false + '#NULL!': false + '#NAME?': false + def `workbook`: + ignore: false + .*extension is not supported and will be removed.*: false + def `write_file`: + _w: false + class `XlsReader`: + .xls: false + Microsoft Excel 97-2004 spreadsheet: Preglednica v Excelu 97-2004 + class `DotReader`: + .dot: false + .gv: false + Dot graph description: false + def `write_graph`: + wt: false + def `write`: + tree: false + class `UrlReader`: + def `__init__`: + http://: false + def `quote_byte`: + %{:02X}: false + utf-8: false + def `urlopen`: + User-Agent: false + Mozilla/5.0 (X11; Linux) Gecko/20100101 Firefox/: false + def `read`: + content-disposition: false + def `_trim_googlesheet`: + (?:https?://)?(?:www\.)?: false + docs\.google\.com/spreadsheets/d/: false + (?P[-\w_]+): false + (?:/.*?gid=(?P\d+).*|.*)?: false + workbook_id: false + sheet_id: false + https://docs.google.com/spreadsheets/d/{}/export?format=tsv: false + &gid=: false + def `_trim_googledrive`: + drive.google.com: false + /file/d/(?P[^/]+).*: false + id: false + uc?export=download&id={id_}: false + def `_trim_dropbox`: + dropbox.com: false + dl=1: false + def `_suggest_filename`: + [\\:/]: false + _: false + filename\*?=(?:\"|.{0,10}?'[^']*')([^\"]+): false +data/io_base.py: + FileFormatBase: false + Flags: false + DataTableMixin: false + PICKLE_PROTOCOL: false + class `Flags`: + ' ': false + (?: false + [{roles}{types}]|: false + ([{roles}][{types}])|: false + ([{types}][{roles}]): false + )#)?(?P.*): false + flags: false + name: false + class `_TableBuilder`: + def `__init__`: + 'Feature ': false + def `_cont_column`: + unsafe: false + 'Non-continuous value in (1-based) ': false + line {row + offset + 1}, column {col + 1}: false + class `DataTableMixin`: + def `adjust_data_width`: + F: false + Columns with no headers were removed.: false + class `_FileReader`: + def `get_reader`: + *: false + No readers for file "{}": false + def `set_table_metadata`: + .metadata: false + rb: false + utf-8: false + :: false + class `_FileWriter`: + def `write_table_metadata`: + def `write_file`: + w: false + utf-8: false + \n: false + '{}: {}': false + wb: false + .metadata: false + attributes: false + def `header_names`: + weights: false + def `header_types`: + continuous: false + def `header_flags`: + weight: false + {}={}: false + class: false + meta: false + def `write_data`: + _w: false + class `_FileFormatMeta`: + def `__new__`: + SUPPORT_COMPRESSED: false + EXTENSIONS: false + darwin: false + win32: false + def `_ext_to_attr_if_attr2`: + EXTENSIONS: false + def `names`: + DESCRIPTION: false + __class__: false + def `writers`: + write_file: false + def `readers`: + read: false + def `img_writers`: + "'{__name__}.FileFormat.img_writers' is no longer used and ": false + 'will be removed. Please use ': false + "'Orange.widgets.io.FileFormat.img_writers' instead.": false + write_image: false + def `graph_writers`: + write_graph: false + class `FileFormatBase`: + def `locate`: + .: false + *: false + File "{}" was not found.: Datoteke "{}" ne najdem. + def `qualified_name`: + .: false +data/io_util.py: + Compression: false + open_compressed: false + detect_encoding: false + isnastr: false + guess_data_type: false + sanitize_variable: false + update_origin: false + isnatstr: false + array_strptime: false + parse_datetime: false + to_datetime: false + class `Compression`: + .gz: false + .bz2: false + .xz: false + def `_is_utf8_sig`: + rb: false + def `detect_encoding`: + file: false + --brief: false + --mime-encoding: false + utf-8: false + utf-8-sig: false + us-ascii: false + iso-8859-1: false + utf-7: false + utf-16le: false + utf-16be: false + ebcdic: false + def `_from_file`: + encoding: false + confidence: false + utf-8: false + rb: false + encoding: false + def `isnastr`: + unsafe: false + def `guess_data_type`: + unsafe: false + _: false + def `sanitize_variable`: + def `get_number_of_decimals`: + .: false + def `mapvalues`: + unsafe: false + _: false + def `_extract_new_origin`: + origin: false + def `update_origin`: + origin: false + nat: false + Nat: false + NaT: false + NAT: false + def `array_strptime`: + raise: false + coerce: false + M8[us]: false + np.ndarray[np.datetime64]: false + NaT: false + Invalid 'errors' argument {errors}: false + unsafe: false + def `parse_datetime`: + raise: false + coerce: false + M8[us]: false + np.ndarray[np.datetime64]: false + Cannot guess date/time format: false + def `to_datetime`: + raise: false + coerce: false + np.ndarray[np.datetime64]: false + M8[us]: false +data/pandas_compat.py: + table_from_frame: false + table_to_frame: false + 3: false + class `OrangeDataFrame`: + orange_variables: false + orange_weights: false + orange_attributes: false + orange_role: false + def `__init__`: + orange_role: false + _o: false + csc: false + copy: false + def `__finalize__`: + concat: false + merge: false + orange_role: false + orange_variables: false + orange_weights: false + orange_attributes: false + def `_reset_index`: + _o: false + def `_convert_datetime`: + def `col_type`: + D: false + now: false + UTC: false + now: false + 1s: false + 1970-01-01: false + def `to_categorical`: + category: false + def `vars_from_df`: + orange_role: false + orange_variables: false + category: false + String variable must be in metas.: false + def `table_from_frame`: + orange_weights: false + orange_attributes: false + _o: false + def `table_from_frames`: + Indexes not equal. Make sure that all three dataframes have equal index: false + 'Leading dimension mismatch ': false + (not {xdf.shape[0]} == {ydf.shape[0]} == {mdf.shape[0]}): false + _o: false + def `table_to_frame`: + def `_column_to_series`: + s: false + def `amend_table_with_frame`: + 'Leading dimension mismatch ': false + (not {arr.shape[0]} == {df.shape[0]}): false +data/storage.py: + class `Storage`: + def `approx_len`: + table.approx_len() has been deprecated. Use len(table): false + ' instead.': false +data/table.py: + dataset_dirs: false + get_sample_datasets_dir: false + RowInstance: false + Table: false + def `get_sample_datasets_dir`: + ..: false + datasets: false + class `RowInstance`: + def `__setitem__`: + Expected primitive value, got '%s': false + def `_str`: + def `sp_values`: + %s=%s: false + ', ': false + , ...: false + [: false + ' | ': false + ]: false + ' {': false + }: false + class `Columns`: + def `__init__`: + ' ': false + _: false + def `_compute_column`: + {type(col)} must return a column, not {col.ndim}d array: false + class `_ArrayConversion`: + def `_can_copy_all`: + X: false + metas: false + Y: false + def `get_subarray`: + X: false + metas: false + Y: false + def `init_partial_results`: + F: false + class `_FromTableConversion`: + def `__init__`: + X: false + Y: false + metas: false + def `convert`: + X: false + Y: false + metas: false + class `Table`: + untitled: nepoimenovano + def `_check_unlocked`: + Table is read-only unless unlocked: false + def `__setstate__`: + X: false + W: false + metas: false + _Y: false + Y: false + def `__getstate__`: + X: false + metas: false + W: false + _: false + _Y: false + _unlocked: false + def `_lock_parts_val`: + X: false + Y: false + metas: false + weights: false + def `_lock_parts_ref`: + X: false + Y: false + metas: false + weights: false + def `_update_locks`: + Unsupported sparse data type: false + def `unlocked`: + "'{name}' is a view into another table ": false + and cannot be unlocked: false + def `__new__`: + def `warn_deprecated`: + "Direct calls to Table's constructor are deprecated ": false + 'and will be removed. Replace this call with ': false + Table.{method}: false + Table() must not be called directly: false + 'Table(name: str) expects just one argument': false + https://: false + http://: false + 'Table(table: Table) expects just one argument': false + from_domain: false + from_table: false + from_list: false + 'Omitting domain in a call to Table(X, Y, metas), is ': false + 'deprecated and will be removed. ': false + Call Table.from_numpy(None, X, Y, metas) instead.: false + def `from_table`: + name: false + attributes: false + def `from_table_rows`: + name: false + attributes: false + def `from_numpy`: + float64: false + Invalid number of variable columns ({} != {}): false + 'Invalid number of class columns ': false + (1 != {len(domain.class_vars)}): false + Invalid number of class columns ({} != {}): false + Invalid number of meta attribute columns ({} != {}): false + Parts of data contain different numbers of rows.: false + def `from_list`: + mismatching number of instances and weights: false + def `save`: + Writing of {}s is not supported: false + Unknown file name extension.: false + def `_set_row`: + invalid length: false + def `__getitem__`: + Table indices must be one- or two-dimensional: false + def `__setitem__`: + Table indices must be one- or two-dimensional: false + 'Setting multiple values requires a ': false + sequence or numpy array: false + Invalid number of values: false + Ordinary attributes can only have primitive values: false + def `__str__`: + [: false + ',\n ': false + ]: false + def `__repr__`: + [: false + ',\n ': false + ,\n ...: false + \n]: false + def `concatenate`: + invalid axis: false + "'ignore_domains' is incompatible with 'axis=1'": false + need at least one table to concatenate: false + attributes: false + untitled: nepoimenovano + def `_concatenate_vertical`: + concatenated tables must have the same domain: false + X: false + Y: false + metas: false + W: false + def `_concatenate_horizontal`: + domain: false + W: false + X: false + Y: false + metas: false + attributes: false + class_vars: false + def `X_density`: + _X_density: false + def `Y_density`: + _Y_density: false + def `metas_density`: + _metas_density: false + def `shuffle`: + Rows of sparse data cannot be shuffled: false + def `get_column_view`: + Table.get_column (or Table.set_column if you must): false + 'get_column_view is returning a dense copy column ': false + {index}: false + 'get_column_view is returning a mapped copy of ': false + column {index.name}: false + def `get_column`: + variable {index.name} is not in domain: false + def `set_column`: + 'cannot set data for variable {index.name} ': false + with different encoding: false + def `_filter_to_indicator`: + def `get_col_indices`: + Discrete filter can't be applied across rows: false + Invalid filter: false + def `col_filter`: + Invalid filter: false + def `_range_filter_to_indicator`: + ignore: false + Invalid operator: false + def `_compute_contingency`: + No row variable: false + Row variable must be discrete: false + 'Contingency can be computed only for categorical ': false + and numeric values.: false + f: false + def `transpose`: + Feature name: Ime spremenljivke + Feature: false + Transposing...: false + old_domain: false + {feature_name} {i:0{places}}: false + def `groupby`: + OrangeTableGroupBy: false + def `_check_arrays`: + def `ninstances`: + shape: false + Leading dimension mismatch (%d != %d): false + Array contains infinity.: false + def `_check_inf`: + AllFloat: false + def `_optimize_indices`: + boolean indices did not match dimension: false +data/util.py: + (^{})( \((\d{{1,}})\))?$: false + def `one_hot`: + dim must be greater than max(values): false + class `SharedComputeValue`: + def `__init__`: + InheritEq: false + '{type(compute_shared).__name__} should define ': false + __eq__ and __hash__ to be used for compute_shared: false + def `get_unique_names`: + {name} ({indices[name]}): false + {name} ({max_index}): false + def `get_unique_names_duplicates`: + {name} ({next(indices[name])}): false + def `sanitized_name`: + \W: false + _: false + def `redefines_eq_and_hash`: + __hash__: false + __eq__: false +data/variable.py: + Unknown: false + MISSING_VALUES: false + make_variable: false + is_discrete_values: false + Value: false + Variable: false + ContinuousVariable: false + DiscreteVariable: false + StringVariable: false + TimeVariable: false + nan: false + ?: false + .: false + NA: false + ~: false + class `Value`: + variable: false + _value: false + def `_as_values_primitive`: + Value: false + def `_as_values_non_primitive`: + Value: false + def `__repr__`: + Value('%s', %s): false + def `__contains__`: + invalid operation on Value(): false + def `__hash__`: + unhashable type - cannot hash values of discrete variables!: false + def `__getstate__`: + _value: false + def `__setstate__`: + value: false + class `Variable`: + def `__init__`: + Variable must have a name: false + InheritEq: false + '{type(compute_value).__name__} should define ': false + __eq__ and __hash__ to be used for compute_value\n: false + or set InheritEq = True if inherited methods suffice: false + def `_clear_cache`: + _clear_cache is no longer needed and thus deprecated: false + def `_clear_all_caches`: + _clear_all_caches is no longer needed and thus deprecated: false + def `repr_val`: + variable descriptors must overload repr_val(): false + def `to_val`: + primitive variable descriptors must overload to_val(): false + def `__reduce__`: + Variables without names cannot be pickled: false + class `ContinuousVariable`: + continuous: false + c: false + numeric: false + n: false + def `number_of_decimals`: + %g: false + %.{}f: false + def `repr_val`: + ?: false + %g: false + {val:.{self._number_of_decimals + 2}f}: false + def `copy`: + number_of_decimals: false + class `DiscreteVariable`: + discrete: false + d: false + categorical: false + def `__init__`: + values of DiscreteVariables must be strings: false + Duplicate values in DiscreteVariable: false + def `get_mapper_from`: + def `mapper`: + In-place mapping of sparse matrices must map 0 to 0: false + In-place column mapping requires a 2d array or: false + a csc or csr matrix.: false + Column mapping can't map {value.ndim}-d objects: false + "Column mapping can't map ": false + {value.ndim}-dimensional objects: false + 'invalid type for value(s): {type(value).__name__}': false + def `to_val`: + Cannot convert {} to value of "{}": false + Value {s} does not exist: false + def `add_value`: + values of DiscreteVariables must be strings: false + def `repr_val`: + ?: false + {}: false + def `__reduce__`: + Variables without names cannot be pickled: false + _values: false + def `copy`: + number of values must match the number of original values: false + class `StringVariable`: + string: false + s: false + text: false + def `str_val`: + ?: false + def `repr_val`: + '"{}"': false + class `TimeVariable`: + time: false + t: false + %Y-%m-%d %H:%M:%S%z: false + %Y-%m-%d %H:%M:%S: false + %Y-%m-%d %H:%M: false + %Y-%m-%dT%H:%M:%S%z: false + %Y-%m-%dT%H:%M:%S: false + %Y-%m-%d: false + %Y-%m-%d %H:%M:%S.%f: false + %Y-%m-%dT%H:%M:%S.%f: false + %Y-%m-%d %H:%M:%S.%f%z: false + %Y-%m-%dT%H:%M:%S.%f%z: false + %Y%m%dT%H%M%S%z: false + %Y%m%d%H%M%S%z: false + %H:%M:%S.%f: false + %H:%M:%S: false + %H:%M: false + %Y%m%dT%H%M%S: false + %Y%m%d%H%M%S: false + %Y%m%d: false + %Y%j: false + %Y: false + %H%M%S.%f: false + %Y-%m: false + %Y-%j: false + ^(: false + \d{1,4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?([+-]\d{4})?)?)?|: false + \d{1,4}\d{2}\d{2}(T?\d{2}\d{2}\d{2}([+-]\d{4})?)?|: false + \d{2}:\d{2}(:\d{2}(\.\d+)?)?|: false + \d{2}\d{2}\d{2}\.\d+|: false + \d{1,4}(-?\d{2,3})?: false + )$: false + 2021-11-25: false + 25.11.2021: false + %d.%m.%Y: false + %d. %m. %Y: false + 25.11.21: false + %d.%m.%y: false + %d. %m. %y: false + 11/25/2021: false + %m/%d/%Y: false + 11/25/21: false + %m/%d/%y: false + 20211125: false + 2021-11-25 00:00:00: false + %Y-%m-%d %H:%M%z: false + 25.11.2021 00:00:00: false + %d.%m.%Y %H:%M: false + %d. %m. %Y %H:%M: false + %d.%m.%Y %H:%M:%S: false + %d. %m. %Y %H:%M:%S: false + %d.%m.%Y %H:%M:%S.%f: false + %d. %m. %Y %H:%M:%S.%f: false + 25.11.21 00:00:00: false + %d.%m.%y %H:%M: false + %d. %m. %y %H:%M: false + %d.%m.%y %H:%M:%S: false + %d. %m. %y %H:%M:%S: false + %d.%m.%y %H:%M:%S.%f: false + %d. %m. %y %H:%M:%S.%f: false + 11/25/2021 00:00:00: false + %m/%d/%Y %H:%M: false + %m/%d/%Y %H:%M:%S: false + %m/%d/%Y %H:%M:%S.%f: false + 11/25/21 00:00:00: false + %m/%d/%y %H:%M: false + %m/%d/%y %H:%M:%S: false + %m/%d/%y %H:%M:%S.%f: false + 20211125000000: false + %Y%m%d%H%M: false + %Y%m%d%H%M%S.%f: false + 00:00:00: false + 000000: false + %H%M: false + %H%M%S: false + 2021: false + 11-25: false + %m-%d: false + 25.11.: false + %d.%m.: false + %d. %m.: false + 11/25: false + %m/%d: false + 1125: false + %m%d: false + class `InvalidDateTimeFormatError`: + def `__init__`: + Invalid datetime format '{date_string}'. Only ISO 8601 supported.: Napačna oblika datuma oz. ure '{date_string}'. Podprt je samo standard ISO 8601. + def `timezone`: + different timezones: false + def `_tzre_sub`: + ([+-])(\d\d):(\d\d)$: false + +00:00: false + -00:00: false + \1\2\3: false + def `repr_val`: + ?: false + def `parse`: + Z: false +data/sql/filter.py: + class `IsDefinedSql`: + def `to_sql`: + ' AND ': false + %s IS NOT NULL: false + NOT (%s): false + class `SameValueSql`: + def `to_sql`: + %s IS NULL: false + %s = %s: false + NOT (%s): false + (NOT (%s) OR %s is NULL): false + class `ValuesSql`: + def `to_sql`: + ' AND ': false + ' OR ': false + NOT (%s): false + ({}): false + class `FilterDiscreteSql`: + def `to_sql`: + %s IN (%s): false + ,: false + %s IS NOT NULL: false + class `FilterContinuousSql`: + def `to_sql`: + %s = %s: false + %s <> %s OR %s IS NULL: false + %s < %s: false + %s <= %s: false + %s > %s: false + %s >= %s: false + %s >= %s AND %s <= %s: false + (%s < %s OR %s > %s): false + %s IS NOT NULL: false + Invalid operator: false + class `FilterString`: + def `to_sql`: + %s IS NOT NULL: false + LOWER(%s): false + %s = %s: false + %s <> %s OR %s IS NULL: false + %s < %s: false + %s <= %s: false + %s > %s: false + %s >= %s: false + %s >= %s AND %s <= %s: false + (%s < %s OR %s > %s): false + %s LIKE '%%%s%%': false + %s LIKE '%s%%': false + %s LIKE '%%%s': false + Invalid operator: false + class `FilterStringList`: + def `to_sql`: + LOWER(%s) in (%s): false + %s in (%s): false + ', ': false + def `quote`: + "'%s'": false + class `CustomFilterSql`: + def `to_sql`: + (: false + ): false + NOT (: false +data/sql/table.py: + sql_log: false + 'Logging started: {}': false + %Y-%m-%d %H:%M:%S: false + class `SqlTable`: + def `__init__`: + No backend could connect to server: false + select: false + (%s) as my_table: false + '; ': false + def `connection_params`: + Use backend.connection_params: false + def `__getitem__`: + Table indices must be one- or two-dimensional: false + Row indices must be integers.: false + def `_fetch_row`: + 'Could not retrieve row {row_index} ': false + from table {self.name}: false + def `_query`: + to_sql: false + Cannot use ordinary attributes with sql backend: false + (%s) AS "%s": false + No fields selected.: false + *: false + def `__bool__`: + 1: false + def `_count_rows`: + COUNT(*): false + def `approx_len`: + table.approx_len() has been deprecated. Use len(table): false + ' instead.': false + def `download_data`: + Too many rows to download the data into memory.: false + def `_get_distributions`: + COUNT(%s): false + %s IS NOT NULL: false + def `_compute_contingency`: + 'Contingency for multiple columns ': false + has not yet been implemented.: false + Defaults have not been implemented yet: false + Row variable must be discrete: false + 'contingency can be computed only for discrete ': false + and continuous values: false + COUNT(%s): false + %s IS NOT NULL: false + def `_filter_same_value`: + "'%s'": false + def `_filter_values`: + "'%s'": false + Invalid condition %s: false + 'SUM(CASE TRUE WHEN %(field_name)s IS NULL THEN 1 ': false + 'ELSE 0 END), ': false + 'SUM(CASE TRUE WHEN %(field_name)s IS NULL THEN 0 ': false + ELSE 1 END): false + 'MIN(%(field_name)s)::double precision, ': false + 'MAX(%(field_name)s)::double precision, ': false + 'AVG(%(field_name)s)::double precision, ': false + 'STDDEV(%(field_name)s)::double precision, ': false + def `sample_percentage`: + system: false + def `sample_time`: + system_time: false + def `_sample`: + ,: false + Sampling of complex queries is not supported: false + .: false + __%s_%s_%s: false + _: false + -: false + 'SELECT * FROM ': false + ' LIMIT 0;': false + 'DROP TABLE ': false + ' ': false + CREATE TABLE: false + AS: false + SELECT * FROM: false + TABLESAMPLE: false + (: false + ): false + def `_execute_sql_query`: + Use backend.execute_sql_query: false + def `__get_nan_frequency`: + ' + ': false + COUNT(*) - COUNT({col.to_sql()}): false +data/sql/backend/base.py: + class `Backend`: + def `list_tables`: + {}.{}: false + def `get_fields`: + *: false +data/sql/backend/mssql.py: + class `PymssqlBackend`: + SQL Server: false + def `__init__`: + server: false + host: false + Incorrect format of connection details: false + def `list_tables_query`: + " + SELECT [TABLE_SCHEMA], [TABLE_NAME] + FROM information_schema.tables + WHERE TABLE_TYPE in ('VIEW' ,'BASE TABLE') + ORDER BY [TABLE_NAME] + ": false + def `n_tables_query`: + SELECT COUNT(*) FROM information_schema.tables: false + def `quote_identifier`: + [{}]: false + def `create_sql_query`: + SELECT: false + TOP: false + ', ': false + FROM: false + TABLESAMPLE system_time(%i): false + WHERE: false + ' AND ': false + GROUP BY: false + AS: false + ORDER BY: false + ,: false + OFFSET: false + ROWS: false + FETCH FIRST: false + ROWS ONLY: false + ' ': false + def `create_variable`: + DATEDIFF(s, '1970-01-01 00:00:00', {}): false + StatementEstRows="(\d+)": false + def `distinct_values_query`: + {field}, Cast({field} as binary), DATALENGTH({field}): false +data/sql/backend/postgres.py: + tsm_system_time: false + quantile: false + class `Psycopg2Backend`: + PostgreSQL: false + def `_create_extensions`: + CREATE EXTENSION IF NOT EXISTS {}: false + Database is missing extension {}: false + def `create_sql_query`: + SELECT: false + ', ': false + FROM: false + TABLESAMPLE system_time(%i): false + WHERE: false + ' AND ': false + GROUP BY: false + ORDER BY: false + ,: false + OFFSET: false + LIMIT: false + ' ': false + def `execute_sql_query`: + utf-8: false + 'Executing: %s': false + '%.2f ms: %s': false + def `quote_identifier`: + '"%s"': false + def `unquote_identifier`: + '"': false + def `list_tables_query`: + AND n.nspname = '{}': false + AND pg_catalog.pg_table_is_visible(c.oid): false + "SELECT n.nspname as ""Schema"", + c.relname AS ""Name"" + FROM pg_catalog.pg_class c + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r','v','m','S','f','') + AND n.nspname <> 'pg_catalog' + AND n.nspname <> 'information_schema' + AND n.nspname !~ '^pg_toast' + {} + AND NOT c.relname LIKE '\\_\\_%' + ORDER BY 1,2;": false + def `n_tables_query`: + SELECT COUNT(*) FROM information_schema.tables: false + " WHERE table_schema = '{schema}'": false + def `create_variable`: + extract(epoch from {}): false + ({})::double precision: false + false: false + true: false + '((CASE WHEN {field_name_q} ': false + THEN 'true' ELSE 'false' END)): false + ({})::text: false + def `_guess_variable`: + false: false + true: false + def `__getstate__`: + connection_pool: false +datasets/list_update.py: + iris_url: false + https://raw.githubusercontent.com/biolab/orange3/master/Orange/datasets/iris.tab: false + def `data_info`: + name: false + location: false + rows: false + features: false + discrete: false + continuous: false + meta: false + missing: false + target: false + type: false + values: false + __main__: false + .: false + .tab: false + datasets.info: false + w: false +distance/base.py: + class `Distance`: + def `__new__`: + {cls.__name__} does not compute similarity: false + domain: false + is_sparse: false + fallback: false + cosine: false + def `check_no_discrete`: + columns with discrete values are incommensurable: stolpci kategoričnih vrednosti niso primerljivi + class `DistanceModel`: + def `__call__`: + Two tables cannot be compared by columns: Razdalj med dvema tabelama ni možno računati po stolpcih. + ignore: false + class `FittedDistanceModel`: + def `__call__`: + mismatching domains: false + class `FittedDistance`: + def `fit`: + domain: false + def `fit_rows`: + normalize: false +distance/distance.py: + class `EuclideanRowsModel`: + def `compute_distances`: + ignore: false + class `EuclideanColumnsModel`: + def `compute_distances`: + ignore: false + class `Euclidean`: + euclidean: false + def `fit_cols`: + def `nowarn`: + Mean of empty slice: false + Degrees of freedom <= 0 for slice: false + some columns have no defined values: nekateri stolpci nimajo znanih vrednosti + warnings.warn: false + some columns are constant: nekateri stolpci so konstantni + class `Manhattan`: + manhattan: false + def `fit_cols`: + 'some columns have zero absolute distance from median, ': 'nekateri stolpci so konstantni ' + or no values: ali pa nimajo nobene znane vrednosti + class `Cosine`: + cosine: false + def `_corrcoef2`: + Invalid axis {} (only 0 or 1 accepted): false + def `check_non_negative`: + Bhattcharyya distance requires non-negative values: false + class `Mahalanobis`: + def `fit`: + Covariance matrix is too large.: Kovariančna matrika je prevelika. + Computation of inverse covariance matrix failed.: Izračun inverza kovariančne matrike ni uspel. + class `MahalanobisModel`: + def `compute_distances`: + Incorrect number of features.: false + mahalanobis: false + class `MahalanobisDistance`: + def `__new__`: + Mahalanobis: false + class `HammingColumnsModel`: + def `compute_distances`: + hamming: false + class `HammingRowsModel`: + def `compute_distances`: + hamming: false +ensembles/ada_boost.py: + SklAdaBoostClassificationLearner: false + SklAdaBoostRegressionLearner: false + class `SklAdaBoostClassificationLearner`: + def `__init__`: + deprecated: false + `algorithm` is deprecated and has no effect (to be removed in 3.42).: false + class `SklAdaBoostRegressionLearner`: + def `__init__`: + linear: false +ensembles/stack.py: + StackedLearner: false + StackedClassificationLearner: false + StackedRegressionLearner: false + StackedFitter: false + class `StackedLearner`: + def `fit_storage`: + f{}: false + class `StackedFitter`: + classification: false + regression: false + def `__init__`: + learners: false + __main__: false + iris: false + housing: false +evaluation/clustering.py: + ClusteringEvaluation: false + class `ClusteringResults`: + def `get_fold`: + This 'Results' instance does not have folds.: false + class `ClusteringEvaluation`: + def `__call__`: + Y: false + def `graph_silhouette`: + g: false + b: false + Number of colors does not match the number of clusters. \n: false + Silhouette score: false + Cluster label: false +evaluation/performance_curves.py: + class `Curves`: + def `from_results`: + "Argument 'model_index' is required when ": false + there are multiple models: false + "Argument 'target_class' is required when the ": false + class is not binary: false +evaluation/scoring.py: + CA: false + Precision: false + Recall: false + F1: false + PrecisionRecallFSupport: false + AUC: false + MSE: false + RMSE: false + MAE: false + MAPE: false + SMAPE: false + R2: false + LogLoss: false + MatthewsCorrCoefficient: false + class `ScoreMetaType`: + def `__new__`: + registry: false + abstract: false + name: false + long_name: false + class `Score`: + def `from_predicted`: + def `as_scalar`: + len(e) > 1: false + class `CA`: + CA: Točnost + Classification accuracy: Klasifikacijska točnost + class `TargetScore`: + def `compute_score`: + binary: false + 'Multiclass data: specify target class or select ': false + averaging ('weighted', 'macro', 'micro'): false + class `Precision`: + Prec: Natančnost + Precision: Natančnost + class `Recall`: + Recall: Priklic + class `F1`: + F1: F1 + class `AUC`: + AUC: AUC + Area under ROC curve: Površina pod krivuljo ROC + def `calculate_weights`: + Class variable has less than two values: Ciljna spremenljivka ima manj kot dve vrednosti + def `compute_score`: + Class variable has less than two values: Ciljna spremenljivka ima manj kot dve vrednosti + class `LogLoss`: + LogLoss: Log Izguba + Logistic loss: Logistična izguba + def `compute_score`: + auto: False + '`LogLoss.compute_score`: eps parameter is unused. ': False + It will always have value of `np.finfo(y_pred.dtype).eps`.: False + class `Specificity`: + Spec: true + Specificity: Specifičnost + def `compute_score`: + binary: false + weighted: false + 'Binary averaging needs two classes in data: ': 'Binarno uteževanje zahteva dva razreda: ' + 'specify target class or use ': 'določite ciljni razred ali pa uporabite ' + weighted averaging.: uteženo povprečenje. + 'Wrong parameters: For averaging select one of the ': false + "following values: ('weighted', 'binary')": false + class `MatthewsCorrCoefficient`: + MCC: true + Matthews correlation coefficient: Matthewov koeficient korelacije + class `MSE`: + MSE: true + Mean square error: Povprečne kvadratna napaka + class `RMSE`: + RMSE: true + Root mean square error: Koren povprečne kvadratne napake + class `MAE`: + MAE: true + Mean absolute error: Povprečna absolutna napaka + class `MAPE`: + MAPE: true + Mean absolute percentage error: Povprečna absolutna odstotna napaka + class `SMAPE`: + sMAPE: true + Symmetric mean absolute percentage error: Simetrična povprečna absolutna odstotna napaka + class `R2`: + R2: true + # Je to OK? + Coefficient of determination: Koeficient determiniranosti + class `CVRMSE`: + CVRMSE: true + Coefficient of variation of the RMSE: Koeficient variacije RMSE + def `compute_score`: + Mean value is too small: Povprečna vrednost je premajhna +evaluation/testing.py: + Results: false + CrossValidation: false + LeaveOneOut: false + TestOnTrainingData: false + ShuffleSplit: false + TestOnTestData: false + sample: false + CrossValidationFeature: false + _MpResults: false + fold_i: false + learner_i: false + model: false + failed: false + n_values: false + values: false + probs: false + train_time: false + test_time: false + def `_mp_worker`: + Test fold is empty: false + class `Results`: + def `__init__`: + mismatching domain: false + mismatching number of rows: false + regression results cannot have non-None 'nclasses': false + regression results cannot have 'probabilities': false + mismatching number of class values: false + mismatching number of methods: false + def `get_fold`: + This 'Results' instance does not have folds.: false + def `get_augmented_data`: + {name} ({value}): false + Fold: false + def `split_by_model`: + probabilities: false + class `Validation`: + def `__new__`: + learners and train_data must both be present or not: false + 'preprocessor cannot be given if learners ': false + and train_data are omitted: false + 'callback cannot be given if learners ': false + "calling Validation's constructor with data and learners ": false + is deprecated;\nconstruct an instance and call it: false + test_data: false + def `fit`: + Validation.fit is deprecated; use the call operator: false + def `_collect_part_results`: + Multiple targets are not supported.: Ocenjevanje modelov z več razredi ni podprto. + class `CrossValidation`: + def `get_indices`: + Using non-stratified sampling.: false + class `CrossValidationFeature`: + def `get_indices`: + "'{self.feature.name}' does not have at least two distinct ": false + values on the data: false + class `TestOnTestData`: + def `__new__`: + train_data: false + argument 'data' is given twice (once as 'train_data'): false + class `TestOnTrainingData`: + def `__call__`: + test_data: false +misc/__init__.py: + def `import_late_warning`: + class `Warn`: + def `__getattr__`: + Install package ': false + "' to use this functionality.": false +misc/_distmatrix_xlsx.py: + def `_get_sheet`: + 'No such sheet: {sheet_name}': Zavihek {sheet_name} ne obstaja. + def `_non_empty_cells`: + def `raise_empty`: + empty sheet: prazen zavihek + def `_get_labels`: + ?: false + def `_matrix_from_cells`: + 'invalid data in cell ': 'neveljaven podatek v celici ' + {openpyxl.utils.get_column_letter(x + col_offset + 1)}: false + {y + row_offset + 1}: false + def `write_matrix`: + DistMatrix: false +misc/collections.py: + class `frozendict`: + def `clear`: + FrozenDict does not support method 'clear': false + def `pop`: + FrozenDict does not support method 'pop': false + def `popitem`: + FrozenDict does not support method 'popitem': false + def `setdefault`: + FrozenDict does not support method 'setdefault': false + def `update`: + FrozenDict does not support method 'update': false + def `__setitem__`: + FrozenDict does not allow setting elements: false + def `__delitem__`: + FrozenDict does not allow deleting elements: false + def `natural_sorted`: + def `natural_keys`: + (\d+): false + class `DictMissingConst`: + __missing: false + def `__reduce_ex__`: + __dict__: false + def `__repr__`: + {type(self).__qualname__}({self.missing!r}, {dict(self)!r}): false +misc/datasets.py: + class `_DatasetInfo`: + def `__init__`: + ../datasets: false + datasets.info: false + r: false +misc/distmatrix.py: + class `DistMatrix`: + def `__array_finalize__`: + row_items: false + col_items: false + axis: false + def `from_file`: + .xlsx: false + def `_labels_to_tables`: + label: false + def `_from_dst`: + empty file: prazna datoteka + distance file must begin with dimension: datoteka se mora začeti z dimenzijo matrike + labelled: false + labeled: false + row_labels: false + col_labels: false + symmetric: false + asymmetric: false + =: false + axis: false + invalid flag '{flag}': false + \t: false + 'mismatching number of column labels, ': 'napačno število oznak stolpcev, ' + {len(col_labels)} != {n}: false + def `num_or_lab`: + "'{labels[n]}'": false + too many rows: preveč vrstic + 'too many columns in matrix row ': 'preveč stolpcev v vrstici ' + {num_or_lab(i, row_labels)}: false + 'invalid element at ': 'napačna vrednost v ' + 'row {num_or_lab(i, row_labels)}, ': 'vrstici {num_or_lab(i, row_labels)}, ' + column {num_or_lab(j, col_labels)}: stolpcu {num_or_lab(j, col_labels)} + def `save`: + .xlsx: false + def `_save_dst`: + {n}\taxis={self.axis}: false + \tcol_labels: false + \trow_labels: false + \tasymmetric: false + wt: false + utf-8: false + \n: false + \t: false +misc/environ.py: + def `_get_parsed_config`: + .: false + data: false + home: false + ~/: false + prefix: false + name: false + Orange: false + version: false + version.major: false + version.minor: false + version.micro: false + etc/orangerc.conf: false + utf-8: false + paths: false + def `get_path`: + paths: false + def `_default_data_dir_base`: + darwin: false + ~/Library/Application Support: false + win32: false + APPDATA: false + ~/AppData/Local: false + posix: false + XDG_DATA_HOME: false + ~/.local/share: false + def `data_dir_base`: + data_dir_base: false + def `data_dir`: + Orange: false + def `widget_settings_dir`: + "'{__name__}.widget_settings_dir' is deprecated.": false + def `_default_cache_dir`: + darwin: false + ~/Library/Caches: false + win32: false + APPDATA: false + ~/AppData/Local: false + posix: false + XDG_CACHE_HOME: false + ~/.cache: false + Orange: false + Cache: false + def `cache_dir`: + cache_dir: false +misc/lazy_module.py: + class `_LazyModule`: + def `_do_import`: + Orange.: false + Orange: false +misc/server_embedder.py: + TaskItem: false + id: false + item: false + no_repeats: false + class `ServerEmbedderCommunicator`: + def `__init__`: + ORANGE_EMBEDDING_API_URL: false + error-reporting/machine-id: false + def `_init_workers`: + Created %d workers: false + def `_cancel_workers`: + Canceling workers: false + All workers canceled: false + def `_send_to_server`: + Embedding %s: false + 'Sending to the server: %s': false + /{self.embedder_type}/{self._model}?machine={self.machine_id}: false + &session={self.session_id}&retry={num_repeats+1}: false + 'Successfully embedded: %s': false + 'Embedding unsuccessful - reading to queue: %s': false + def `_send_request`: + Content-Type: false + Content-Length: false + Read timeout: false + Network error: false + Embedding error: false + def `_parse_response`: + utf-8: false + embedding: false +misc/wrapper_meta.py: + class `WrapperMeta`: + def `__new__`: + __wraps__: false + __wrapped__: false + ' +A wrapper for `${sklname}`. The following is its documentation: + +${skldoc} + ': false + {}.{}: false + Attributes\n---------: false + Examples\n--------: false + Parameters\n---------: false + ${sklname}: false + ${skldoc}: false +misc/tests/example_embedder.py: + class `ExampleServerEmbedder`: + def `__init__`: + image/jpeg: false + def `_encode_data_instance`: + big: false +misc/utils/embedder_utils.py: + class `EmbedderCache`: + {:s}_embeddings.pickle: false + def `save_pickle`: + wb: false + Can't save embedding to %s due to %s.: false + def `load_pickle`: + rb: false + Can't load embedding from %s due to %s.: false + def `get_proxies`: + def `add_scheme`: + ://: false + http://{url}: false + http_proxy: false + https_proxy: false + http://: false + https://: false +modelling/ada_boost.py: + SklAdaBoostLearner: false + class `SklAdaBoostLearner`: + classification: false + regression: false +modelling/base.py: + class `Fitter`: + classification: false + regression: false + def `__init__`: + preprocessors: false + def `get_learner`: + No learner to handle '{}': false + def `supports_weights`: + supports_weights: false + def `params`: + 'A fitter does not have its own params. If you need to access ': false + learner params, please use the `get_params` method.: false +modelling/catgb.py: + CatGBLearner: false + class `CatGBLearner`: + Gradient Boosting (catboost): true + classification: false + regression: false +modelling/column.py: + ColumnLearner: false + ColumnModel: false + def `_check_column_combinations`: + Regression can only be used with numeric variables: false + Numeric columns can only be used with binary class variables: false + Column contains values that are not in class variable: false + 'Intercept and coefficient are only allowed for continuous ': false + variables: false + class `ColumnLearner`: + def `__init__`: + column '{column.name}': stolpec `{column.name}` + def `fit_storage`: + Class variable does not match the data: false + class `ColumnModel`: + def `__init__`: + Intercept and coefficient must both be provided or absent: false + ' ({intercept}, {coefficient})': false + column '{column.name}'{pars}: stolpec '{column.name}'{pars} + def `_predict_discrete`: + 'Column values must be in [0, 1] range ': false + unless logistic function is applied: false + def `__str__`: + ' ({self.intercept}, {self.coefficient})': false + ColumnModel {self.column.name}{pars}: false +modelling/constant.py: + ConstantLearner: false + class `ConstantLearner`: + classification: false + regression: false +modelling/gb.py: + GBLearner: false + class `GBLearner`: + Gradient Boosting (scikit-learn): true + classification: false + regression: false +modelling/knn.py: + KNNLearner: false + class `KNNLearner`: + classification: false + regression: false +modelling/linear.py: + SGDLearner: false + class `SGDLearner`: + sgd: false + classification: false + regression: false + def `_change_kwargs`: + classification: false + regression: false + {pref}_{attr}: false + loss: false + epsilon: false +modelling/neural_network.py: + NNLearner: false + class `NNLearner`: + classification: false + regression: false +modelling/randomforest.py: + RandomForestLearner: false + class `RandomForestLearner`: + random forest: false + classification: false + regression: false + def `fitted_parameters`: + n_estimators: false + Number of trees: Število dreves +modelling/svm.py: + SVMLearner: false + LinearSVMLearner: false + NuSVMLearner: false + class `SVMLearner`: + classification: false + regression: false + class `LinearSVMLearner`: + classification: false + regression: false + class `NuSVMLearner`: + classification: false + regression: false +modelling/tree.py: + SklTreeLearner: false + TreeLearner: false + class `SklTreeLearner`: + tree: drevo + classification: false + regression: false + class `TreeLearner`: + tree: drevo + classification: false + regression: false +modelling/xgb.py: + XGBLearner: false + XGBRFLearner: false + class `XGBLearner`: + Extreme Gradient Boosting (xgboost): true + classification: false + regression: false + class `XGBRFLearner`: + Extreme Gradient Boosting Random Forest (xgboost): true + classification: false + regression: false +preprocess/continuize.py: + DomainContinuizer: false + class `DomainContinuizer`: + def `__call__`: + def `transform_discrete`: + {}={}: false + data has multinomial attributes: false + continuizer requires data: false +preprocess/discretize.py: + EqualFreq: false + EqualWidth: false + EntropyMDL: false + DomainDiscretizer: false + decimal_binnings: false + time_binnings: false + short_time_units: false + BinDefinition: false + class `Discretizer`: + def `_fmt_interval`: + def `strip0`: + ^\d+\.\d+: false + 0: false + .: false + 'Formatter returned identical thresholds: {lows}': false + < {highs}: true + ≥ {lows}: true + {lows} - {highs}: true + def `_get_discretized_values`: + single_value: konstanta + Some interval thresholds are identical: false + def `fmt_fixed`: + {val:.{digits}f}: false + class `BinSql`: + def `__call__`: + 'width_bucket({self.var.to_sql()}, ': false + ARRAY{str(self.points)}::double precision[]): false + class `SingleValueSql`: + def `__call__`: + "'%s'": false + class `Discretization`: + def `__call__`: + "Subclasses of 'Discretization' need to implement ": false + the call operator: false + class `EqualFreq`: + def `__call__`: + quantile(%s, ARRAY%s): false + class `FixedTimeWidth`: + def `__call__`: + %Y: false + %y %b: false + %y %b %d: false + %y %b %d %H:%M: false + %H:%M:%S: false + def `_simplified_time_intervals`: + ' ': false + < {join(labels[0])}: false + {join(low)} - {join(no_common(low, high))}: false + ≥ {join(labels[-1])}: false + class `Binning`: + def `_create_binned_var`: + < {blabels[0]}: false + {lab1} - {lab2}: false + ≥ {blabels[-1]}: false + class `BinDefinition`: + def `__new__`: + %g: false + {width:g}: false + def `decimal_binnings`: + %g: false + def `_time_binnings`: + %H:%M:%S: false + second: sekunda + %b %d %H:%M: false + minute: minuta + %y %b %d %H:%M: false + hour: ura + %y %b %d: false + day: dan + week: teden + %y %b: false + month: mesec + %Y: false + year: leto + {step // 7} week{'s' * (step > 7)}: {step // 7} {plsi(step // 7, 'teden|tedna|tedni|tednov')} + {step} {unit}{'s' * (step > 1)}: {step} {plsi(step, dict(dan='dan|dneva|dnevi|dni', teden='teden|tedna|tedni|tednov', mesec='mesec|meseca|meseci|mesecev', leto='leto|leti|leta|let').get(unit, unit))} + def `_simplified_labels`: + 42: false + ' ': false + :: true + {to_remove} {labels[0]}: false + def `_unique_time_bins`: + %y %b %d: false + ' %H:%M': false + :%S: false + def `_min_max_unique`: + no valid (non-nan) data: false + sec: sek + min: min + hrs: ur + wks: ted + mon: mes + yrs: let +preprocess/fss.py: + SelectBestFeatures: false + SelectRandomFeatures: false + class `SelectBestFeatures`: + def `score_only_nice_features`: + -inf: false + inf: false +preprocess/impute.py: + ReplaceUnknowns: false + Average: false + DoNotImpute: false + DropInstances: false + Model: false + AsValue: false + Random: false + Default: false + FixedValueByType: false + class `BaseImputeMethod`: + {var.name} -> {self.short_name}: true + class `DoNotImpute`: + Don't impute: Ne nadomeščaj + leave: pusti + class `DropInstances`: + Remove instances with unknown values: Odstrani primere z neznanimi vrednostmi + drop: odstrani + class `Average`: + Average/Most frequent: Povprečna/najpogostejša vrednost + average: povprečje + Replace with average/mode of the column: Zamenjaj s povprečno oziroma najpogostejšo vrednostjo + def `__call__`: + Variable must be numeric or categorical.: Spremenljivka mora biti številska ali kategorična + class `ImputeSql`: + def `__call__`: + coalesce(%s, %s): false + class `Default`: + Fixed value: Določena vrednost + value: false + {var} -> {self.default}: true + class `FixedValueByType`: + Fixed value: Določena vrednost + Fixed Value: določena + {var.name}: false + class `ReplaceUnknownsModel`: + def `transform`: + abstract in Transformation, never used here: false + class `Model`: + Model-based imputer: Nadomeščanje z modelom + model: model + ' ({self.learner.name})': true + def `name`: + {} ({}): true + name: false + def `__call__`: + `{}` doesn't support domain type: `{}` ne podpira te vrste ciljne spremenljivke + class `AsValue`: + As a distinct value: Kot posebna, nova vrednost + new value: nova vrednost + def `__call__`: + {var.name}: true + N/A: NN + {var.name}_def: {var.name} znana + undef: ne + def: da + class `ReplaceUnknownsRandom`: + def `__init__`: + 'Only categorical and numeric ': Podprte so samo kategorične + variables are supported.: in številske spremenljivke. + class `Random`: + Random values: Naključno izbrana vrednost + random: naključna + Replace with a random value: Nadomesti z naključno vrednostjo + def `__call__`: + "'{}' has no values": false + "'{}' has an unknown distribution": false +preprocess/normalize.py: + Normalizer: false +preprocess/preprocess.py: + Continuize: false + Discretize: false + Impute: false + RemoveNaNRows: false + SklImpute: false + Normalize: false + Randomize: false + Preprocess: false + RemoveConstant: false + RemoveNaNClasses: false + RemoveNaNColumns: false + ProjectPCA: false + ProjectCUR: false + Scale: false + RemoveSparse: false + AdaptiveNormalize: false + PreprocessorList: false + class `Preprocess`: + def `__call__`: + Subclasses need to implement __call__: false + class `Continuize`: + Continuize: false + Indicators: false + FirstAsBase: false + FrequentAsBase: false + Remove: false + RemoveMultinomial: false + ReportError: false + AsOrdinal: false + AsNormalizedOrdinal: false + Leave: false + Continuize.MultinomialTreatment: false + class `SklImpute`: + def `__init__`: + mean: false + class `RemoveNaNClasses`: + Orange.data.filter.HasClas: false + class `Normalize`: + Normalize: false + NormalizeBySpan: false + NormalizeBySD: false + Normalize.Type: false + def `__call__`: + skip-normalization: false + class `Randomize`: + Randomize: false + Randomize.Type: false + class `Scale`: + class `_MethodEnum`: + def `__call__`: + _: false + Scale: false + NoCentering: false + Mean: false + Median: false + Scale.CenteringType: false + NoScaling: false + Std: false + Span: false + Scale.ScalingType: false +preprocess/remove.py: + Remove: false + class `Remove`: + def `get_vars_and_results`: + removed: false + reduced: false + sorted: false + Var: false + var: false + Removed: false + sub: false + Reduced: false + Sorted: false + Transformed: false +preprocess/score.py: + Chi2: false + ANOVA: false + UnivariateLinearRegression: false + InfoGain: false + GainRatio: false + Gini: false + ReliefF: false + RReliefF: false + FCBF: false + class `Scorer`: + def `friendly_name`: + ([a-z])([A-Z]): false + ' ': false + def `_friendly_vartype_name`: + categorical: kategorično + numeric: številsko + Variable: false + def `__call__`: + {} requires data with a target variable.: {} zahteva podatke s ciljno spremenljivko + {} requires a {} target variable.: {} zahteva {} ciljno spremenljivko. + {} cannot score {} variables.: {} ne deluje s {} spremenljivko. + class `LearnerScorer`: + def `score_data`: + def `join_derived_features`: + variable: false + class `ReliefF`: + ReliefF: false + def `score_data`: + ReliefF requires one single class: false + 'ReliefF supports classification; use RReliefF ': false + for regression: false + class `RReliefF`: + RReliefF: false + def `score_data`: + RReliefF requires one single class: false + 'RReliefF supports regression; use ReliefF ': false + for classification: false + __main__: false + Best =: false + Weights =: false +preprocess/transformation.py: + class `Transformation`: + def `__getstate__`: + _target_domain: false + def `transform`: + ColumnTransformations must implement method 'transform'.: false + class `Normalizer`: + def `transform`: + Normalization does not work for sparse data.: false + class `MappingTransform`: + def `__init__`: + DTypeLike: false + "'nan' value in mapping.keys()": false +projection/base.py: + LinearCombinationSql: false + Projector: false + Projection: false + SklProjector: false + LinearProjector: false + DomainProjection: false + class `LinearCombinationSql`: + def `__call__`: + ' + ': false + {} * {}: false + {} * ({} - {}): false + class `Projector`: + projection: false + def `fit`: + Classes derived from Projector must overload method fit: false + def `__call__`: + Preprocessing...: Predprocesiranje... + "A keyword argument 'progress_callback' has been ": false + 'added to the preprocess() signature. Implementing ': false + 'the method without the argument is deprecated and ': false + will result in an error in the future.: false + Fitting...: Prileganje... + def `__getstate__`: + _Projector__tls: false + class `TransformDomain`: + def `__getstate__`: + _hash: false + class `ComputeValueProjector`: + def `__init__`: + Argument projection is unused and will be removed.: false + class `DomainProjection`: + C: false + def `__init__`: + def `proj_variable`: + mean_: false + def `_get_var_names`: + x: false + y: false + {self.var_prefix}-{postfix}: false + class `LinearProjector`: + Linear Projection: false + class `SklProjector`: + skl projection: false + def `_get_sklparams`: + self: false + Wrapper does not define '__wraps__': false + def `preprocess`: + 'Wrapped scikit-learn methods do not support ': false + multinomial variables.: false +projection/cur.py: + CUR: false + class `CUR`: + cur: false + class `CURModel`: + def `__call__`: + 'CUR can select either columns ': false + (axis = 0) or rows (axis = 1).: false + class `Projector`: + def `__getstate__`: + transformed: false + __main__: false + fro: false + 'Fro. error (optimal SVD): %5.4f': false + 'Fro. error (CUR): %5.4f': false +projection/freeviz.py: + FreeViz: false + class `FreeVizModel`: + freeviz: false + class `FreeViz`: + FreeViz: false + def `__call__`: + Can not handle discrete variables: false + ' with more than two values': false + def `forces_regression`: + sqeuclidean: false + def `forces_classification`: + hamming: false + def `gradient`: + weights.ndim != 1 ({}): false + X and embeddings must have the same length ({}!={}): false + X.shape[0] != weights.shape[0] ({}!={}): false + def `freeviz`: + X and y must have the same length: false + center.shape != (X.shape[1], ) ({} != {}): false + scale.shape != (X.shape[1],) ({} != {})): false + ignore: false +projection/lda.py: + LDA: false + class `LDAModel`: + LD: false + class `LDA`: + LDA: false + def `__init__`: + svd: false + def `fit`: + n_components: false +projection/manifold.py: + MDS: false + Isomap: false + LocallyLinearEmbedding: false + SpectralEmbedding: false + TSNE: false + def `torgerson`: + auto: false + arpack: false + lapack: false + w was not in ascending order: false + {} of the {} eigenvalues were negative.: false + class `MDS`: + MDS: true + def `__init__`: + euclidean: false + random: false + def `__call__`: + dissimilarity: false + precomputed: false + PCA: false + n_components: false + class `Isomap`: + Isomap: true + def `__init__`: + auto: false + class `LocallyLinearEmbedding`: + Locally Linear Embedding: Lokalna linearna vložitev + def `__init__`: + auto: false + standard: false + class `SpectralEmbedding`: + Spectral Embedding: Spektralna vložitev + def `__init__`: + nearest_neighbors: false + class `TSNEModel`: + def `__init__`: + TSNE: false + def `transform`: + 'A sparse matrix was passed, but dense data is required. Use ': false + X.toarray() to convert to a dense numpy array.: false + perplexity: false + 'Perplexity should be an instance of `Iterable`, `%s` ': false + given.: false + perplexities: false + def `optimize`: + n_iter: false + inplace: false + propagate_exception: false + class `TSNE`: + t-SNE: true + def `__init__`: + auto: false + pca: false + euclidean: false + def `compute_affinities`: + 'A sparse matrix was passed, but dense data is required. Use ': false + X.toarray() to convert to a dense numpy array.: false + 'Perplexity should be an instance of `Iterable`, `%s` ': false + given.: false + 'Perplexity should be an instance of `float`, `%s` ': false + def `compute_initialization`: + pca: false + spectral: false + random: false + 'Invalid initialization `%s`. Please use either `pca` or ': false + `random` or provide a numpy array.: false + def `convert_embedding_to_model`: + precomputed: false + 'Expected `data` to be instance of ': false + '{DistMatrix.__class__.__name__} when using ': false + "`metric='precomputed'. Got {data.__class__.__name__} ": false + instead!: false + x: false + y: false + t-SNE-{p}: true +projection/pca.py: + PCA: false + SparsePCA: false + IncrementalPCA: false + TruncatedSVD: false + class `PCA`: + PCA: false + def `__init__`: + auto: false + def `fit`: + n_components: false + class `SparsePCA`: + Sparse PCA: false + def `__init__`: + lars: false + class `PCAModel`: + PC: false + def `_get_var_names`: + {self.var_prefix}{postfix}: false + class `IncrementalPCA`: + Incremental PCA: false + class `TruncatedSVD`: + Truncated SVD: false + def `__init__`: + randomized: false + def `fit`: + n_components: false +projection/radviz.py: + RadViz: false + class `RadVizModel`: + radviz: false + class `RadViz`: + RadViz: false + def `__call__`: + Can not handle categorical variables: Ne morem obravnavati kategoričnih spremenljivk + ' with more than two values': ' z več kot dvema vrednostima.' + def `transform`: + ignore: false +regression/base_regression.py: + LearnerRegression: false + ModelRegression: false + SklModelRegression: false + SklLearnerRegression: false + class `LearnerRegression`: + def `incompatibility_reason`: + Too many target variables.: Preveč ciljnih spremenljivk. + Numeric target variable expected.: Pričakujem številsko ciljno spremenljivko. +regression/catgb.py: + CatGBRegressor: false +regression/curvefit.py: + CurveFitLearner: false + class `CurveFitModel`: + def `coefficients`: + coef: false + name: false + def `__getstate__`: + Can't pickle/copy callable. Use str expression instead.: false + domain: false + original_domain: false + parameters_names: false + parameters: false + function: false + args: false + class `CurveFitLearner`: + Curve Fit: false + def `__init__`: + Provide 'parameters_names' parameter.: false + Provide 'features_names' parameter.: false + Provide 'available_feature_names' parameter.: false + Provide 'functions' parameter.: false + def `fit_storage`: + Numeric feature expected.: false + def `__getstate__`: + Can't pickle/copy callable. Use str expression instead.: false + parameters_names: false + features_names: false + p0: false + bounds: false + preprocessors: false + def `__setstate__`: + expression: false + def `_create_lambda`: + eval: false + x: false + : false + __main__: false + housing: false + a: false + b: false + c: false + LSTAT: false + o: false +regression/gb.py: + GBRegressor: false + class `GBRegressor`: + def `__init__`: + squared_error: false + friedman_mse: false + deprecated: false +regression/knn.py: + KNNRegressionLearner: false +regression/linear.py: + LinearRegressionLearner: false + RidgeRegressionLearner: false + LassoRegressionLearner: false + SGDRegressionLearner: false + ElasticNetLearner: false + ElasticNetCVLearner: false + PolynomialLearner: false + class `RidgeRegressionLearner`: + def `__init__`: + auto: false + class `ElasticNetCVLearner`: + def `__init__`: + auto: false + class `SGDRegressionLearner`: + def `__init__`: + squared_error: false + l2: false + invscaling: false + class `PolynomialLearner`: + poly learner: false + class `LinearModel`: + def `__str__`: + LinearModel {}: false + class `PolynomialModel`: + def `__str__`: + PolynomialModel {}: false +regression/linear_bfgs.py: + LinearRegressionLearner: false + class `LinearRegressionLearner`: + linear_bfgs: false + def `fit`: + 'Linear regression does not support ': false + multi-target classification: false + unknown values: false + __main__: false + housing: false + {:5.2f} {}: false + test data: false + majority: false +regression/mean.py: + MeanLearner: false + class `MeanLearner`: + def `fit_storage`: + 'regression.MeanLearner expects a domain with a ': false + (single) numeric variable.: false + class `MeanModel`: + def `__str__`: + MeanModel({}): false +regression/neural_network.py: + NNRegressionLearner: false + class `NNRegressionLearner`: + def `_initialize_wrapped`: + callback: false +regression/pls.py: + PLSRegressionLearner: false + class `PLSModel`: + PLS T: false + PLS U: false + def `__str__`: + PLSModel {self.skl_model}: false + def `_get_var_names`: + {prefix}{postfix}: false + def `project`: + PLSModel can only project tables: false + def `components`: + components: komponente + Component {i + 1}: Komponenta {i + 1} + def `coefficients_table`: + coef {i}: koef {i} + name: ime + coefficients: koeficienti + def `residuals_normal_probability`: + {name} ({var.name}): true + Sample Quantiles: Vzorčni kvantili + Theoretical Quantiles: Teoretični kvantili + residuals normal probability: verjetnost normalnih residualov + def `dmodx`: + DModX: false + DMod: false + class `PLSRegressionLearner`: + def `fit`: + n_components: false + def `incompatibility_reason`: + Numeric targets expected.: Metoda zahteva številčne ciljne spremenljivke. + Only numeric target variables expected.: Metoda deluje le za številčne ciljne spremenljivke. + def `fitted_parameters`: + n_components: false + Components: Komponent + __main__: false + housing: false + 'learner: {learner}\nRMSE: {ca}\n': false +regression/random_forest.py: + RandomForestRegressionLearner: false + class `RandomForestRegressor`: + def `trees`: + def `wrap`: + {} - tree {}: {} - drevo {} + instances: false + class `RandomForestRegressionLearner`: + def `__init__`: + squared_error: false +regression/simple_random_forest.py: + SimpleRandomForestLearner: false + class `SimpleRandomForestLearner`: + simple rf reg: false + def `__init__`: + sqrt: false +regression/svm.py: + SVRLearner: false + LinearSVRLearner: false + NuSVRLearner: false + class `SVRLearner`: + def `__init__`: + rbf: false + auto: false + class `LinearSVRLearner`: + def `__init__`: + epsilon_insensitive: false + class `NuSVRLearner`: + def `__init__`: + rbf: false + auto: false + __main__: false + housing: false + 'learner: {}\nRMSE: {}\n': false +regression/tree.py: + SklTreeRegressionLearner: false + TreeLearner: false + class `TreeLearner`: + def `__init__`: + binarity: false + min_samples_leaf: false + min_samples_split: false + max_depth: false + def `fit_storage`: + 'Exhaustive binarization does not handle ': 'Izčrpna binarizacija ne zmore kategoričnih spremenljivk ' + attributes with more than {} values: z več kot {} vrednostmi. + class `SklTreeRegressionLearner`: + regression tree: false + def `__init__`: + squared_error: false + best: false +regression/xgb.py: + XGBRegressor: false + XGBRFRegressor: false + class `XGBRegressor`: + def `__init__`: + reg:squarederror: false + gain: false + class `XGBRFRegressor`: + def `__init__`: + reg:squarederror: false + gain: false +statistics/basic_stats.py: + def `_get_variable`: + variable: false + 'variable does not match the variable ': false + in the data: false + domain: false + invalid specification of variable: false + class `BasicStats`: + def `__init__`: + inf: false + -inf: false +statistics/contingency.py: + def `_get_variable`: + variable: false + variable does not match the variable in the data: false + domain: false + expected %s variable not %s: false + expected %s, not '%s': false + class `Discrete`: + def `__new__`: + incompatible arguments (data storage and 'unknowns': false + row_variable: false + col_variable: false + def `from_data`: + row_variable needs to be specified (data has no class): false + row_variable: false + col_variable: false + def `__eq__`: + col_unknowns: false + row_unknowns: false + unknowns: false + def `__array_finalize__`: + col_variable: false + row_variable: false + col_unknowns: false + row_unknowns: false + unknowns: false + class `Continuous`: + def `__init__`: + incompatible arguments (data storage and 'unknowns': false + row_variable: false + col_variable: false + def `from_data`: + row_variable needs to be specified (data has no class): false + row_variable: false + col_variable: false + Fallback method for computation of contingencies is not implemented yet: false + def `__eq__`: + col_unknowns: false + row_unknowns: false + unknowns: false + def `__setitem__`: + 'Setting individual class contingencies is not implemented yet. ': false + Set .values and .counts.: false + def `normalize`: + contingencies can be normalized only with axis=1 or without axis: false + def `get_contingency`: + col_variable: false + cannot compute distribution of '%s': false + def `get_contingencies`: + data has no target variable: false +statistics/distribution.py: + def `_get_variable`: + variable: false + variable does not match the variable in the data: false + domain: false + expected %s variable not %s: false + expected %s, not '%s': false + class `Distribution`: + def `__array_finalize__`: + variable: false + unknowns: false + def `__eq__`: + unknowns: false + class `Discrete`: + def `__new__`: + incompatible arguments (data storage and 'unknowns': false + unknowns: false + def `__add__`: + unknowns: false + def `__iadd__`: + unknowns: false + def `__sub__`: + unknowns: false + def `__isub__`: + unknowns: false + class `Continuous`: + def `__new__`: + incompatible arguments (data storage and 'unknowns': false + unknowns: false + def `from_data`: + float: false + def `class_distribution`: + domain has no class attribute: false + def `get_distribution`: + cannot compute distribution of '%s': false +statistics/util.py: + def `_eliminate_zeros`: + eliminate_zeros: false + '`{x.__type__}` does not implement `eliminate_zeros`. Some values ': false + in the sparse matrix may by explicit zeros.: false + def `_count_nans_per_row_sparse`: + unsupported type '{}': false + def `sparse_count_implicit_zeros`: + The matrix provided was not sparse.: false + def `sparse_has_implicit_zeros`: + The matrix provided was not sparse.: false + def `sparse_implicit_zero_weights`: + The matrix provided was not sparse.: false + Computing zero weights on ndimensinal weight matrix is not implemented: false + def `countnans`: + Only axis 0 and 1 are currently supported: false + Axis %d is out of bounds: false + def `_nan_min_max`: + nan: false + def `mean`: + mean() resulted in nan. If input can contain nan values,: false + ' perhaps you meant nanmean?': false + def `nanmean`: + weights are only supported if axis is defined: false + def `nan_mean_var`: + axis=None is not supported: false + def `digitize`: + right: false + left: false + def `isnan`: + The `out` parameter can only be set `x` when using sparse matrices: false +tests/__init__.py: + def `named_file`: + wt: false + def `suite`: + test*.py: false + tests: false + widgets: false + __main__: false + suite: false +tests/base.py: + class `PickleTest`: + def `setUp`: + attributes: false + class_vars: false + class_var: false + variables: false + metas: false + anonymous: false + def `create_pickling_tests`: + def `create_test`: + test_{}: false +tests/dummy_learners.py: + class `DummyMulticlassLearner`: + def `incompatibility_reason`: + Not all class variables are discrete: false +tests/sql/base.py: + def `parse_uri`: + /: false + table: false + class `TestParseUri`: + def `test_parses_connection_uri`: + sql://user:password@host:7678/database/table: false + sql: false + host: false + user: false + password: false + database: false + table: false + def `test_parse_minimal_connection_uri`: + sql://host/database/table: false + sql: false + host: false + database: false + table: false + def `assertDictContainsSubset`: + '%s, expected: %s, actual: %s': false + 'Missing: %s': false + ,: false + '; ': false + 'Mismatched values: %s': false + def `connection_params`: + ORANGE_TEST_DB_URI: false + '|': false + class `PostgresTestConnection`: + postgres: false + psycopg2: false + def `create_sql_table`: + float: false + varchar({}): false + col{}: false + '"{}"': false + DROP TABLE IF EXISTS {}: false + CREATE TABLE {} ({}): false + ', ': false + {} {}: false + ({}): false + NULL: false + "'{}'": false + INSERT INTO {} VALUES {}: false + def `drop_sql_table`: + DROP TABLE {}: false + class `MicrosoftTestConnection`: + mssql: false + pymssql: false + def `create_sql_table`: + float: false + varchar({}): false + col{}: false + '"{}"': false + DROP TABLE IF EXISTS {}: false + CREATE TABLE {} ({}): false + ', ': false + {} {}: false + ({}): false + NULL: false + "'{}'": false + INSERT INTO {} VALUES {}: false + def `drop_sql_table`: + DROP TABLE {}: false + class `DataBaseTest`: + def `_check_db`: + >: false + <: false + {} module is required for this database: false + Database is not running: false + No connection provided for {}: false + Unsupported database: false + This test is only run database version higher then {}: false + This test is only run on database version lower then {}: false + def `_setup_test_with`: + def `new_test`: + setUpDB: false + tearDownDB: false + def `run_on`: + def `decorator`: + test_db_: false + _: false + 'On ': false + ' run: ': false + def `create_iris_sql_table`: + iris: false + sepal length: false + sepal width: false + petal length: false + petal width: false + float: false + varchar(15): false + def `drop_iris_sql_table`: + iris: false +tests/sql/test_filter.py: + class `TestIsDefinedSql`: + def `setUpDB`: + m: false + f: false + def `test_on_all_columns`: + postgres: false + mssql: false + def `test_selected_columns`: + postgres: false + mssql: false + def `test_all_columns_negated`: + postgres: false + def `test_selected_columns_negated`: + postgres: false + mssql: false + def `test_can_inherit_is_defined_filter`: + postgres: false + class `TestHasClass`: + def `setUpDB`: + m: false + f: false + def `test_has_class`: + postgres: false + mssql: false + def `test_negated`: + postgres: false + mssql: false + class `TestSameValueSql`: + def `setUpDB`: + a: false + m: false + f: false + b: false + def `test_on_continuous_attribute`: + postgres: false + mssql: false + def `test_on_continuous_attribute_with_unknowns`: + postgres: false + mssql: false + def `test_on_continuous_attribute_with_unknown_value`: + postgres: false + mssql: false + def `test_on_continuous_attribute_negated`: + postgres: false + def `test_on_discrete_attribute`: + postgres: false + mssql: false + a: false + def `test_on_discrete_attribute_with_unknown_value`: + postgres: false + mssql: false + def `test_on_discrete_attribute_with_unknowns`: + postgres: false + mssql: false + m: false + def `test_on_discrete_attribute_negated`: + postgres: false + mssql: false + a: false + def `test_on_discrete_attribute_value_passed_as_int`: + postgres: false + mssql: false + def `test_on_discrete_attribute_value_passed_as_float`: + postgres: false + mssql: false + class `TestValuesSql`: + def `setUpDB`: + a: false + m: false + f: false + b: false + def `test_values_filter_with_no_conditions`: + postgres: false + mssql: false + def `test_discrete_value_filter`: + postgres: false + mssql: false + a: false + def `test_discrete_value_filter_with_multiple_values`: + postgres: false + a: false + b: false + def `test_discrete_value_filter_with_None`: + postgres: false + def `test_continuous_value_filter_equal`: + postgres: false + mssql: false + def `test_continuous_value_filter_not_equal`: + postgres: false + def `test_continuous_value_filter_less`: + postgres: false + mssql: false + def `test_continuous_value_filter_less_equal`: + postgres: false + def `test_continuous_value_filter_greater`: + postgres: false + def `test_continuous_value_filter_greater_equal`: + postgres: false + def `test_continuous_value_filter_between`: + postgres: false + def `test_continuous_value_filter_outside`: + postgres: false + mssql: false + def `test_continuous_value_filter_isdefined`: + postgres: false + class `TestFilterStringSql`: + def `setUpDB`: + Lorem ipsum dolor sit amet, consectetur adipiscing: false + elit. Vestibulum vel dolor nulla. Etiam elit lectus, mollis nec: false + mattis sed, pellentesque in turpis. Vivamus non nisi dolor. Etiam: false + lacinia dictum purus, in ullamcorper ante vulputate sed. Nullam: false + congue blandit elementum. Donec blandit laoreet posuere. Proin: false + quis augue eget tortor posuere mollis. Fusce vestibulum bibendum: false + neque at convallis. Donec iaculis risus volutpat malesuada: false + vehicula. Ut cursus tempor massa vulputate lacinia. Pellentesque: false + eu tortor sed diam placerat porttitor et volutpat risus. In: false + vulputate rutrum lacus ac sagittis. Suspendisse interdum luctus: false + sem auctor commodo.: false + ' ': false + def `test_filter_string_is_defined`: + postgres: false + def `test_filter_string_equal`: + postgres: false + mssql: false + in: false + def `test_filter_string_equal_case_insensitive_value`: + postgres: false + In: false + in: false + def `test_filter_string_equal_case_insensitive_data`: + postgres: false + donec: false + Donec: false + def `test_filter_string_not_equal`: + postgres: false + in: false + def `test_filter_string_not_equal_case_insensitive_value`: + postgres: false + In: false + in: false + def `test_filter_string_not_equal_case_insensitive_data`: + postgres: false + donec: false + Donec: false + def `test_filter_string_less`: + postgres: false + mssql: false + A: false + def `test_filter_string_less_case_insensitive_value`: + postgres: false + In: false + in: false + def `test_filter_string_less_case_insensitive_data`: + postgres: false + donec: false + def `test_filter_string_less_equal`: + postgres: false + mssql: false + A: false + def `test_filter_string_less_equal_case_insensitive_value`: + postgres: false + In: false + in: false + def `test_filter_string_less_equal_case_insensitive_data`: + postgres: false + donec: false + def `test_filter_string_greater`: + postgres: false + mssql: false + volutpat: false + def `test_filter_string_greater_case_insensitive_value`: + postgres: false + In: false + in: false + def `test_filter_string_greater_case_insensitive_data`: + postgres: false + donec: false + def `test_filter_string_greater_equal`: + postgres: false + volutpat: false + def `test_filter_string_greater_equal_case_insensitive_value`: + postgres: false + In: false + in: false + def `test_filter_string_greater_equal_case_insensitive_data`: + postgres: false + donec: false + def `test_filter_string_between`: + postgres: false + a: false + c: false + def `test_filter_string_between_case_insensitive_value`: + postgres: false + I: false + O: false + i: false + o: false + def `test_filter_string_between_case_insensitive_data`: + postgres: false + i: false + O: false + o: false + def `test_filter_string_contains`: + postgres: false + et: false + def `test_filter_string_contains_case_insensitive_value`: + postgres: false + eT: false + et: false + def `test_filter_string_contains_case_insensitive_data`: + postgres: false + do: false + def `test_filter_string_outside`: + postgres: false + am: false + di: false + def `test_filter_string_outside_case_insensitive`: + postgres: false + d: false + k: false + def `test_filter_string_starts_with`: + postgres: false + D: false + def `test_filter_string_starts_with_case_insensitive`: + postgres: false + D: false + d: false + def `test_filter_string_ends_with`: + postgres: false + s: false + def `test_filter_string_ends_with_case_insensitive`: + postgres: false + S: false + s: false + def `test_filter_string_list`: + postgres: false + et: false + in: false + def `test_filter_string_list_case_insensitive_value`: + postgres: false + Et: false + In: false + et: false + in: false + def `test_filter_string_list_case_insensitive_data`: + postgres: false + mssql: false + donec: false + Donec: false + __main__: false +tests/sql/test_misc.py: + class `MiscSqlTests`: + def `test_discretization`: + postgres: false + sepal length: false + def `test_get_conditional_distribution`: + postgres: false + Cannot import widgets: false + sepal length: false + def `test_create_sql_contingency`: + postgres: false + Cannot import widgets: false +tests/sql/test_naive_bayes_sql.py: + class `NaiveBayesTest`: + def `test_NaiveBayes`: + postgres: false + Iris-setosa: false + Iris-virginica: false + Iris-versicolor: false + iris: false +tests/sql/test_sql_table.py: + class `TestSqlTable`: + def `discrete_variable`: + mf: false + def `test_constructs_correct_attributes`: + postgres: false + col0: false + '"col0"': false + col1: false + '"col1"': false + f: false + m: false + col2: false + '"col2"': false + def `test_make_attributes`: + postgres: false + def `test_len`: + postgres: false + mssql: false + def `test_bool`: + postgres: false + mssql: false + def `test_len_with_filter`: + postgres: false + mssql: false + m: false + x: false + def `test_XY_small`: + postgres: false + mssql: false + col2: false + 0: false + 1: false + 2: false + def `test_XY_large`: + postgres: false + mssql: false + Orange.data.sql.table.AUTO_DL_LIMIT: false + col2: false + 0: false + 1: false + 2: false + def `test_download_data`: + postgres: false + mssql: false + X: false + Y: false + metas: false + W: false + ids: false + col2: false + 0: false + 1: false + 2: false + def `test_query_all`: + postgres: false + mssql: false + def `test_unavailable_row`: + postgres: false + mssql: false + def `test_query_subset_of_attributes`: + postgres: false + mssql: false + sepal length: false + sepal width: false + double width: false + 2 * "sepal width": false + def `test_query_subset_of_rows`: + postgres: false + def `test_getitem_single_value`: + postgres: false + mssql: false + Iris-setosa: false + def `test_type_hints`: + postgres: false + mssql: false + iris: false + def `test_joins`: + postgres: false + "SELECT a.""sepal length"", + b. ""petal length"", + CASE WHEN b.""petal length"" < 3 THEN '<' + ELSE '>' + END AS ""qualitative petal length"" + FROM iris a + INNER JOIN iris b ON a.""sepal width"" = b.""sepal width"" + WHERE a.""petal width"" < 1 + ORDER BY a.""sepal length"", b. ""petal length"" ASC": false + qualitative petal length: false + <: false + >: false + def `_mock_attribute`: + '"%s"': false + def `test_universal_table`: + postgres: false + ' + SELECT + v1.col2 as v1, + v2.col2 as v2, + v3.col2 as v3, + v4.col2 as v4, + v5.col2 as v5 + FROM %(table_name)s v1 + INNER JOIN %(table_name)s v2 ON v2.col0 = v1.col0 AND v2.col1 = 2 + INNER JOIN %(table_name)s v3 ON v3.col0 = v2.col0 AND v3.col1 = 3 + INNER JOIN %(table_name)s v4 ON v4.col0 = v1.col0 AND v4.col1 = 4 + INNER JOIN %(table_name)s v5 ON v5.col0 = v1.col0 AND v5.col1 = 5 + WHERE v1.col1 = 1 + ORDER BY v1.col0 + ': false + '"%s"': false + iris: false + Iris-setosa: false + Iris-virginica: false + Iris-versicolor: false + def `test_class_var_type_hints`: + postgres: false + mssql: false + iris: false + def `test_meta_type_hints`: + postgres: false + mssql: false + iris: false + def `test_metas_type_hints`: + postgres: false + mssql: false + iris: false + def `test_select_all`: + postgres: false + mssql: false + SELECT * FROM iris: false + def `test_discrete_bigint`: + postgres: false + bigint: false + def `test_continous_bigint`: + postgres: false + mssql: false + bigint: false + def `test_discrete_int`: + postgres: false + int: false + def `test_continous_int`: + postgres: false + mssql: false + int: false + def `test_discrete_smallint`: + postgres: false + smallint: false + def `test_continous_smallint`: + postgres: false + mssql: false + smallint: false + def `test_boolean`: + postgres: false + F: false + T: false + False: false + True: false + boolean: false + def `test_discrete_char`: + postgres: false + mssql: false + M: false + F: false + char(1): false + def `test_discrete_bigger_char`: + postgres: false + M: false + F: false + char(10): false + def `test_meta_char`: + postgres: false + mssql: false + ABCDEFGHIJKLMNOPQRSTUVW: false + char(1): false + def `test_discrete_varchar`: + postgres: false + mssql: false + M: false + F: false + varchar(1): false + def `test_meta_varchar`: + postgres: false + mssql: false + ABCDEFGHIJKLMNOPQRSTUVW: false + varchar(1): false + def `test_time_date`: + postgres: false + 2014-04-12: false + 2014-04-13: false + 2014-04-14: false + 2014-04-15: false + 2014-04-16: false + date: false + def `test_time_time`: + postgres: false + 17:39:51: false + 11:51:48.46: false + 05:20:21.492149: false + 21:47:06: false + 04:47:35.8: false + time: false + def `test_time_timetz`: + postgres: false + 17:39:51+0200: false + 11:51:48.46+01: false + 05:20:21.4921: false + 21:47:06-0600: false + 04:47:35.8+0330: false + timetz: false + def `test_time_timestamp`: + postgres: false + 2014-07-15 17:39:51.348149: false + 2008-10-05 11:51:48.468149: false + 2008-11-03 05:20:21.492149: false + 2015-01-02 21:47:06.228149: false + 2016-04-16 04:47:35.892149: false + timestamp: false + def `test_time_timestamptz`: + postgres: false + 2014-07-15 17:39:51.348149+0200: false + 2008-10-05 11:51:48.468149+02: false + 2008-11-03 05:20:21.492149+01: false + 2015-01-02 21:47:06.228149+0100: false + 2016-04-16 04:47:35.892149+0330: false + timestamptz: false + def `test_double_precision`: + postgres: false + mssql: false + double precision: false + def `test_numeric`: + postgres: false + mssql: false + numeric(15, 2): false + def `test_real`: + postgres: false + mssql: false + real: false + def `test_serial`: + postgres: false + serial: false + def `test_smallserial`: + postgres>90200: false + smallserial: false + def `test_bigserial`: + postgres>90200: false + bigserial: false + def `test_text`: + postgres: false + ABCDEFGHIJKLMNOPQRSTUVW: false + text: false + def `test_other`: + postgres: false + bcd4d9c0-361e-bad4-7ceb-0d171cdec981: false + 544b7ddc-d861-0201-81c8-9f7ad0bbf531: false + b35a10f7-7901-f313-ec16-5ad9778040a6: false + b267c4be-4a26-60b5-e664-737a90a40e93: false + uuid: false + foo: false + def `test_recovers_connection_after_sql_error`: + postgres: false + mssql: false + SELECT 1/%s FROM %s: false + SELECT %s FROM %s: false + def `test_basic_stats`: + postgres: false + sepal length: false + def `test_basic_stats_on_large_data`: + postgres: false + Orange.data.sql.table.LARGE_TABLE: false + sepal length: false + def `test_distributions`: + postgres: false + mssql: false + def `test_contingencies`: + postgres: false + sepal width: false + iris: false + def `test_pickling_restores_connection_pool`: + postgres: false + def `test_pickling_respects_downloaded_state`: + postgres: false + def `test_list_tables_with_schema`: + postgres: false + DROP SCHEMA IF EXISTS orange_tests CASCADE: false + CREATE SCHEMA orange_tests: false + CREATE TABLE orange_tests.efgh (id int): false + INSERT INTO orange_tests.efgh (id) VALUES (1): false + INSERT INTO orange_tests.efgh (id) VALUES (2): false + orange_tests: false + efgh: false + def `test_nan_frequency`: + postgres: false + mssql: false + __main__: false +utils/tree/rules.py: + class `DiscreteRule`: + def `merge_with`: + Merged two discrete rules `%s` and `%s`: false + def `description`: + {} {}: false + =: false + ≠: false + def `__str__`: + {} {} {}: false + =: false + ≠: false + def `__repr__`: + DiscreteRule(attr_name='%s', equals=%s, value=%s): false + class `ContinuousRule`: + def `merge_with`: + 'Continuous rules can currently only be ': false + merged with other continuous rules: false + def `description`: + %s %.3f: false + >: false + ≤: false + def `__str__`: + %s %s %.3f: false + >: false + ≤: false + def `__repr__`: + "ContinuousRule(attr_name='%s', greater=%s, value=%s, ": false + inclusive=%s): false + class `IntervalRule`: + def `__init__`: + 'The left rule must be an instance of the `ContinuousRule` ': false + class.: false + 'The right rule must be an instance of the `ContinuousRule` ': false + def `description`: + ∈ %s%.3f, %.3f%s: false + [: false + (: false + ]: false + ): false + def `__str__`: + %s ∈ %s%.3f, %.3f%s: false + [: false + (: false + ]: false + ): false + def `__repr__`: + IntervalRule(attr_name='%s', left_rule=%s, right_rule=%s): false +utils/tree/skltreeadapter.py: + class `SklTreeAdapter`: + def `rules`: + values: false + _: false +widgets/__init__.py: + def `widget_discovery`: + Orange3: false + Orange.widgets.data: false + Orange.widgets.visualize: false + Orange.widgets.model: false + Orange.widgets.evaluate: false + Orange.widgets.unsupervised: false + Transform: Predelava podatkov + '#FF9D5E': false + data/icons/Transform.svg: false + Orange Obsolete: Zastarelo + Orange.widgets.obsolete.owtable: false + {DEVELOP_ROOT}/doc/visual-programming/build/htmlhelp/index.html: false + data: false + share/help/en/orange3/htmlhelp/index.html: false + https://docs.biolab.si/orange/3/visual-programming/: false +widgets/credentials.py: + Orange3 - {}: false + class `CredentialManager`: + def `__init__`: + __service_name: false + def `service_name`: + __service_name: false + def `__setattr__`: + Failed to set secret '%s' of '%r'.: false + def `__getattr__`: + Failed to get secret '%s' of '%r'.: false + def `__delattr__`: + Failed to delete secret '%s' of '%r'.: false +widgets/gui.py: + OWComponent: false + OrangeUserRole: false + TableView: false + resource_filename: false + miscellanea: false + setLayout: false + separator: false + rubber: false + widgetBox: false + hBox: false + vBox: false + indentedBox: false + widgetLabel: false + label: false + spin: false + doubleSpin: false + checkBox: false + lineEdit: false + button: false + toolButton: false + comboBox: false + radioButtons: false + radioButtonsInBox: false + appendRadioButton: false + hSlider: false + labeledSlider: false + valueSlider: false + auto_commit: false + auto_send: false + auto_apply: false + ProgressBar: false + VerticalLabel: false + tabWidget: false + createTabPage: false + table: false + tableItem: false + VisibleHeaderSectionContextEventFilter: false + checkButtonOffsetHint: false + toolButtonSizeHint: false + FloatSlider: false + ControlGetter: false + VerticalScrollArea: false + CalendarWidgetWithTime: false + DateTimeEditWCalendarTime: false + BarRatioRole: false + BarBrushRole: false + SortOrderRole: false + LinkRole: false + BarItemDelegate: false + IndicatorItemDelegate: false + LinkStyledItemDelegate: false + ColoredBarItemDelegate: false + HorizontalGridDelegate: false + VerticalItemDelegate: false + ValueCallback: false + is_macstyle: false + createAttributePixmap: false + attributeIconDict: false + attributeItem: false + listView: false + ListViewWithSizeHint: false + listBox: false + OrangeListBox: false + TableValueRole: false + TableClassValueRole: false + TableDistribution: false + TableVariable: false + TableBarItem: false + palette_combo_box: false + BarRatioTableModel: false + class `__AttributeIconDict`: + def `__getitem__`: + c: false + N: false + d: false + C: false + s: false + S: false + t: false + T: false + ?: false + def `listView`: + uniformItemSizes: false + class `OrangeListBox`: + def `updateGeometries`: + _updatingGeometriesNow: false + def `comboBox`: + valueType: false + Argument 'valueType' is deprecated and ignored: false + class `TableBarItem`: + color_schema: false + class `HScrollStepMixin`: + def `wheelEvent`: + source: false + darwin: false + class `BarRatioTableModel`: + def `setExtremesFrom`: + ignore: false + .*All-NaN slice encountered.*: false + def `_argsortData`: + mergesort: false +widgets/io.py: + ImgFormat: false + PngFormat: false + SvgFormat: false + ClipboardFormat: false + PdfFormat: false + MatplotlibFormat: false + MatplotlibPDFFormat: false +widgets/settings.py: + Setting: false + SettingsHandler: false + SettingProvider: false + ContextSetting: false + Context: false + ContextHandler: false + IncompatibleContext: false + rename_setting: false + widget_settings_dir: false + DomainContextHandler: false + PerfectDomainContextHandler: false + ClassValuesContextHandler: false + SettingsPrinter: false + migrate_str_to_variable: false + class `DomainContextHandler`: + def `__init__`: + {} is not a valid parameter for DomainContextHandler: false + def `encode_setting`: + 'Variables must be stored as ContextSettings; ': false + change {setting.name} to ContextSetting.: false + def `decode_setting`: + def `get_var`: + Cannot decode variable without domain: false +widgets/widget.py: + OWWidget: false + Input: false + Output: false + MultiInput: false + AttributeList: false + Message: false + Msg: false + StateInfo: false + InputSignal: false + OutputSignal: false + Default: false + NonDefault: false + Single: false + Multiple: false + Dynamic: false + Explicit: false +widgets/data/__init__.py: + Data: Podatki + orange.widgets.data: false + Data manipulation: Delo s podatki + icons/Category-Data.svg: false + '#FFD39F': false +widgets/data/owaggregatecolumns.py: + class `OWAggregateColumns`: + Aggregate Columns: Združi stolpce + Compute a sum, max, min ... of selected columns.: Izračunaj vsoto, minimum, maksimum ... izbranih stolpcev. + Transform: Predelava podatkov + icons/AggregateColumns.svg: false + aggregate columns, aggregate, sum, product, max, min, mean, median, variance: aggregate columns, aggregate, sum, product, max, min, mean, median, variance, agregacija, vsota, zmnožek, maksimum, minimum, povprečje, mediana, varianca + class `Inputs`: + Data: Podatki + Features: Spremenljivke + class `Outputs`: + Data: Podatki + class `Warning`: + Some input features are categorical:\n{}: Nekatere vhodne spremenljivke so kategorične:\n{} + Some input features are missing:\n{}: Nekatere vhodne spremenljivke manjkajo:\n{} + Sum: Vsota + Product: Produkt + Min: false + Minimal value: Najmanjša vrednost + Max: false + Maximal value: Največja vrednost + Mean: false + Mean value: Povprečna vrednost + Variance: Varianca + Median: Mediana + Count non-zero: Preštej neničelne + agg: false + def `__init__`: + Variable selection: Izbor spremenljivk + All: Vse + All, including meta attributes: Vse, vključno z meta atributi + Features from separate input signal: Stolpci, določeni z vhodnim signalom + Selected variables: Izbrane spremenljivke + variables: false + Operation: Operacija + var_name: false + 'Output variable name: ': 'Ime izhodne spremenljivke: ' + def `send_report`: + Output:: Izhod: + "'{self._new_var_name()}' as {self.operation.lower()} of {var_list}": "'{self._new_var_name()}' kot {self.operation.lower()} stolpcev {var_list}" + def `_and_others`: + "'{variables[0].name}'": true + ', ': true + "'{var.name}'": false + ' and {len(variables) - limit} more': ' in še {len(variables) - limit} {plsi(len(variables) - limit, "druga|drugi|druge|drugih")}' + " and '{variables[-1].name}'": " in '{variables[-1].name}'" + __main__: false + brown-selected: false +widgets/data/owcolor.py: + class `AttrDesc`: + def `to_dict`: + rename: false + def `from_dict`: + rename: false + class `DiscAttrDesc`: + def `to_dict`: + renamed_values: false + colors: false + def `from_dict`: + renamed_values: false + '{var.name}: ': false + renaming of values ignored due to duplicate names: preimenovanje ni upoštevano zaradi podvojenih imen + colors: false + class `ContAttrDesc`: + def `create_variable`: + palette: false + def `to_dict`: + colors: false + def `from_dict`: + colors: false + class `ContColorTableModel`: + def `data`: + def `_column2`: + Copy to all: Dodeli vsem + class `OWColor`: + Color: Obarvaj + Set color legend for variables.: Določi barvne legende za spremenljivke. + icons/Colors.svg: false + palette, legend: palette, legend, barve, legenda + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + def `__init__`: + Discrete Variables: Kategorične spremenljivke + Numeric Variables: Številske spremenljivke + Save: Shrani + Load: Naloži + Reset: Povrni + auto_apply: false + def `save`: + File name: Ime datoteke + Variable definitions (*.colors): Nastavitve barv (*.colors) + colorwidget/last-location: false + def `_save_var_defs`: + w: false + categorical: false + numeric: false + def `load`: + File name: Ime datoteke + Variable definitions (*.colors): Nastavitve barv (*.colors) + File error: Napaka pri branju + File cannot be opened.: Datoteke ni mogoče odpreti. + Invalid file format.: Napačna oblika datoteke. + def `_parse_var_defs`: + categorical: false + numeric: false + rename: false + Duplicated variable names: Podvojena imena spremenljivk + Variables will not be renamed due to duplicated names.: Zaradi podvojenih imen spremenljivke ne bodo preimenovane. + "'{name}'": false + 'Definition for variable {names[0]}, which does not ': 'Nastavitve za spremenljivko {names[0]}, ki je ni v podatkih, ' + appear in the data, was ignored.\n: niso uporabljene.\n + 'Definitions for variables ': Nastavitve za {plsi(len(names), "|spremenljivki|spremenljivke")} + {", ".join(names[:-1])} and {names[-1]}: {", ".join(names[:-1])} in {names[-1]} + 'Definitions for {", ".join(names[:4])} ': 'Nastavitve za {", ".join(names[:4])} ' + and {len(names) - 4} other variables: in še {z_besedo(len(names) - 4, 4, "f")} {plsi(len(names) - 4, "|drugi spremenljivki|druge spremenljivke|drugih spremenljivk")} + , which do not appear in the data, were ignored.\n: , ki jih ni v podatkih, niso uporabljene.\n + Invalid definitions: Neuporabljene definicije + \n: false + def `_start_dir`: + basedir: false + colorwidget/last-location: false + ~{os.sep}: false + def `send_report`: + def `_report_variables`: + def `was`: + '{n} (was: {o})': {n} (prej: {o}) + ' \n': false + {square(*color)} {was(value, old_value)}: false + : false + {pal.friendly_name}: false + '\n': false + ' {names}': false + ' {value_cols}\n': false + \n: false + Features: Spremenljivke + {pl(len(dom.class_vars), "Outcome")}: {plsi(len(dom.class_vars), "Ciljna spremenljivka|Ciljni spremenljivki|Ciljne spremenljivke")} + Meta attributes: Meta spremenljivke + {name}{rows}: false + {table}
      : false + __main__: false + heart_disease.tab: false +widgets/data/owconcatenate.py: + class `OWConcatenate`: + Concatenate: Stakni tabele + Concatenate (append) two or more datasets.: Stakni več tabel eno pod drugo. + Transform: Predelava podatkov + icons/Concatenate.svg: false + concatenate, append, join, extend: concatenate, append, join, extend, stakni, združi, razširi + class `Inputs`: + Primary Data: Osnovna tabela + Additional Data: Dodatne tabele + class `Outputs`: + Data: Podatki + class `Error`: + Inputs must be of the same type.: Podatki morajo biti enake vrste. + Ignoring column names requires matching column types: Ignoriranje imen zahteva, da se tipi stolpcev ujemajo. + class `Warning`: + Variables with duplicated names have been renamed.: Spremenljivk s podvojenimi imeni so preimenovane. + 'Some variables may not be concatenated correctly due ': 'Nekatere spremenljivke morda ne bodo pravilno združene zaradi ' + to attributes difference ({}).: razlike v atributih ({}). + Source ID: Vir + all variables that appear in input tables: spremenljivke iz vseh tabel + only variables that appear in all tables: spremenljivke, ki se pojavijo v vseh tabelah + Class attribute: Ciljna spremenljivka + Attribute: Spremenljivka + Meta attribute: Meta spremenljivka + def `__init__`: + Variable Sets Merging: Združevanje stolpcev + 'When there is no primary table, ': 'Kadar ni podana osnovna tabela, ' + the output should contain: naj izhodna tabela vsebuje + merge_type: true + 'The resulting table will have a class only if there ': 'Ciljna spremenljivka bo ohranjena le, če imajo ' + is no conflict between input classes.: vse vhodne tabele isto ciljno spremenljivko. + Variable matching: Ujemanje spremenljivk + ignore_names: false + Use column names from the primary table,\n: Uporabi imena stolpcev iz osnovne tabele,\n + and ignore names in other tables.: in ignoriraj imena v ostalih tabelah. + ignore_compute_value: false + Treat variables with the same name as the same variable,\n: Obravnavaj spremenljivke z enakimi imeni kot isto spremenljivko,\n + even if they are computed using different formulae.: četudi so izračunane z različnimi formulami oz. iz različnih izvornih spremenljivk. + Source Identification: Oznaka vira + append_source_column: false + Append data source IDs: Vsaki vrstici dodaj ime tabele, iz katere izhaja + Feature name:: Ime spremenljivke: + source_attr_name: false + Place:: Vrsta: + source_column_role: false + auto_commit: false + def `commit`: + name: false + {} ({}): true + class_vars: false + attributes: false + metas: false + def `send_report`: + Domain: Domena + from primary data: iz osnovne tabele + Source data ID: Spremenljivka z imenom vira + {} (as {}): {} (kot {}) + def `merge_domains`: + attributes: false + class_vars: false + metas: false + def `_unique_vars`: + AttrDesc: false + template: false + original: false + values: false + number_of_decimals: false + __main__: false + iris: false + zoo: false +widgets/data/owcontinuize.py: + Use general preset: Uporabi splošne nastavitve + preset: splošno + Treat the variable as defined in general preset: Spremeni, kot določajo splošne nastavitve + Keep categorical: Ohrani kategorično + keep as is: ohrani, kot je + Keep the variable discrete: Ohrani spremenljivko diskretno + First value as base: Prva vrednost kot osnova + first as base: prva kot osnova + One indicator variable for each value except the first: Ena indikatorska spremenljivka za vsako vrednost, razen za prvo + Most frequent as base: Najpogostejša vrednost kot osnova + frequent as base: najpogostejša kot osnova + One indicator variable for each value except the most frequent: Ena indikatorska spremenljivka za vsako vrednost, razen za najpogostejšo + One-hot encoding: Enično kodiranje + one-hot: enično kodiranje + One indicator variable for each value: Ena indikatorska spremenljivka za vsako vrednost + Remove if more than 2 values: Odstrani, če ima več kot dve vrednosti + remove if >2: odstrani, če >2 + Remove variables with more than two values; indicator otherwise: Odstrani spremenljivke z več kot dvema vrednostma; sicer indikator + Remove: Odstrani + remove: odstrani + Remove variable: Odstrani spremenljivko + Treat as ordinal: Obravnavaj kot ordinalno + as ordinal: kot ordinalna + Each value gets a consecutive number from 0 to number of values - 1: Vsaka vrednost dobi zaporedno številko od 0 do števila vrednosti - 1 + Treat as normalized ordinal: Obravnavaj kot normalizirano ordinalno + as norm. ordinal: kot norm. ordinalna + Same as above, but scaled to [0, 1]: Enako kot zgoraj, vendar skalirano na [0, 1] + Keep as it is: Ohrani tako, kot je + no change: brez sprememb + Keep the variable as it is: Ohrani spremenljivko tako, kot je + Standardize to μ=0, σ²=1: Standardiziraj na μ=0, σ²=1 + standardize: standardizirano + Subtract the mean and divide by standard deviation: Odštej povprečje in deli s standardnim odklonom + Center to μ=0: Centriraj na μ=0 + center: centriraj + Subtract the mean: Odštej povprečje + Scale to σ²=1: Skaliraj na σ²=1 + scale: skaliraj + Divide by standard deviation: Deli s standardnim odklonom + Normalize to interval [-1, 1]: Normaliziraj na interval [-1, 1] + to [-1, 1]: na interval [-1, 1] + Linear transformation into interval [-1, 1]: Linearna transformacija v interval [-1, 1] + Normalize to interval [0, 1]: Normaliziraj na interval [0, 1] + to [0, 1]: na interval [0, 1] + Linear transformation into interval [0, 1]: Linearna transformacija v interval [0, 1] + class `ContDomainModel`: + def `__init__`: + Meta attributes: Meta spremenljivke + Targets: Ciljne spremenljivke + def `data`: + {name} {hint[0]}: false + class `DefaultContModel`: + def `__init__`: + ★: false + def `data`: + 'General preset: {self.method}': Splošna nastavitev: {self.method} + Default for variables without specific settings: Privzeto za spremenljivke brez posebnih nastavitev + class `ListViewSearch`: + class `Delegate`: + def `displayText`: + '{name}: {hint}': false + class `OWContinuize`: + Continuize: Kontinuizacija + 'Transform categorical attributes into numeric and, ': 'Kategorične atribute pretvori v številčne in ' + optionally, scale numeric values.: po želji normalizira številčne vrednosti. + icons/Continuize.svg: false + Transform: Predelava podatkov + continuize, encode, dummy, numeric, one-hot, binary, treatment, contrast: continuize, encode, dummy, numeric, one-hot, binary, treatment, contrast, kodiranje, navidezni, numerično, binarni, obdelava, kontrast + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + class `Error`: + 'Some chosen methods do not support sparse data: {}': Nekatere izbrane metode ne podpirajo redkih podatkov: {} + def `__init__`: + Categorical Variables: Kategorične spremenljivke + Numeric Variables: Številske spremenljivke + Reset All: Ponastavi vse + autosend: false + def `_prepare_output`: + \n: false + ', ': false + def `_get`: + min: false + max: false + mean: false + std: false + major: false + def `_scaled_vars`: + mean: false + std: false + hint={hint}?!: false + min: false + max: false + def `_continuized_vars`: + major: false + hint={hint}?!: false + {var.name}={value}: false + def `send_report`: + Categorical variables: Kategorične spremenljivke + Numeric variables: Numerične spremenljivke + General preset: Splošna nastavitev + Unlisted: Nerazvrščeno + 'Any unlisted attributes default to general preset, and ': 'Za atribute, ki niso izpisani, je uporabljena privzeta nastavitev, ' + 'unlisted meta attributes and target variables are kept ': 'neizpisani meta atributi pa ostajajo ' + as they are: nespremenjeni. + def `migrate_settings`: + continuous_treatment: false + zero_based: false + cont_var_hints: false + disc_var_hints: false + multinomial_treatment: false + class_treatment: false + __main__: false + heart_disease: false +widgets/data/owcorrelations.py: + class `CorrelationType`: + def `items`: + Pearson correlation: Pearsonova korelacija + Spearman correlation: Spearmanova korelacija + class `KMeansCorrelationHeuristic`: + def `_impute_means`: + ignore: false + class `CorrelationRank`: + def `row_for_state`: + name: false + :: true + N/A: ? + {score[1]:+.3f}: false + class `OWCorrelations`: + Correlations: Korelacije + Compute all pairwise attribute correlations.: Izračuna korelacije med vsemi pari spremenljivk. + icons/Correlations.svg: false + Unsupervised: Nenadzorovano učenje + pearson, spearman: pearson, spearman + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + Features: Spremenljivke + Correlations: Korelacije + class `Information`: + Constant features have been removed.: Konstantne spremenljivke so odstranjene. + class `Error`: + At least two numeric features are needed.: Potrebni sta vsaj dve številski spremenljivki. + At least two instances are needed.: Potrebna sta vsaj dva primera. + def `__init__`: + correlation_type: false + (All combinations): (Vse kombinacije) + feature: false + impute_missing: false + Impute missing values: Nadomesti manjkajoče vrednosti + Replace missing values with means;\n: Nadomesti manjkajoče vrednosti s povprečji;\n + if disabled, rows with missing values for the corre: v nasprotnem primeru so vrstice z manjkajočimi vrednos + sponding variables are ignored: tmi teh spremenljivk izključene + def `set_data`: + removed: false + def `set_actual_data`: + mean: false + def `commit`: + Correlation: Korelacije + uncorrected p: nepopravljen p + FDR: FDR + Feature 1: Spremenljivka 1 + Feature 2: Spremenljivka 2 + Correlations: Korelacije + def `migrate_context`: + selection: false + def `mock_data`: + a: false + abc: false + defghij: false + __main__: false + iris: false +widgets/data/owcreateclass.py: + class `ValueFromDiscreteSubstring`: + def `__setattr__`: + patterns: false + case_sensitive: false + match_beginning: false + variable: false + map_values: false + class `OWCreateClass`: + Create Class: Ustvari razrede + Create class attribute from a string attribute: Ustvari razrede iz besedilne spremenljivke. + icons/CreateClass.svg: false + Transform: Predelava podatkov + create class: create class, razred + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + class: razred + class `Warning`: + Data contains only numeric variables.: Podatki vsebujejo samo številske vrednosti. + class `Error`: + Class name duplicated.: Spremenljivka s tem imenom že obstaja. + Class name should not be empty.: Ime razreda ne more biti prazno. + 'Invalid regular expression: {}': Napačen regularni izraz: {} + def `__init__`: + class_name: false + New Class Name: Ime novega razreda + 'QLineEdit { padding-left: 4px; }': false + Source column and patterns: Izvorni stolpec in vzorci + attribute: false + Name: Ime + Substring: Vzorec + Count: Primerov + +: true + Options: Možnosti + regular_expressions: false + Use regular expressions: Uporabi regularne izraze + match_beginning: false + Match only at the beginning: Vzorec mora biti na začetku + case_sensitive: false + Case sensitive: Loči med velikimi in malimi črkami + Apply: Uveljavi + def `adjust_n_rule_rows`: + def `_add_line`: + ×: true + styleSheet: false + 'color: gray': false + def `class_labels`: + ^C\\d+: true + C{next(class_count)}: true + def `update_counts`: + def `_set_labels`: + {n_matched}: false + + {n_before}: false + {n_before} o: {n_before} od + "f {n_total} matching {pl(n_total, 'instance')} ": " {n_total} {plsi(n_total, 'pojavitve|pojavitev|pojavitev')} " + {pl(n_before, 'is|are')} already covered above.: {plsi(n_before, 'je že pokrita|sta že pokriti|so že pokrite|je že pokritih')} s predhodnimi vzorci. + All matching instances are already covered above: Vse pojavitve so že pokrite s predhodnimi vzorci. + def `_set_placeholders`: + (remaining instances): (preostali primeri) + (unused): (neuporabljeno) + def `send_report`: + def `_cond_part`: + '{class_name} ': false + if {self.attribute.name} contains {patt}: če {self.attribute.name} vsebuje {patt} + otherwise: sicer + def `_count_part`: + already covered above: s predhodnimi vzorci + the single matching instance is {aca}: pojavitev je že pokrita {aca} + both matching instances are {aca}: obe pojavitvi sta že pokriti {aca} + all {n_total} matching instances are {aca}: {plsi(n_total, 'edina|obe|vse|vseh')} {n_total} {plsi(n_total, 'pojavitev je že pokrita|pojavitvi sta že pokriti|pojavitve so že pokrite|pojavitev je že pokritih')} {aca} + {n_matched} {pl(n_matched, 'instance')}: {n_matched} {plsi(n_matched, 'primer')} + {n_matched} matching {pl(n_matched, 'instance')}: {n_matched} {plsi(n_matched, 'pojavitev|pojavitvi|pojavitve|pojavitev')} + " (+{n_already} that {pl(n_already, 'is|are')} {aca})": " (+{n_already}, ki {pl(n_already, 'je že pokrita|sta že pokriti|so že pokrite|je že pokritih')} {aca})" + Input: Vhod + Source attribute: Izvorni stolpec +
    • {_cond_part()}; {_count_part()}
    • : false + Output: Izhod + Class name: Ime razreda +
        {output}
      : false + def `migrate_settings`: + context_settings: false + class_name: false + rules: false + match_beginning: false + case_sensitive: false + regular_expressions: false + __version__: false + __main__: false + zoo: false +widgets/data/owcreateinstance.py: + class `DiscreteVariableEditor`: + def `__init__`: + ?: false + class `ContinuousVariableEditor`: + def `__init__`: + Min/Max cannot be NaN.: false + class `DoubleSpinBox`: + def `textFromValue`: + ?: false + class `TimeVariableEditor`: + yyyy-MM-dd: true + hh:mm:ss: true + def `__init__`: + '{TimeVariableEditor.DATE_FORMAT} ': false + {TimeVariableEditor.TIME_FORMAT}: false + class `OWCreateInstance`: + Create Instance: Sestavi primer + Interactively create a data instance from sample dataset.: Ročno sestavi nov primer. + icons/CreateInstance.svg: false + Transform: Predelava podatkov + create instance, simulator: create instance, simulator, nov + class `Inputs`: + Data: Podatki + Reference: Referenca + class `Outputs`: + Data: Podatki + class `Information`: + 'Variables with only missing values were ': 'Spremenljivke, ki nimajo nobene znane vrednosti ' + removed from the list.: niso na voljo. + Median: Mediana + Mean: Povprečje + Random: Naključno + Input: Vhodni primer + median: false + mean: false + random: false + input: false + name: false + Variable: Spremenljivka + variable: false + Value: Vrednost + header: false + def `__init__`: + Filter...: true + buttonBox: false + append_to_data: false + Append this instance to input data: Dodaj primer k vhodnim podatkom + auto_commit: false + def `_initialize_values`: + median: false + mean: false + random: false + input: false + def `_create_data_from_values`: + created: ustvarjeni + def `_append_to_data`: + __source_widget: false + Source ID: Vir + def `send_report`: + Input: Vhod + Output: Izhod + {var.name}:: false + Values: Vrednosti + __main__: false + housing: false +widgets/data/owcsvimport.py: + T: false + K: false + E: false + OWCSVFileImport: false + class `Options`: + def `__init__`: + utf-8: false + ColumnType: false + .: true + def `__repr__`: + {}{!r}: false + def `as_dict`: + encoding: false + delimiter: false + quotechar: false + doublequote: false + skipinitialspace: false + quoting: false + columntypes: false + rowspec: false + decimal_separator: false + group_separator: false + def `from_dict`: + encoding: false + delimiter: false + quotechar: false + doublequote: false + quoting: false + skipinitialspace: false + columntypes: false + rowspec: false + decimal_separator: false + .: true + group_separator: false + def `spec_as_encodable`: + start: false + stop: false + value: false + def `spec_from_encodable`: + start: false + stop: false + value: false + class `CSVImportDialog`: + def `__init__`: + dialog-button-box: false + def `options`: + decimal: false + group: false + def `restoreDefaults`: + utf-8: false + def `__update_preview`: + rb: false + def `dialog_button_box_set_enabled`: + __p_dialog_button_box_set_enabled: false + class `VarPathItem`: + def `data`: + ${{{vpath.name}}}/{vpath.relpath}: true + ' (missing)': ' (ne obstaja)' + class `ImportItem`: + def `fromPath`: + ImportItem: false + ${{{path.name}}}/{path.relpath}: true + text/csv: false + Text - comma separated: Besedilo - stolpci ločeni z vejicami + *.csv: false + *: false + text/tab-separated-values: false + Text - tab separated: Besedilo - stolpci ločeni s tabulatorji + *.tsv: false + text/plain: false + Text - all files: Vse besedilne datoteke + *.txt: false + class `FileDialog`: + def `filterStr`: + {f.name} ({', '.join(f.globs)}): true + def `default_options_for_mime_type`: + text/csv: false + text/tab-separated-values: false + utf-8: false + utf-16: false + iso8859-1: iso8859-2 + class `OWCSVFileImport`: + CSV File Import: Uvoz datoteke CSV + Import a data table from a CSV formatted file.: Prebere podatkovno tabelo v obliki CSV. + icons/CSVFile.svg: false + Data: Podatki + csv file import, file, load, read, open, csv: csv file import, file, load, read, open, csv, datoteka, nalaganje, branje, odpri + class `Outputs`: + Data: Podatki + Loaded data set.: Prebrani podatki. + Data Frame: Podatkovni okvir + class `Error`: + Unexpected error: Nepričakovana napaka + Encoding error\n: Napaka v kodiranju besedila + 'The file might be encoded in an unsupported encoding or it ': 'Datoteka uporablja nepodprto kodiranje, ' + might be binary: morda pa je binarna + directory: false + filter: false + def `__init__`: + File:: Datoteka: + recent-combo: falsed + Recent files.: Nedavne datoteke. + Recent files…: Nedavne datoteke… + …: true + Browse filesystem: Izberi datoteko + Import any file…: Uvozi poljubno datoteko… + Import relative to workflow file…: Uvozi poljubno datoteko… + Import a file within the workflow file directory: Uvozi datoteko v mapi s trenutnim delotokom + basedir: false + Info: true + Load: Naloži + Import Options…: Nastavitve branja… + 'button-layout: {:d};': false + def `workflowEnvChanged`: + basedir: false + def `_browse_for_missing`: + def `accepted`: + text/plain: false + def `_browse_dialog`: + Open Data File: Odpri datoteko s podatki + directory: false + filter: false + def `store_state`: + directory: false + filter: false + def `_might_be_binary_mb`: + The '{basename}' may be a binary file.\n: "'{basename}' je najbrž binarna datoteka.\n" + Are you sure you want to continue?: Vseeno nadaljujem? + def `_path_must_be_relative_mb`: + Invalid path: Napačna mapa + Selected path is not within '{prefix}': Mapa mora biti znotraj '{prefix}' + def `browse`: + text/plain: false + Import Options: Nastavitve branja + def `_activate_import_dialog_for_item`: + Import Options: Nastavitve uvoza + size: false + def `onfinished`: + size: false + path: false + options: false + def `_local_settings`: + {}.ini: false + def `_note_recent`: + recent: false + path: false + options: false + def `__set_read_progress`: + qint64: false + def `cancel`: + Cancelled: Prekinjeno +
      Cancelled
      Press 'Reload' to try again
      :
      Prekinjeno
      Pritisni 'Ponovno naloži', da ponovim poskus
      + def `__set_running_state`: + Running: V teku + Restart: Ponovno + '
      Loading: {}
      ':
      Branje: {}
      + def `__clear_running_state`: + Reload: Ponovno naloži + def `__set_error_state`: + '
      {basename} was not loaded due to a text encoding ': Datoteke
      {basename} ne morem prebrati, ker je shranjena v neznanem + 'error. The file might be saved in an unknown or invalid ': kodiranju ali pa je binarna. + encoding, or it might be a binary file.
      :
      +
      {basename} was not loaded due to an error:: Datoteke
      {basename} ne morem prebrati: + "

      {err}

      ": true + def `_update_status_messages`: + "{n_instances} {pl(n_instances, 'row')}, ": "{n_instances} {plsi(n_instances, 'vrstica')}, " + "{n_features} {pl(n_features, 'feature')}, ": "{n_features} {plsi(n_features, 'spremenljivka')}, " + {n_meta} {pl(n_meta, 'meta')}: {n_meta} meta {plsi(n_meta, 'spremenljivka')} + def `itemsFromSettings`: + recent: false + path: false + options: false + Could not reconstruct options for '%s': false + def `_replacements`: + basedir: false + def `_restoreState`: + Failed to restore '%s': false + def `canDropUrl`: + text/plain: false + def `migrate_settings`: + compatibility_mode: false + _session_items: false + _session_items_v2: false + def `sniff_csv_with_path`: + utf-8: false + rt: false + def `_open`: + r: false + rb: false + rt: false + .gz: false + .bz2: false + .xz: false + .zip: false + t: false + Expected a single file in the archive.: Pričakujem arhiv z eno samo datoteko. + application/gzip: false + application/zip: false + application/x-xz: false + application/x-bzip: false + def `_mime_type_for_path`: + rb: false + ?: false + .: false + ~: false + nan: false + NAN: false + NaN: false + N/A: false + n/a: false + NA: false + NaT: false + NAT: false + def `load_csv`: + def `dtype`: + float: false + category: false + object: false + X.: false + .: true + decimal: false + thousands: false + float_precision: false + round_trip: false + rb: false + read: false + object: false + coerce: false + {prefix}{column}: false + def `guess_data_type`: + category: false + class `TaskState`: + qint64: false + def `pandas_to_table`: + str: false + UTC: false + M8[ns]: false + "Column '{}' with dtype: {} skipped.": false + __main__: false +widgets/data/owdatainfo.py: + class `OWDataInfo`: + Data Info: Podatki o tabeli + orange.widgets.data.info: false + Display basic information about the data set: Pokaže osnovne podatke o tabeli. + icons/DataInfo.svg: false + Data: Podatki + data info, information, inspect: data info, information, inspect, podatki, pregled + class `Inputs`: + Data: Podatki + def `__init__`: + Data table properties: Lastnosti tabele + Additional attributes: Dodatni podatki + def `data`: + Name: Ime + Location: Lokacija + Size: Velikost + Features: Značilke + Targets: Ciljne spremenljivke + Metas: Meta atributi + Missing data: Manjkajoči podatki + def `set_exact_length`: + Size: Velikost + def `update_info`: + '': false + def `dict_as_table`: + : false + ': false +
      {label}: ': false +
      : false +
      : false + No data.: Ni podatkov. + def `send_report`: + Data table properties: Lastnosti tabele + Additional attributes: Dodatni podatki + def `_p_name`: + name: false + -: true + def `_p_location`: + ' ': false + {key}={value}: false + password: false + SQL Table using connection:
      {connection_string}: Tabels SQL s povezavo:
      {connection_string} + def `_p_size`: + {n} {pl(n, 'row')}: {n} {plsi(n, 'vrstica')} + , {ncols} {pl(ncols, 'column')}: , {ncols} {plsi(ncols, 'stolpec|stolpca|stolpci|stolpcev')} + features: značilke + meta attributes: meta atributi + targets: ciljne spremenljivke + ; sparse {', '.join(sparseness)}: ; redke matrike {', '.join(sparseness)} + def `_p_targets`: + numeric target variable: številska ciljna spremenljivka + 'categorical outcome with ': kategorična ciljna spremenljivka {plsi_sz(nclasses)} + {nclasses} {pl(nclasses, 'class|classes')}: " {z_besedo(nclasses, 6, 'm')} {plsi(nclasses, 'razredom|razredoma|razredi')}" + {disc_class} categorical {pl(disc_class, 'target')}: " {z_besedo(disc_class, 1, 'f')} {plsi(disc_class, '|kategorični ciljni spremenljivki|kategorične ciljne spremenljivke|kategoričnih ciljnih spremenljivk')}" + {cont_class} numeric {pl(cont_class, 'target')}: " {z_besedo(cont_class, 1, 'f')} {plsi(cont_class, '|številski ciljni spremenljivki|številske ciljne spremenljivke|številskih ciljnih spremenljivk')}" + multi-target data,
      : več ciljnih spremenljivk,
      + def `_p_missing`: + (not checked for SQL data): (neznano, podatki iz SQL) + feature: značilkah + targets: ciljni spremenljivki + meta variable: meta atributih + {n_miss} ({n_miss / np.prod(part.shape):.1%}) in {name}: {n_miss} ({n_miss / np.prod(part.shape):.1%}) v {name} + none: ni + ', ': true + def `_pack_var_counts`: + categorical: kategorične + numeric: numerične + text: besedilna + ', ': false + {count} {name}: {name}: {count} + __main__: false + heart_disease: false +widgets/data/owdatasampler.py: + class `OWDataSampler`: + Data Sampler: Vzorčenje + 'Randomly draw a subset of data points ': 'Naključni vzorec ' + from the input dataset.: iz vhodne tabele. + icons/DataSampler.svg: false + Transform: Predelava podatkov + data sampler, random: data sampler, random, naključno + class `Inputs`: + Data: Podatki + class `Outputs`: + Data Sample: Vzorec podatkov + Remaining Data: Preostali podatki + class `Information`: + Compatibility mode\n: Združljivostni način\n + New versions of widget have swapped outputs for cross validation: Nova različica gradnika ima zamenjane izhode pri prečnem preverjanju + class `Warning`: + Stratification failed.\n{}: Stratifikacija ni uspela.\n{} + Sample is bigger than input.: Velikost vzorca ne more presegati števila vhodnih primerov. + class `Error`: + Number of subsets exceeds data size.: Število podmnožic ne more presegati števila vhodnih primerov. + Sample can't be larger than data.: Vzorec ne sme biti večji od podatkov. + Data is too small to stratify.: Podatki so premajhni za stratifikacijo. + Dataset is empty.: Zbirka podatkov je prazna. + def `__init__`: + Sampling Type: Vrsta vzorčenja + sampling_type: false + Fixed proportion of data:: Predpisani delež podatkov: + sampleSizePercentage: false + %d %%: true + Fixed sample size: Predpisana velikost vzorca: + sampleSizeNumber: false + 'Instances: ': 'Število primerov: ' + replacement: false + Sample with replacement: 'Vzorči s ponavljanjem: ' + Cross validation: Prečno preverjanje + Number of subsets:: Število podmnožic: + number_of_folds: false + selectedFold: false + Unused subset:: Neuporabljena podmnožica: + Selected subset:: Izbrana podmnožica: + Bootstrap: Samovzorčenje (bootstrap) + Time:: Čas: + sampleSizeSqlTime: false + ' sec': ' sek' + Percentage: Delež + sampleSizeSqlPercentage: false + ' %': false + Options: Možnosti + use_seed: false + Replicable (deterministic) sampling: Ponovljivo vzorčenje + stratify: false + Stratify sample (when possible): Stratificiraj (če je možno) + sql_dl: false + Download data to local memory: Preberi podatke v pomnilnik + Sample Data: Vzorči + def `send_report`: + Random sample with {self.sampleSizePercentage} % of data: Naključen vzorec {plsi_sz(self.sampleSizePercentage)} {self.sampleSizePercentage} % podatkov + Random data instance: Naključen primer iz podatkov + Random sample with {self.sampleSizeNumber} data instances: Naključen vzorec {plsi_sz(self.sampleSizeNumber)} {self.sampleSizeNumber} {plsi(self.sampleSizeNumber, 'primerom|primeroma|primeri')} + , with replacement: , s ponavljanjem + '{self.number_of_folds}-fold cross-validation ': '{self.number_of_folds}-kratno prečno preverjanje ' + without subset #{self.selectedFold}: brez podmnožice #{self.selectedFold} + Bootstrap: Samovzorčenje + , stratified (if possible): , stratificirano (če je možno) + , deterministic: , ponovljivo + Sampling type: Vrsta vzorčenja + Input: Vhod + {len(self.data)} {pl(len(self.data), 'instance')}: {len(self.data)} {plsi(len(self.data), 'primer')} + Sample: Vzorec + {self.sampled_instances} {pl(self.sampled_instances, 'instance')}: {self.sampled_instances} {plsi(self.sampled_instances, 'primer')} + Remaining: Preostali + {self.remaining_instances} {pl(self.remaining_instances, 'instance')}: {self.remaining_instances} {plsi(self.remaining_instances, 'primer')} + def `migrate_settings`: + sampling_type: false + compatibility_mode: false + __main__: false + iris: false +widgets/data/owdatasets.py: + def `format_exception`: + \n: false + class `Namespace`: + def `__init__`: + English: false + class `OWDataSets`: + Datasets: Zbirke podatkov + Load a dataset from an online repository: Naloži podatke s spletnega skladišča. + icons/DataSets.svg: false + orangecontrib.prototypes.widgets.owdatasets.OWDataSets: false + datasets, online, data, sets: datasets, online, data, sets, zbirka, podatki + https://datasets.biolab.si/: false + datasets: false + English: Slovenščina + All Languages: (Vsi jeziki) + (General): (Splošno) + (Show all): (Prikaži vse) + islocal: false + label: false + title: false + Title: Ime + size: false + Size: Velikost + instances: false + Instances: Primerov + variables: false + Variables: Spremenljivke + target: false + Target: Ciljna spr. + tags: false + Tags: Oznake + class `Error`: + Could not fetch dataset list: Ne morem prebrati seznama + class `Warning`: + 'Could not fetch datasets list, only local ': Ne morem prebrati seznama, kažem + cached datasets are shown: le lokalno shranjene podatke + class `Outputs`: + Data: Podatki + def `__init__`: + label: false + _header_index: false + Search for data set ...: Poišči podatke ... + Typing four letters or more overrides domain and language filters: Vpišite vsaj štiri črke, da prekličete filtre po področju in jeziku + 'Show data sets in ': 'Prikaži zbirke podatkov v jeziku ' + Domain:: Področje: + Press Return or double-click to send: Pritisni Enter ali dvoklikni za izbor + Description: Opis + splitter_state: false + Initializing: Zaganjam + def `_parse_info`: + version: false + def `update_domain_combo`: + sc: false + def `update_model`: + ' ': false + ' ': false + ', ': false + sc: false + def `__set_index`: + Error while fetching updated index: false + def `set_model`: + X: false + '888 bytes ': '888 bajtov ' + '9999.9 MB ': false + 100000000: false + 1000000: false + def `__update_cached_state`: + ' ': false + ' ': false + def `commit`: + Fetching...: Pobiram... + def `__commit_complete`: + Error:: Napaka: + def `migrate_settings`: + selected_id: false + \\: false + /: false + def `variable_icon`: + categorical: false + x: false + numeric: false + def `make_html_list`: + '"margin: 5px; text-indent: -40px; margin-left: 40px;"': false + def `format_item`: +

      {i}

      : false + \n: false + def `description_html`: + ' ({datainfo.year})': true + , from {datainfo.source}: , z {datainfo.source} + {escape(datainfo.title)}{year}{source}: true +

      {datainfo.description}

      : true + See Also\n: Glej tudi\n + : true + References\n: Viri\n + \n: true + __main__: false +widgets/data/owdiscretize.py: + \s*,\s*: true + year: leto|leti|leta|let + month: mesec|meseca|meseci|mesecev + day: dan|dneva|dnevi|dni + week: teden|tedna|tedni|tednov + hour: ura + minute: minuta + second: sekunda + invalid width: nepravilna širina + too many intervals: preveč intervalov + def `_fixed_width_discretization`: + .: true + def `_mdl_discretization`: + no discrete class: razred ni kategoričen + def `_custom_discretization`: + invalid cuts: nepravilne meje + Use general preset: Uporabi splošno nastavitvo + preset: splošno + Treat the variable as defined in general preset: Spremeni, kot je določeno v splošni nastavitvi + Keep numeric: Ohrani številsko + keep: ohrani + Keep the variable as is: Pusti spremenljivko tako, kot je + Entropy vs. MDL: Entropija proti MDL + entropy: entropija + Split values until MDL exceeds the entropy (Fayyad-Irani)\n: Deli, dokler je zmanjšanje entropije večje od MDL (Fayyad-Irani)\n + (requires discrete class variable): (zahteva kategorično ciljno spremenljivko) + 'Equal frequency, intervals: ': 'Enaka pogostost, št. intervalov: ' + equal freq, k={}: pogostost, k={} + Create bins with same number of instances: Določi intervale z enakim številom primerov + freq_spin: false + 'Equal width, intervals: ': Enaka širina, št. intervalov + equal width, k={}: širina, k={} + Create bins of the same width: Sestavi intervale enake širine + width_spin: false + Remove: Odstrani + remove: odstrani + Remove variable: Odstrani spremenljivko + 'Natural binning, desired bins: ': 'Naravni intervali, želeno število: ' + binning, desired={}: naravni, število={} + 'Create bins with nice thresholds; ': 'Sestavi intervale z lepimi mejami; ' + try matching desired number of bins: število intervalov je čim bližje želenemu + binning_spin: false + 'Fixed width: ': 'Določena širina: ' + fixed width {}: širina {} + Create bins with the given width (not for time variables): Sestavi intervale določene širine + width_line: false + 'Time interval: ': 'Časovni interval: ' + time interval, {} {}: čas, {} {} + Create bins with the give width (for time variables): Sestavi intervale z določeno širino (za časovne spremenljivke) + width_time_line: false + width_time_unit: false + 'Custom: ': 'Ročno: ' + 'custom: {}': ročno: {} + Use manually specified thresholds: Uporabi ročno določene meje + threshold_line: false + def `format_desc`: + {time_units[unit]}(s): {plsi(1, time_units[unit])} + {pl(width, time_units[unit])}: {plsi(width, time_units[unit])} + class `DiscDomainModel`: + def `data`: + '{var.name}: ': false +

      {tip}: false + {",  ".join(values)}

      : false +
      : false + - {value}
      : false + ': ': false + ' ': false + class `DefaultDiscModel`: + def `__init__`: + ★: true + def `data`: + 'General preset: ': 'Splošna nastavitev: ' + Default setting for variables without specific setings: Privzeta metoda za spremenljivke brez specifičnih nastavitev + class `IncreasingNumbersListValidator`: + def `validate`: + +-., 0123456789: false + ' ': false + ', ': true + def `show_tip`: + ::show_tip_qlabel: false + tip-label: false + QTipLabel: false + hide-timer: false + DState: false + method: false + points: false + disc_var: false + Default: false + Leave: false + MDL: false + EqualFreq: false + k: false + EqualWidth: false + Custom: false + class `OWDiscretize`: + Discretize: Diskretizacija + Discretize numeric variables: Diskretizacija številskih spremenljivk. + Transform: Predelava podatkov + icons/Discretize.svg: false + discretize, bin, categorical, nominal, ordinal: discretize, bin, categorical, nominal, ordinal, kategorije, intervali, številske, ordinalne, spremenljivke + class `Inputs`: + Data: Podatki + Input data table: Vhodni podatki + class `Outputs`: + Data: Podatki + Table with categorical features: Tabela z diskretnimi spremenljivkami + def `__init__`: + autosend: false + def `_create_buttons`: + def `manual_cut_editline`: + e.g. 0.0, 0.5, 1.0: npr. 0.0, 0.5, 1.0 +

      : false + Enter cut points as a comma-separate list of \n: Navedite prage kot seznam števil, ločenih z vejico \n + strictly increasing numbers e.g. 0.0, 0.5, 1.0).

      : v naraščajočem vrstnem redu, npr. 0.0, 0.5, 1.0.

      + {unit}(s): {plsi(1, unit)} + CC: KP + Copy the current cut points to manual mode: Uporabi trenutne vrednosti kot ročno nastavitev + def `_update_discretizations`: + values: false + def `_discretize_var`: + ': ': : + ': ': : + ' <{dvar}>': false + ' ': ' ' + (: false + ', ': true + ): false + def `_copy_to_manual`: + ', ': true + def `send_report`: + ': ': true + {name} ({format_desc(desc.hint)}): false + ', ': true + Variables: Spremenljivke + def `migrate_settings`: + default_method: false + default_method_name: false + default_k: false + default_cutpoints: false + context_settings: false + saved_var_states: false + Leave: false + Keep: false + Default: false + Custom: false + ', ': false + {x:g}: false + var_hints: false + __main__: false + heart_disease: false +widgets/data/oweditdomain.py: + V: false + H: false + "%a Weekday abbreviated name +%A Weekday full name +%w Weekday as a number (0=Sunday, 6=Saturday) +%d Day of the month (01-31) +%b Month abbreviated name +%B Month full name +%m Month as a number (01-12) +%y Year without century (00-99) +%Y Year with century +%H Hour (00-23) +%I Hour (01-12) +%p AM or PM +%M Minute (00-59) +%S Second (00-59) +%f Microsecond (000000-999999) +%z UTC offset in the form +HHMM or -HHMM +%Z Time zone name +%j Day of the year (001-366) +%U Week number of the year (Sunday as the first day of the week) +%W Week number of the year (Monday as the first day of the week) +%c Locale's appropriate date and time representation +%x Locale's appropriate date representation +%X Locale's appropriate time representation": false + class `Categorical`: + Categorical: false + name: false + categories: false + annotations: false + class `Real`: + Real: false + name: false + format: false + annotations: false + class `String`: + String: false + name: false + annotations: false + class `Time`: + Time: false + name: false + annotations: false + class `Rename`: + Rename: false + name: false + class `CategoriesMapping`: + CategoriesMapping: false + mapping: false + class `Annotate`: + Annotate: false + annotations: false + class `Unlink`: + Unlink: false + class `CategoricalVector`: + CategoricalVector: false + vtype: false + data: false + class `RealVector`: + RealVector: false + vtype: false + data: false + class `StringVector`: + StringVector: false + vtype: false + data: false + class `TimeVector`: + TimeVector: false + vtype: false + data: false + class `AsString`: + AsString: false + class `AsContinuous`: + AsContinuous: false + def `__call__`: + g: false + class `AsCategorical`: + AsCategorical: false + class `StrpTime`: + StrpTime: false + label: false + formats: false + have_date: false + have_time: false + class `TimeUnit`: + TimeUnit: false + label: false + unit: false + class `AsTime`: + def `unit`: + s: false + def `__call__`: + us: false + Y0: false + def `data`: + coerce: false + def `formatter_for_dtype`: + __formatter: false + def `masked_unique`: + O: false + masked value if present must be in last position: false + def `categorical_from_vector`: + __formater: false + ?: false + def `categorize_unique`: + masked value if present must be last: false + class `DictItemsModel`: + def `__init__`: + Key: Ime oznake + Value: Vrednost + class `BaseEditor`: + def `__init__`: + editor-form-layout: false + class `VariableEditor`: + def `__init__`: + name-editor: false + Name:: Ime: + Unlink variable from its source variable: Prekini povezavo z izvorno spremenljivko + 'Make Orange forget that the variable is derived from ': S tem bo Orange "pozabil", da je spremenljivka\n + another.\n: izračunana iz vrednosti neke druge spremenljivke.\n + 'Use this for instance when you want to consider variables ': To lahko uporabimo, da, na primer, dosežemo, da bo\n + 'with the same name but from different sources as the same ': dve enako poimenovani spremenljivki iz različnih virov\n + variable.: obravnaval kot isto spremenljivko. + annotation-pairs-edit: false + annotate-action-group: false + +: true + action-add-label: false + Add a new label.: Dodaj novo oznako. + \N{MINUS SIGN}: true + action-delete-label: false + Remove selected label.: Odstrani izbrano oznako. + Add: Dodaj + Remove: Odstrani + Labels:: Oznake: + class `GroupItemsDialog`: + other: ostalo + def `__init__`: + Group selected values: Združi izbrane vrednosti + Group values with less than: Združi vrednosti z manj kot + Group all except: Združi vse vrednosti, razen + selected_radio: false + occurrences: pojavitvami + most frequent values: najpogostejših + frequent_abs_spin: false + X: false + frequent_rel_spin: false + ' %': true + n_values_spin: false + 'New value name: ': 'Ime nove vrednosti: ' + name_line_edit: false + dialog-button-box: false + def `get_dialog_settings`: + frequent_abs_spin: false + frequent_rel_spin: false + n_values_spin: false + name_line_edit: false + selected_radio: false + class `CountedListModel`: + def `__counts`: + key value '{key}' is not hashable: false + class `CategoriesEditDelegate`: + def `initStyleOption`: + (dropped): (odstranjeno) + (added): (dodano) + {sourcename} \N{RIGHTWARDS ARROW} {text}: false + (merged): (združeno) + ' ': false + def `createEditor`: + QStyleOptionViewItem: false + def `updateEditorGeometry`: + QStyleOptionViewItem: false + class `DiscreteVariableEditor`: + def `__init__`: + action-group-categories: false + Move up: Pomakni višje + \N{UPWARDS ARROW}: true + Move the selected item up.: Pomakni izbrano vrednost višje. + Move down: Premakni nižje + \N{DOWNWARDS ARROW}: true + Move the selected item down.: Premakni izbrano vrednost nižje. + Add: Dodaj + +: true + action-add-item: false + Append a new item.: Dodaj novo vrednost + Remove item: Odstrani vrednost + \N{MINUS SIGN}: true + action-remove-item: false + Delete the selected item.: Odstrani izbrano vrednost + Rename selected items: Preimenuj izbrane vrednosti + =: true + action-rename-selected-items: false + Rename selected items.: Preimenuj izbrane vrednosti. + Merge: Združi + M: Z + action-activate-merge-dialog: false + Merge infrequent items.: Združi redkejše vrednosti + Remove: Odstrani + Merge selected items: Združi izbrane vrednosti + Merge infrequent: Združi redke + Values:: Vrednosti: + def `set_data_categorical`: + 'invalid mapping: {tr.mapping}': false + def `get_data`: + {mapping}, {var}: false + def `_remove_category`: + invalid state '{state}' for {index.row()}: false + def `_merge_categories`: + Import Options: Združevanje + class `TimeVariableEditor`: + Custom format: false + Detect automatically: Zaznaj samodejno + Default: Privzeto + s: false + Nanosecond: Nanosekunda + ns: false + Microsecond: Mikrosekunda + us: false + Millisecond: Milisekunda + ms: false + Second: Sekunda + Minute: Minuta + m: false + Hour: Ura + h: false + Day: Dan + D: false + Month: Mesec + M: false + Year: Leto + Y0: false + Years since 1970: Leto od 1970 + Y: false + def `__init__`: + custom-format-line-edit: false + %Y-%m-%d %H:%M:%S: false + Format:: Oblika: + Unit:: Enota: + Custom format:: Prilagojen format: + def `get_data`: + %(-?)d|%(b|B)|%(-?)m|%(y|Y)|%(-?)j|%(-?)U|%(-?)W|%(a|A)|%w: false + %(-?)H|%(-?)I|%p|%(-?)M|%(-?)S|%f: false + class `VariableEditDelegate`: + categorical: kategorično + numeric: številsko + string: besedilno + time: časovno + def `initStyleOption`: + {var.name} \N{RIGHTWARDS ARROW} {tr.name}: false + ' (reinterpreted as ': ' (pretolmačena v ' + {self.ReinterpretNames[type(tr)]}): true + def `helpEvent`: + Name `{name}` is duplicated: Ime '{name}' je podvojeno. + class `ReinterpretVariableEditor`: + def `__init__`: + def `decorate`: + type-combo: false + Categorical: Kategorična + Numeric: Številska + Text: Besedilna + Time: Časovna + (Restore original): (Obnovi izvorno) + Type:: Vrsta: + def `_set_data_single`: + type-combo: false + def `_set_data_multi`: + type-combo: false + def `__reinterpret_activated_single`: + type-combo: false + def `__reinterpret_activated_multi`: + type-combo: false + class `OWEditDomain`: + Edit Domain: Uredi domeno + Rename variables, edit categories and variable annotations.: Preimenuj spremenljivke, uredi kategorije in oznake. + icons/EditDomain.svg: false + edit domain, rename, drop, reorder, order: edit domain, rename, drop, reorder, order, preimenuj, odstrani, preuredi, uredi + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + class `Error`: + A variable name is duplicated.: Ime spremenljivke je podvojeno. + class `Warning`: + Failed to restore transform {} for column {}: Ne morem obnoviti transformacije {} za stolpec {} + Categories mapping for {} does not apply to current input: Preslikava kategorij za {} ne velja za trenutne podatke. + def `__init__`: + Variables: Spremenljivke + Edit: Uredi + output_table_name: false + 'Output table name: ': Ime izhodne tabele: + Reset All: Povrni vse + button-reset-all: false + Reset all variables to their input state.: Povrni vse vrednosti v začetno stanje. + Reset Selected: Povrni izbrane + button-reset: false + Rest selected variable to its input state.: Povrni izbrane spremenljivke v začetno stanje. + Apply: Potrdi + button-apply: false + Apply changes and commit data on output.: Potrdi spremembe in pošlji podatke na izhod. + def `_update_restore_warnings`: + ', ': false + def `_set_modified`: + button-apply: false + def `send_report`: +
        : false +
      • {part}
      • : false +
      : false + No changes: Ni sprememb + def `migrate_context`: + domain_change_hints: false + Orange.data.variable: false + DiscreteVariable: false + Categorical: false + TimeVariable: false + Time: false + ContinuousVariable: false + Real: false + f: false + StringVariable: false + String: false + _domain_change_store: false + def `migrate_settings`: + context_settings: false + __version__: false + _domain_change_store: false + output_table_name: false + _domain_change_hints: false + StrpTime: false + AsTime: false + def `enumerate_columns`: + x: false + y: false + m: false + def `table_column_data`: + __formatter: false + ?: false + def `report_transform`: + C: false + N: false + S: false + T: false + def `type_char`: + ?: false + def `strike`: + {escape(text)}: false + def `i`: + {escape(text)}: false + def `text`: + {escape(text)}: false + '{var.name} → ({type_char(reinterpret)}) ': false + {rename.name if rename is not None else var.name}: false + {var.name} → {rename.name}: false + (unlinked from source): (odvezana od vira) + Values: Vrednosti +  : false + (added): (dodana) + ' → ': false + Labels: Oznake + : true + ' : ': true + : true + (new): (nova) + "
      {header}
      ": false +
      {title}:
      : false +
      \n: false + "
      ": false +
      : false + \n: false + def `abstract`: + f: false + def `_parse_attributes`: + {item[0]}={item[1]}: false + nan: false + def `as_float_or_nan`: + unsafe: false + def `apply_reinterpret_c`: + ns: false + us: false + ms: false + s: false + m: false + h: false + def `datetime64_to_epoch`: + M8[us]: false + M8[D]: false + 1970-01-01: false + class `ReparseTimeTransform`: + def `transform`: + coerce: false + class `ReinterpretTimeWithUnit`: + def `transform`: + Y0: false + M8[Y]: false + M8[{self.unit}]: false + def `column_str_repr_discrete`: + ?: false + def `column_str_repr_string`: + ?: false + __main__: false + iris: false +widgets/data/owfeatureconstructor.py: + FeatureDescriptor: false + name: false + expression: false + meta: false + ContinuousDescriptor: false + number_of_decimals: false + DateTimeDescriptor: false + DiscreteDescriptor: false + values: false + ordered: false + StringDescriptor: false + def `selected_row`: + invalid 'selectionMode': false + class `FeatureEditor`: + ' +Use variable names as values in expression. +Categorical features are passed as strings +(note the change in behaviour from Orange 3.30). + +': false + _: false + str: false + float: false + int: false + len: false + abs: false + max: false + min: false + def `__init__`: + Name...: Ime... + Meta attribute: Meta atribut + Expression...: Izraz... + Select Column: Izberi stolpec + Select Function: Izberi funkcijo + def `setEditorData`: + Select Feature: Izberi spremenljivko + def `on_funcs_changed`: + atan2: false + fmod: false + ldexp: false + log: false + pow: false + copysign: false + hypot: false + (,): false + e: false + pi: false + (): false + class `ContinuousFeatureEditor`: + A numeric expression\n\n: Številski izraz\n\n + class `DateTimeFeatureEditor`: + 'Result must be a string in ISO-8601 format ': 'Rezultat mora biti niz v formatu ISO-8601 ' + (e.g. 2019-07-30T15:37:27 or a part thereof),\n: (npr. 2019-07-30T15:37:27 ali del tega),\n + or a number of seconds since Jan 1, 1970.: ali število sekund od 1. januarja 1970 + class `DiscreteFeatureEditor`: + Result must be a string, if values are not explicitly given\n: Če vrednosti niso eksplicitno podane spodaj, mora biti rezultat niz, + or a zero-based integer indices into a list of values given below.: sicer pa indeks elementa v spodnjem seznamu (začenši z 0). + def `__init__`: + 'If values are given, above expression must return zero-based ': 'Če so vrednosti podane, mora biti rezultat izraza ' + integer indices into that list.: indeks elementa v te seznamu (začenši z 0). + A, B ...: true + Values (optional): Vrednosti (opcijsko) + def `setEditorData`: + ', ': false + ,: false + \,: false + def `editorData`: + (?: false + eval: false + Ellipsis: false + False: false + None: false + True: false + abs: false + all: false + any: false + acsii: false + bin: false + bool: false + bytearray: false + bytes: false + chr: false + complex: false + dict: false + divmod: false + enumerate: false + filter: false + float: false + format: false + frozenset: false + getattr: false + hasattr: false + hash: false + hex: false + id: false + int: false + iter: false + len: false + list: false + map: false + max: false + memoryview: false + min: false + next: false + object: false + oct: false + ord: false + pow: false + range: false + repr: false + reversed: false + round: false + set: false + slice: false + sorted: false + str: false + sum: false + tuple: false + type: false + zip: false + normalvariate: false + gauss: false + expovariate: false + gammavariate: false + betavariate: false + lognormvariate: false + paretovariate: false + vonmisesvariate: false + weibullvariate: false + triangular: false + uniform: false + nanmean: false + nanmin: false + nanmax: false + nansum: false + nanstd: false + nanmedian: false + nancumsum: false + nancumprod: false + nanargmax: false + nanargmin: false + nanvar: false + mean: false + std: false + median: false + cumsum: false + cumprod: false + argmax: false + argmin: false + var: false + class `FeatureFunc`: + DType: false + def `__init__`: + eval: false + def `__repr__`: + {0.__name__}{1!r}: false + __main__: false + iris: false +widgets/data/owfeaturestatistics.py: + def `format_time_diff`: + ~{years} years: ~{years} {plsi(years, "leto|leti|leta|let")} + ~{months} months: ~{months} {plsi(months, "mesec|meseca|meseci|mesecev")} + ~{weeks} weeks: ~{weeks} {plsi(weeks, "teden|tedna|tedni|tednov")} + ~{days} days: ~{days} {plsi(days, "dan|dneva|dnevi|dni")} + ~{hours} hours: ~{hours} {plsi(hours, "ura|uri|ure|ur")} + ~{minutes} minutes: ~{minutes} {plsi(minutes, "minuta")} + {seconds} seconds: ~{seconds} {plsi(seconds, "sekunda")} + class `FeatureStatisticsTableModel`: + class `Columns`: + def `name`: + Column: Stolpec + Distribution: Porazdelitev + Mean: Srednja vrednost + Mode: Najpogostejša + Median: Mediana + Dispersion: Razpršitev + Min.: Minimum + Max.: Maksimum + Missing: Manjkajoče + def `__compute_statistics`: + def `__mode`: + C: false + def `get_statistics_table`: + Entropy: Entropija + Column: Stolpec + Mode: Najpogostejša + {self.table.name} (Column Statistics): {self.table.name} (Statistika stolpcev) + def `_sortColumnData`: + Data should be at most 2-dimensional: false + def `_argsortData`: + stable: false + 'We do not deal with non numeric values in sorting by ': false + multiple values: false + Add an empty column of zeros at index -2 to accomodate NaNs: false + def `data`: + def `display`: + def `format_zeros`: + {0:.{num_decimals}f}: false + def `render_value`: + ∞: false + '#ccc': false + {self._dispersion[row]:.3g}: false + {missing} ({perc} %): false + class `OWFeatureStatistics`: + Column Statistics: Statistika stolpcev + Show basic statistics for columns.: Osnovna statistika stolpcev. + icons/FeatureStatistics.svg: false + feature, variable: spremenljivka, stolpec + class `Inputs`: + Data: Podatki + class `Outputs`: + Reduced Data: Izbrani podatki + Statistics: Statistika + def `__init__`: + None: Enobarvno + color_var: false + Color:: Barva: + auto_commit: false + def `migrate_context`: + selected_rows: false + selected_vars: false + __main__: false + iris: false +widgets/data/owfile.py: + Determine type from the file extension: Določi vrsto iz končnice datoteke + def `add_origin`: + type: false + origin: false + class `OWFile`: + File: Datoteka + orange.widgets.data.file: false + 'Read data from an input file or network ': Preberi podatke iz datoteke ali omrežja. + and send a data table to the output.: "" + icons/File.svg: false + Data: Podatki + file, load, read, open: file, load, read, open, datoteka, naloži, preberi, odpri + class `Outputs`: + Data: Podatki + Attribute-valued dataset read from the input file.: Podatki iz datoteke ali omrežja + sample-datasets: false + iris.tab: false + titanic.tab: false + housing.tab: false + heart_disease.tab: false + brown-selected.tab: false + zoo.tab: false + class `Information`: + No file selected.: Datoteka ni izbrana. + class `Warning`: + The file is too large to load automatically.: Datoteka je prevelika za samodejno branje. + ' Press Reload to load.': " Pritisnite 'Ponovno naloži' za branje." + Read warning:\n{}: Opozorilo ob branju:\n{} + Categorical variables with >100 values may decrease performance.: Kategorične spremenljivke z veliko vrednostmi lahko upočasnijo delovanje. + 'Some variables have been renamed ': Spremenljivke s podvojenimi imeni so preimenovane. + to avoid duplicates.\n{}: \n{} + Most widgets do not support multiple targets: Večina gradnikov ne podpira večih ciljnih spremenljivk. + class `Error`: + File not found.: Datoteka ni najdena. + Missing reader.: Bralnik za ta tip ne obstaja. + Select file type.: Izberite vrsto datoteke. + Error listing available sheets.: Napaka ob ustvarjanju seznama listov. + Read error:\n{}: Napaka ob branju:\n{} + Read error, possibly due to incorrect choice of file type:\n{}: Napaka ob branju, morda zaradi napačne izbire vrste datoteke:\n{} + 'Use CSV File Import widget for advanced options ': 'Uporabi Bralnik CSV za napredne možnosti ' + for comma-separated files: za datoteke ločene z vejico + use-csv-file-import: falxe + 'This widget loads only tabular data. Use other widgets to load ': 'Ta gradnik nalaga le tabelarične podatke. Uporabi druge gradnike za branje ' + other data types like models, distance matrices and networks.: drugih vrst podatkov, kot so modeli, matrike razdalj in mreže. + other-data-types: false + def `__init__`: + read: false + EXTENSIONS: false + def `group_readers_per_addon_key`: + def `package`: + .: false + Orange.data: false + 0: false + Source: Vir + source: false + File:: Datoteka: + ...: true + Reload: Ponovno naloži + Sheet: List + URL:: true + File Type: Vrsta datoteke + Info: true + No data loaded.: Podatki niso naloženi. + Columns (Double click to edit): Stolpci (dvoklikni za urejanje) + Reset: Povrni + Apply: Uveljavi + Browse documentation datasets: Odpri podatke iz dokumentacije + def `_url_set`: + http://: false + def `browse_file`: + File: Datoteka + Cannot find the directory with documentation datasets: Ne najdem mape s podatki iz dokumentacije + ~/: false + *: false + def `load_data`: + No data.: Ni podatkov. + def `_get_reader`: + Can not find reader "{qname}": Ne najdem bralnika "{qname}" + def `_describe`: + attributes: false + Name: Ime + Description: Opis + {descs[0]}: false +

      {'
      '.join(descs)}

      : false +

      {len(table)} {pl(len(table), 'instance')}:

      {len(table)} {plsi(len(table), 'primer')} +
      {nattrs} {pl(nattrs, 'feature')} {missing_in_attr}:
      {nattrs} {plsi(nattrs, 'spremenljivka')} {missing_in_attr} +
      Regression; numerical class {missing_in_class}:
      Regresija {missing_in_class} + '
      Classification; categorical class ': '
      Klasifikacija: ' + with {nvals} {pl(nvals, 'value')} {missing_in_class}: {plsi_sz(nvals)} {nvals} {plsi(nvals, 'razredom|razredoma|razredi')} {missing_in_class} + '
      Multi-target; ': '
      Večrazredni; ' + "{ntargets} target {pl(ntargets, 'variable')} ": "{ntargets} {plsi(ntargets, 'ciljna spremenljivka|ciljni spremenljivki|ciljne spremenljivke|ciljnih spremenljivk')} " + {missing_in_class}: true +
      Data has no target variable.:
      Podatki nimajo ciljne spremenljivke. +
      {nmetas} {pl(nmetas, 'meta attribute')}:
      {nmetas} {plsi(nmetas, 'meta spremenljivka')} +

      : false + Timestamp: false + "

      First entry: {table[0, 'Timestamp']}
      ":

      Prvi vnos: {table[0, 'Timestamp']}
      + "Last entry: {table[-1, 'Timestamp']}

      ": Zadnji vnos: {table[-1, 'Timestamp']}

      + def `retrieveSpecificSettings`: + modified_variables: false + def `apply_domain_edit`: + attributes: false + "Renamed: {', '.join(renamed)}": Preimenovano: {', '.join(renamed)} + def `send_report`: + def `get_ext_name`: + unknown: neznan + File: Datoteka + No file.: Ni datoteke. + ~: false + /: false + \\: false + ' ({self.sheet_combo.currentText()})': false + File name: Ime datoteke + Format: Oblika + Data: Podatki + Resource: Vir + class `OWFileDropHandler`: + def `canDropUrl`: + http: false + https: false + ftp: false + def `parametersFromUrl`: + recent_paths: false + source: false + recent_urls: false + __main__: false +widgets/data/owgroupby.py: + def `concatenate`: + ' ': false + def `var`: + 1970-01-01: false + UTC: false + 1s: false + Mean: Povprečje + mean: false + Median: Mediana + median: false + Q1: true + Q3: true + Min. value: Najmanjša vrednost + min: false + Max. value: Največja vrednost + max: false + Mode: Najpogostejša + Standard deviation: Standardna deviacija + Variance: Varianca + Sum: Vsota + sum: false + Concatenate: Stakni + Span: Razpon + First value: Prva vrednost + first: false + Last value: Zadnja vrednost + last: false + Random value: Naključna + Count defined: Število znanih + count: false + Count: Velikost skupine + size: false + Proportion defined: Delež znanih + def `_run`: + Aggregating: Računam + Attributes: Spremenljivke + Aggregations: Funkcije + class `VarTableModel`: + def `__init__`: + OWGroupBy: false + def `data`: + ' and {len(aggs) - 3} more': " in {len(aggs) - 3} {plsi(len(aggs) - 3, 'druga|drugi|druge|drugih')}" + ', ': false + class `OWGroupBy`: + Group by: Združi po + Transform: Predelava podatkov + icons/GroupBy.svg: false + aggregate, group by: aggregate, group by, agregiraj, skupine + class `Inputs`: + Data: Podatki + Input data table: Tabela vhodnih podatkov + class `Outputs`: + Data: Podatki + Aggregated data: Agregirani podatki + class `Error`: + {}: false + def `__init_control_area`: + gb_attrs: false + Group by: Združi po + auto_commit: false + def `__init_main_area`: + ' ': false + Aggregations: Funkcije + def `migrate_context`: + aggregations: false + Sum: false + __main__: false + iris: false +widgets/data/owimpute.py: + class `DisplayFormatDelegate`: + def `initStyleOption`: + method: false + class `AsDefault`: + Default (above): Privzeto (zgoraj) + {var.name}: false + RowMask: false + mask: false + def `var_key`: + {}.{}: false + class `OWImpute`: + Impute: Nadomesti neznane vrednosti + Impute missing values in the data table.: Nadomesti neznane vrednosti v podatkih. + icons/Impute.svg: false + impute, substitute, missing: impute, substitute, missing, vstavi, nadomesti, zamenjaj, manjkajoče + Transform: Predelava podatkov + class `Inputs`: + Data: Podatki + Learner: Model + class `Outputs`: + Data: Podatki + class `Error`: + Imputation failed for '{}': Nadomeščanje za '{}' ni uspelo. + Model based imputer does not work for sparse data: Nadomeščanje z modelom ne deluje za redke podatke. + class `Warning`: + Default method can not handle '{}': Privzeta metoda ne deluje za '{}' + def `__init__`: + Default Method: Privzeta metoda + Fixed values; numeric variables:: Privzeta vrednost; številske spremenljivke: + default_numeric_value: false + , time:: , časovne: + default_time: false + Individual Attribute Settings: Nastavitve za posamične spremenljivke + Restore All to Default: Nastavi vse na privzeto + autocommit: false + def `__commit_finish`: + def `get_variable`: + Error for %s: false + def `create_data`: + Error: false + Unknown: false + def `send_report`: + {} ({}): true + Default method: Privzeta metoda + Specific imputers: Posamične nastavitve + ', ': true + Method: Metoda + def `__sample_data`: + c{i}: true + t{i}: true + __main__: false + brown-selected: false +widgets/data/owmelt.py: + item: stvar + value: vrednost + row: vrstica + class `MeltContextHandler`: + def `match`: + idvar: false + def `encode_setting`: + idvar: false + def `decode_setting`: + idvar: false + class `OWMelt`: + Melt: Raztopi + Convert wide data to narrow data, a list of item-value pairs: Spremeni široke podatke v ozke, to je, seznam parov (stvar, vrednost). + Transform: Predelava podatkov + icons/Melt.svg: false + melt, shopping list, wide, narrow: melt, shopping list, wide, narrow, nakupovalni seznam, širok, ozek + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + class `Error`: + No features to melt: Ni stolpcev za raztapljanje + class `Information`: + No columns with unique values\n: Ni stolpcev z neponovljenimi vrednostmi.\n + Only columns with unique values are useful for row identifiers.: Samo stolpci z neponovljenimi vrednostmi so uporabni kot identifikatorji vrstic. + def `__init__`: + Unique Row Identifier: Identifikator vrstice + Row number: Številka vrstice + idvar: false + A column with identifier, like customer's id: Stolpec z identifikatorjem, npr. številka nakupa + Filter: true + only_numeric: false + Ignore non-numeric features: Uporabi le številske spremenljivke + exclude_zeros: false + Exclude zero values: Preskoči ničelne vrednosti + Besides missing values, also omit items with zero values: Poleg neznanih vrednosti preskoči tudi stvari z vrednostjo 0. + Names for generated features: Imena stolpcev v novih podatkih + Item:: Stvar: + item_var_name: false + 'padding-left: 3px': false + Value:: Vrednost: + value_var_name: false + def `send_report`: + Settings: Nastavitve + Row identifier: Identifikator vrstice + Ignore non-numeric features: Uporabi le številske vrednosti + Exclude zero values: Preskoči ničelne vrednosti + Output: Izhod + def `_store_output_desc`: + Item column: Stolpec s stvarjo + Value column: Stolpec z vrednostjo + Number of items: Število stvari + __main__: false + zoo: false +widgets/data/owmergedata.py: + Instance id: Identiteta vrstice + Row index: Številka vrstice + class `ConditionBox`: + RowItems: false + pre_label: false + left_combo: false + in_label: false + right_combo: false + remove_button: false + def `add_row`: + and: in + ×: true + def `add_plus_row`: + +: true + class `DomainModelWithTooltips`: + def `data`: + Match rows sequentially: Združi istoležne vrstice + 'Re-match rows from tables obtained from the same ': Združi vrstice, dobljene iz istega vira, + source,\n: \n + 'e.g. data from the same file that was split within ': na primer iste datoteke, ki je bila razdeljena v delotoku. + the workflow.: "" + class `MergeDataContextHandler`: + def `settings_from_widget`: + attr_pairs: false + def `settings_to_widget`: + attr_pairs: false + def `match`: + attr_pairs: false + class `OWMergeData`: + Merge Data: Združi vrstice + Merge datasets based on the values of selected features.: Združi tabele glede na vrednosti izbranih spremenljivk. + Transform: Predelava podatkov + icons/MergeData.svg: false + merge data, join: merge data, join, zlivanje + class `Inputs`: + Data: Podatki + Data A: false + Extra Data: Dodatni podatki + Data B: false + class `Outputs`: + Data: Podatki + Merged Data A+B: false + Merged Data B+A: false + Merged Data: false + Append columns from Extra data: Dodaj podatke iz druge tabele + Find matching pairs of rows: Sestavi pare vrstic, ki se ujemajo + Concatenate tables: Zlij tabeli + The first table may contain, for instance, city names,\n: Prva tabela vključuje stolpec z imeni mest,\n + and the second would be a list of cities and their coordinates.\n: druga tabela pa so imena mest in njihove koordinate.\n + Columns with coordinates would then be appended to the output.: Koordinate iz druge tabele bodo dodane v pripadajoče vrstice prve. + 'Input tables contain different features describing the same data ': Tabeli vsebujeta različne lastnosti istih primerov.\n + instances.\n: "" + Output contains matched instances. Rows without matches are removed.: Gradnik poišče pare, ki se ujemajo. Preostale zavrže. + 'Output contains all instances. Data from merged instances is ': Izhodni podatki vsebujejo vse primere.\n + merged into single rows.: Ujemajoče se vrstice so združene. + Confused about merging options?\nSee the tooltips!: Zbegani? Ne razumete izbir?\nObiščite jih z miško in preberite razlage! + merging_types: false + class `Warning`: + 'Some variables have been renamed ': Zaradi podvojenih imen so nekatere spremenljivke preimenovane.\n + to avoid duplicates.\n{}: {} + 'Some (unused) combinations of values in Data appear in ': 'Nekatere (neuporabljene) kombinacije vrednosti v podatkih se pojavijo ' + multiple rows.: v več vrsticah. + 'Some (unused) combinations of values in Extra Data appear in ': 'Nekatere (neuporabljene) kombinacije vrednosti v dodatnih podatkih se pojavijo ' + class `Error`: + Numeric and non-numeric columns ({} and {}) cannot be matched.: Številskih in kategoričnih stolpcev ({} in {}) ni mogoče primerjati. + Row index cannot be matched with {}.: Številke vrstice ni mogoče primerjati z {}. + Instance cannot be matched with {}.: Identitete vrstice ni mogoče primerjati z {}. + Some combinations of values in Data appear in multiple rows.: Nekatere kombinacije vrednosti v podatkih se pojavijo v več vrsticah. + \nEvery matched combination may appear at most once.: \nVsaka ujemajoča se kombinacija se lahko pojavi največ enkrat. + \nEvery combination may appear at most once.: \nVsaka kombinacija se lahko pojavi največ enkrat. + Some combinations of values in Extra Data appear in multiple rows.: Nekatere kombinacije vrednosti v dodatnih podatkih se pojavijo v več vrsticah. + def `__init__`: + merging: false + Merging: Združevanje + matches: se ujema z + Row matching: Kriteriji za ujemanje vrstic + def `send_report`: + Merging: Združevanje + Match: Ujemanje + ', ': false + {self._get_col_name(left)} with {self._get_col_name(right)}: {self._get_col_name(left)} (levo) in {self._get_col_name(right)} (desno) + def `_get_col_name`: + "'{obj.name}'": false + def `_join_table_by_indices`: + name: false + attributes: false + def `_domain_rename_duplicates`: + ', ': false + def `migrate_settings`: + def `mig_value`: + Position (index): false + Source position (index): false + augment: false + merge: false + combine: false + merging: false + attr_pairs: false + attr_{oper}_data: false + attr_{oper}_extra: false + context_settings: false + __main__: false + tests/data-gender-region: false + tests/data-regions: false +widgets/data/owneighbors.py: + Euclidean: Evklidska + Manhattan: Manhattanska + Mahalanobis: Mahalanobisova + Cosine: Kosinusna + Jaccard: Jaccardova + Spearman: Spearmanova + Absolute Spearman: Absolutna Spearmanova + Pearson: Pearsonova + Absolute Pearson: Absolutna Pearsonova + class `OWNeighbors`: + Neighbors: Sosedi + Compute nearest neighbors in data according to reference.: Poišči najbližje sosede referenčnih primerov. + icons/Neighbors.svg: false + Unsupervised: Nenadzorovano učenje + orangecontrib.prototypes.widgets.owneighbours.OWNeighbours: false + knn, nearest neighbors, distance, similarity: knn, nearest neighbors, distance, similarity, najbližji sosedje, razdalja, podobnost + class `Inputs`: + Data: Podatki + Reference: Referenčni podatki + class `Outputs`: + Neighbors: Sosedi + class `Info`: + Input data includes reference instance(s).\n: Vhodni podatki vključujejo referenčne primere.\n + Reference instances are not considered as neighbours.: Referenčni primeri niso šteti za sosede. + class `Warning`: + Every data instance is same as some reference: Vsi podatki se pojavijo tudi med referenčnimi. + class `Error`: + Data and reference have different features: Podatki in referenčni podatki so opisani z različnimi značilkami. + def `__init__`: + distance_index: false + 'Distance metric: ': 'Vrsta razdalje: ' + n_neighbors: false + Limit number of neighbors to:: Omeji število sosedov na: + limit_neighbors: false + include_reference: false + Include reference example: Vključi referenčni primer + def `commit`: + ' (neighbors)': ' (sosedi)' + def `_data_with_similarity`: + distance: false + __main__: false + iris.tab: false +widgets/data/owoutliers.py: + def `run`: + Initializing...: Zaganjam... + class `SVMEditor`: + def `__init__`: + 'An upper bound on the fraction of training errors and a ': 'Zgornja meja deleža napak pri učenju in ' + lower bound of the fraction of support vectors: spodnja meja deleža podpornih vektorjev + Nu:: 𝜈: + nu: false + %d %%: false + gamma: false + Kernel coefficient:: Koeficient jedra: + def `get_parameters`: + nu: false + gamma: false + def `get_report_parameters`: + Detection method: Metoda detekcije + One class SVM with non-linear kernel (RBF): SVM enega razreda z nelinearnim jedrom (RBF) + Regularization (nu): Regularizacija (število) + {self.nu/100:.0%}: false + Kernel coefficient: Koeficient jedra + class `CovarianceEditor`: + def `__init__`: + Contamination:: Kontaminacija: + cont: false + %d %%: false + empirical_covariance: false + Support fraction:: Delež podpore: + support_fraction: false + def `get_parameters`: + contamination: false + support_fraction: false + def `get_report_parameters`: + Detection method: Metoda zaznavanja + Covariance estimator: Ocena kovariance + Contamination: Kontaminacija + {self.cont/100:.0%}: false + Support fraction: Delež podpore + class `LocalOutlierFactorEditor`: + euclidean: false + manhattan: false + cosine: false + jaccard: false + hamming: false + minkowski: false + Euclidean: Evklidska + Manhattan: Manhattanska + Cosine: Kosinusna + Jaccard: Jaccardova + Hamming: Hammingova + Minkowski: Minkowska + def `__init__`: + Contamination:: Kontaminacija: + cont: false + %d %%: false + n_neighbors: false + Neighbors:: Sosedi: + metric_index: false + Metric:: Metrika: + def `get_parameters`: + n_neighbors: false + contamination: false + algorithm: false + brute: false + metric: false + def `get_report_parameters`: + Detection method: Metoda detekcije + Local Outlier Factor: Lokalni faktor odstopanja + Contamination: Kontaminacija + {self.cont/100:.0%}: false + Number of neighbors: Število sosedov + Metric: Metrika + class `IsolationForestEditor`: + def `__init__`: + Contamination:: Kontaminacija: + cont: false + %d %%: false + replicable: false + Replicable training: Ponovno učenje + def `get_parameters`: + contamination: false + random_state: false + def `get_report_parameters`: + Detection method: Metoda detekcije + Isolation Forest: Izolacijski gozd + Contamination: Kontaminacija + {self.cont/100:.0%}: false + Replicable training: Ponovno učenje + class `OWOutliers`: + Outliers: Osamelci + Detect outliers.: Odkrivanje izstopajočih vrednosti. + icons/Outliers.svg: false + Unsupervised: Nenadzorovano učenje + outliers, inlier: outliers, inlier, izstopajoče vrednosti + class `Inputs`: + Data: Podatki + class `Outputs`: + Inliers: Ostali primeri + Outliers: Osamelci + Data: Podatki + class `Warning`: + Too many features for covariance estimation.: Preveč spremenljivk za oceno kovariance. + class `Error`: + Singular covariance matrix.: Kovariančna matrika je singularna. + Not enough memory: Premalo pomnilnika. + def `init_gui`: + Method: Metoda + outlier_method: false + auto_commit: false + def `_init_editors`: + Parameters: Parametri + def `send_report`: + Data: Podatki + Input instances: Vhodni primeri + Inliers: Ostali primeri + Outliers: Osamelci + Detection: Detekcija + def `migrate_settings`: + svm_editor: false + nu: false + gamma: false + empirical_covariance: false + support_fraction: false + cov_editor: false + cont: false + __main__: false + iris: false +widgets/data/owpaintdata.py: + Append: false + points: false + Insert: false + indices: false + Move: false + delta: false + DeleteIndices: false + Composite: false + f: false + g: false + AirBrush: false + pos: false + radius: false + intensity: false + rstate: false + Jitter: false + Magnet: false + density: false + SelectRegion: false + region: false + DeleteSelection: false + DeleteAll: false + MoveSelection: false + class `PaintViewBox`: + def `__init__`: + mousePressEvent: false + mouseMoveEvent: false + mouseReleaseEvent: false + mouseClickEvent: false + mouseDragEvent: false + mouseEnterEvent: false + mouseLeaveEvent: false + class `SelectTool`: + def `__init__`: + Delete: false + Backspace: false + def `_icon`: + icons/paintdata: false + class `OWPaintData`: + Brush: Pršilo + Create multiple instances: Postavi več točk + brush.svg: false + Put: Pisalo + Put individual instances: Postavi posamične točke + put.svg: false + Select: Izbor + Select and move instances: Izberi in premikaj točke + select-transparent_42px.png: false + Jitter: Potresi + Jitter instances: Potresi točke + jitter.svg: false + Magnet: Pritegni + Attract multiple instances: Pritegni točke + magnet.svg: false + Clear: Pobriši + Clear the plot: Pobriši vse točke + ../../../icons/Dlg_clear.png: false + Paint Data: Risanje podatkov + Create data by painting data points on a plane.: Risanje podatkov v graf. + icons/PaintData.svg: false + paint data, create, draw: paint data, create, draw, sestavi, nariši + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + Painted data: Narisani podatki + x: true + y: true + C1: true + C2: true + plot: true + class `Warning`: + Input data has no variables: Vhodni podatki nimajo spremenljivk + Numeric target value can not be used.: Numerične ciljne spremenljivke ne morem uporabiti + Sparse data is ignored.: Ignoriram redke podatke. + 'Some variables have been renamed ': 'Spremenljivke s podvojenimi imeni ' + to avoid duplicates.\n{}: so preimenovane.\n{} + class `Information`: + Paint Data uses data from the first two attributes.: Gradnik uporablja prvi dve spremenljivki. + def `_init_ui`: + Names: Imena + attr1: false + 'Variable X: ': 'Spremenljivka X: ' + attr2: false + 'Variable Y: ': 'Spremenljivka Y: ' + hasAttr2: true + Labels: Razredi + +: true + Add new class label: Dodaj nov razred + MINUS SIGN: true + Remove selected class label: Odstrani razred + Tools: Orodja + brushRadius: false + Radius:: Polmer: + density: false + Intensity:: Gostota: + symbol_size: false + Symbol:: Velikost: + Reset to Input Data: Ponastavi na vhodne podatke + autocommit: false + bottom: false + left: false + def `set_dimensions`: + left: false + def `set_data`: + C1: true + def `add_new_class_label`: + C: true + def `remove_selected_class_label`: + Delete class label: Odstrani izbrani razred + def `_on_editing_started`: + macro: false + def `execute`: + Non normalized command: false + def `_add_command`: + Name: Ime + Delete: Brisanje + Clear All: Pobriši vse + Move: Premik + unreachable: false + def `_replot`: + +: true + def `_attr_name_changed`: + bottom: false + left: false + def `commit`: + Class: Razred + ', ': true + bottom: false + left: false + def `send_report`: + x: true + y: true + Axis x: Os x + Axis y: Os y + Number of points: Število točk + Painted data: Narisani podatki + def `prepare_color_table_and_index`: + unsafe: false + __main__: false +widgets/data/owpivot.py: + class `Pivot`: + def `__init__`: + Row variable should be DiscreteVariable: false + ' or ContinuousVariable': false + Column variable should be DiscreteVariable: false + Total: Skupno + total: false + def `_create_group_tables`: + ({str(fun).lower()}): false + {var.name} ({str(fun).lower()}): false + table: false + total_h: false + total_v: false + total: false + def `_create_pivot_tables`: + table: false + total_h: false + total_v: false + total: false + def `__get_pivot_tab_domain`: + def `map_values`: + nan: false + have_date: false + have_time: false + {v}: false + Total: Skupno + Aggregate: Vrednost + def `__get_pivot_tab_x`: + fill_value: false + dtype: false + def `count_defined`: + nan: false + Count: Velikost skupine + Count defined: Število znanih + Sum: Vsota + Mean: Povprečje + Min: Najmanjša + Max: Največja + Mode: Najpogostejša + Median: Mediana + Var: Varianca + Majority: Večina + class `PivotTableView`: + Total: Skupno + def `_draw_lines`: + t: false + def `_resize`: + ' ': false + class `OWPivot`: + Pivot Table: Pivotna tabela + Reshape data table based on column values.: Preoblikuj podatke glede na vrednosti stolpcev. + Transform: Predelava podatkov + icons/Pivot.svg: false + pivot table, pivot, group, aggregate: pivot table, pivot, group, aggregate, skupine, agregacija + class `Inputs`: + Data: Podatki + class `Outputs`: + Pivot Table: Pivotna tabela + Filtered Data: Izbrani podatki + Grouped Data: Skupine podatkov + class `Warning`: + Column feature should be selected.: Izbrati je potrebno spremenljivko za stolpce. + Some aggregations ({}) cannot be computed.: Nekaterih vrednosti ({}) ni mogoče izračunati. + Some variables have been renamed in some tables: Spremenljivke s podvojenimi imeni so + to avoid duplicates.\n{}: preimenovane.\n{} + Selected variable has too many values.: Izbrana spremenljivka ima preveč različnih vrednosti. + At least one variable is required.: Potrebna je vsaj ena spremenljivka. + def `_add_control_area_controls`: + Rows: Vrstice + row_feature: false + Columns: Stolpci + col_feature: false + (Same as rows): (Enako kot vrstice) + Values: Vrednosti + val_feature: false + (None): (Brez) + auto_commit: false + def `__add_aggregation_controls`: + Aggregations: Izračunaj + def `skipped_aggs`: + ', ': false + def `init_attr_values`: + row_feature: false + col_feature: false + val_feature: false + def `send_report`: + Row feature: Vrstice + Column feature: Stolpci + Value feature: Vrednost + Group by: Združi po + def `migrate_settings`: + sel_agg_functions: false + __main__: false + heart_disease: false +widgets/data/owpreprocess.py: + class `DiscretizeEditor`: + n: false + force: false + None: Brez diskretizacije + Equal width discretization: Enaka širina intervalov + Equal frequency discretization: Enaka pogostost vrednosti + Remove numeric features: Odstrani številske spremenljivke + Entropy-MDL discretization: Diskretizacija z MDL in entropijo + def `__init__`: + Number of intervals (for equal width/frequency): Število intervalov (za enake širine ali pogostosti) + def `setParameters`: + method: false + n: false + def `parameters`: + method: false + n: false + def `createinstance`: + method: false + def `__repr__`: + ', Number of intervals: {}': , število intervalov: {} + {}{}: false + class `ContinuizeEditor`: + Most frequent is base: Najpogostejša vrednost kot osnova + One feature per value: Po ena spremenljivka na vrednost + Remove non-binary features: Odstrani nebinarne spremenljivke + Remove categorical features: Odstrani kategorične spremenljivke + Treat as ordinal: Obravnavaj kot kategorične spremenljivke kot ordinalne + Divide by number of values: Obravnavaj kot ordinalne, a deli s številom vrednosti + def `setParameters`: + multinomial_treatment: false + def `parameters`: + multinomial_treatment: false + def `createinstance`: + multinomial_treatment: false + class `RemoveSparseEditor`: + missing values: manjkajočimi vrednostmi + zeros: ničlami + def `__init__`: + Remove features with too many: Odstrani spremenljivke s preveč + Threshold:: Prag: + Percentage: Odstotek + Fixed: Število primerov + def `parameters`: + fixedThresh: false + percThresh: false + useFixedThreshold: false + filter0: false + def `setParameters`: + percThresh: false + fixedThresh: false + useFixedThreshold: false + filter0: false + def `createinstance`: + filter0: false + useFixedThreshold: false + fixedThresh: false + percThresh: false + def `__repr__`: + 'remove features with too many {self.options[self.filter0]}, threshold: ': 'odstrani spremenljivke s preveč {self.options[self.filter0]}, meja: ' + {self.fixedThresh} {pl(self.fixedThresh, 'instance')}: {self.fixedThresh} {plsi(self.fixedThresh, 'primer')} + {self.percThresh} %: true + class `ImputeEditor`: + Don't impute.: Ne nadomeščaj + Replace with constant: Nadomesti z določeno vrednostjo + Average/Most frequent: Povprečna/najpogostejša vrednost + Model based imputer: Nadomeščanje z modelom + Replace with random value: Nadomesti z naključno vrednostjo + Remove rows with missing values.: Odstrani vrstice z manjkajočimi vrednostmi + def `setParameters`: + method: false + def `parameters`: + method: false + def `createinstance`: + method: false + class `UnivariateFeatureSelect`: + def `__init__`: + Score: Kriterij + Number of features: Število ohranjenih spremenljivk + Fixed:: Določeno število: + Proportion:: Delež v odstotkih + %: true + def `setItems`: + text: false + def `setParameters`: + score: false + strategy: false + k: false + p: false + def `parameters`: + score: false + strategy: false + p: false + k: false + class `FeatureSelectEditor`: + Information Gain: Informacijski prispevek + Gain ratio: Delež informacijskega prispevka + Gini index: Zmanjšanje Ginijevega indeksa + ReliefF: true + Fast Correlation Based Filter: Hitri korelacijski filter + ANOVA: true + Chi2: true + RReliefF: true + Univariate Linear Regression: Univariatna linearna regresija + def `__init__`: + text: false + Information Gain: Informacijski prispevek + tooltip: false + Gain Ratio: Delež informacijskega prispevka + Gini Index: Zmanjšanje Ginijevega indeksa + ReliefF: true + Fast Correlation Based Filter: Hitri korelacijski filter + ANOVA: true + Chi2: true + RReliefF: true + Univariate Linear Regression: Univariatna linearna regresija + def `createinstance`: + score: false + strategy: false + k: false + p: false + def `__repr__`: + 'Score: {}, Strategy (Fixed): {}': kriterij: {}, število (določeno): {} + score: false + k: false + class `RandomFeatureSelectEditor`: + def `__init__`: + Number of features: Število spremenljivk + Fixed: Določeno število + Percentage: Odstotek + %: true + def `setParameters`: + strategy: false + k: false + p: false + def `parameters`: + strategy: false + p: false + k: false + def `createinstance`: + strategy: false + k: false + p: false + def `__repr__`: + select {num} {pl(num,'feature')}: izbor {num} {plsi(num,'primer')} + select {perc} % features: izbor {perc} % primerov + class `Scale`: + Standardize to μ=0, σ²=1: Standardiziraj (μ=0, σ²=1) + Center to μ=0: Centriraj (μ=0) + Scale to σ²=1: Skaliraj (σ²=1) + Normalize to interval [-1, 1]: Normaliziraj v interval [-1, 1] + Normalize to interval [0, 1]: Normaliziraj v interval [0, 1] + def `setParameters`: + method: false + def `parameters`: + method: false + def `createinstance`: + method: false + class `Randomize`: + def `__init__`: + Classes: Razrede + Features: Spremenljivke + Meta data: Meta spremenljivke + Randomize:: Premešaj: + Replicable shuffling:: Ponovljivo mešanje: + def `setParameters`: + rand_type: false + rand_seed: false + def `parameters`: + rand_type: false + rand_seed: false + def `createinstance`: + rand_type: false + rand_seed: false + def `__repr__`: + {}, {}: true + Replicable: ponovljivo + Not replicable: neponovljivo naključno + class `PCA`: + def `__init__`: + Components:: Število komponent: + def `setParameters`: + n_components: false + def `parameters`: + n_components: false + def `createinstance`: + n_components: false + def `__repr__`: + 'Components: {}': {} komponent + class `CUR`: + def `__init__`: + Rank:: Rang: + Relative error:: Relativna napaka: + def `setParameters`: + rank: false + max_error: false + def `parameters`: + rank: false + max_error: false + def `createinstance`: + rank: false + max_error: false + def `__repr__`: + 'Rank: {}, Relative error: {}': rang: {}, relativna napaka: {} + def `icon_path`: + icons/: true + Discretize: Diskretizacija + orange.preprocess.discretize: false + Discretization: Diskretizacija + Discretize Continuous Variables: Diskretiziraj številske spremenljivke + Discretize.svg: false + Continuize: Kontinuizacija + orange.preprocess.continuize: false + Continuization: Kontinuizacija + Continuize Discrete Variables: Spremeni kategorične spremenljivke v številske + Continuize.svg: false + Impute: Nadomeščanje + orange.preprocess.impute: false + Impute Missing Values: Nadomesti manjkajoče vrednosti + Impute.svg: false + Feature Selection: Izbor spremenljivk + orange.preprocess.fss: false + Select Relevant Features: Izberi relevantne spremenljivke + SelectColumns.svg: false + Random Feature Selection: Naključen izbor spremenljivk + orange.preprocess.randomfss: false + Select Random Features: Izberi naključno množico spremenljivk + SelectColumnsRandom.svg: false + Normalize: Normalizacija + orange.preprocess.scale: false + Scale: Skaliraj + Normalize Features: Normalizacija spremenljivk + Normalize.svg: false + Randomize: Premešanje vrednosti + orange.preprocess.randomize: false + Randomization: Premešanje vrednosti + Random.svg: false + Remove Sparse: Odstranjevanje redkih podatkov + orange.preprocess.remove_sparse: false + Remove Sparse Features: Odstrani redke spremenljivke + PurgeDomain.svg: false + PCA: true + orange.preprocess.pca: false + Principal Component Analysis: Metoda osnovnih komponent + PCA.svg: false + CUR: CUR + orange.preprocess.cur: false + CUR Matrix Decomposition: Matrična dekompozicija CUR + class `OWPreprocess`: + Preprocess: Predprocesiranje + Construct a data preprocessing pipeline.: Sestavi zaporedje predprocesorjev. + Transform: Predelava podatkov + icons/Preprocess.svg: false + preprocess, process: preprocess, process, proces + class `Inputs`: + Data: Podatki + class `Outputs`: + Preprocessor: Predprocesor + Preprocessed Data: Predprocesirani podatki + def `__init__`: + def `mimeData`: + application/x-qwidget-ref: false + utf-8: false + Preprocessors: Predprocesorji + Drag items from the list on the left: Povleci predprocesorje z leve + autocommit: false + def `load`: + preprocessors: false + def `dropMimeData`: + application/x-qwidget-ref: false + def `save`: + name: false + preprocessors: false + def `migrate_settings`: + storedsettings: false + preprocessors: false + orange.preprocess.scale: false + center: false + scale: false + Mean: false + NoScaling: false + NoCentering: false + Std: false + Span: false + method: false + def `send_report`: + Settings: Nastavitve + __main__: false + brown-selected: false +widgets/data/owpurgedomain.py: + class `OWPurgeDomain`: + Purge Domain: Očisti domeno + 'Remove redundant values and features from the dataset. ': 'Odstrani neuporabljene vrednosti in spremenljivke. ' + Sort values.: Uredi vrednosti. + icons/PurgeDomain.svg: false + Transform: Predelava podatkov + remove, delete, unused: remove, delete, unused, odstrani, uredi, neuporabljene + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + sortValues: false + Sort categorical feature values: Uredi kategorične vrednosti po abecedi + removeValues: false + Remove unused feature values: Odstrani neuporabljene vrednosti + removeAttributes: false + Remove constant features: Odstrani konstantne spremenljivke + sortClasses: false + Sort categorical class values: Uredi razrede po abecedi + removeClasses: false + Remove unused class variable values: Odstrani prazne razrede + removeClassAttribute: false + Remove constant class variables: Odstrani konstantne ciljne spremenljivke + removeMetaAttributeValues: false + Remove unused meta attribute values: Odstrani neuporabljene vrednosti meta spremenljivk + removeMetaAttributes: false + Remove constant meta attributes: Odstrani konstantne meta spremenljivke + Sorted features: Urejene značilke + resortedAttrs: false + Reduced features: Poenostavljene značilke + reducedAttrs: false + Removed features: Odstranjene značilke + removedAttrs: false + Sorted classes: Urejeni razredi + resortedClasses: false + Reduced classes: Poenostavljeni razredi + reducedClasses: false + Removed classes: Odstranjeni razredi + removedClasses: false + Reduced metas: Poenostavljene meta spremenljivke + reducedMetas: false + Removed metas: Odstranjene meta spremenljivke + removedMetas: false + def `__init__`: + -: true + Features: Značilke + 'Sorted: %(resortedAttrs)s, ': 'Urejene: %(resortedAttrs)s, ' + 'reduced: %(reducedAttrs)s, removed: %(removedAttrs)s': poenostavljene: %(reducedAttrs)s, odstranjene: %(removedAttrs)s + Classes: Razredi + 'Sorted: %(resortedClasses)s,': Urejeni: %(resortedClasses)s, + 'reduced: %(reducedClasses)s, removed: %(removedClasses)s': poenostavljeni: %(reducedClasses)s, odstranjeni: %(removedClasses)s + Meta attributes: Meta spremenljivke + 'Reduced: %(reducedMetas)s, removed: %(removedMetas)s': poenostavljene: %(reducedMetas)s, odstranjene: %(removedMetas)s + autoSend: false + def `setData`: + -: true + def `commit`: + removed: false + reduced: false + sorted: false + def `send_report`: + def `list_opts`: + '; ': true + no changes: ni sprememb + Settings: Nastavitve + Features: Značilke + Classes: Razredi + Metas: Meta spremenljivke + Statistics: Statistika + __main__: false + https://datasets.biolab.si/core/car.tab: false + buying: false + v-high: false +widgets/data/owpythonscript.py: + OWPythonScript: false + 'import numpy as np +from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable + +domain = Domain([ContinuousVariable("age"), + ContinuousVariable("height"), + DiscreteVariable("gender", values=("M", "F"))]) +arr = np.array([ + [25, 186, 0], + [30, 164, 1]]) +out_data = Table.from_numpy(domain, arr) +': false + def `read_file_content`: + utf-8: true + strict: true + Light: false + '#000': false + '#f00': false + bold #008000: false + '#212121': false + '#00f': false + '#05a': false + '#aa22ff': false + '#008000': false + '#ba2121': false + '#080': false + bold #aa22ff: false + italic #408080: false + Dark: false + '#fff': false + bold #4caf50: false + '#e0e0e0': false + '#1e88e5': false + '#42a5f5': false + '#43a047': false + '#ff7070': false + '#66bb6a': false + def `make_pygments_style`: + PygmentsStyle: false + styles: false + class `FakeSignatureMixin`: + def `__init__`: + 4444: false + class `FunctionSignature`: + def `__init__`: + python_script: false + in_: false + 'def : false + ': false + : false + ;">(: false + ;">):: false + def `update_signal_text`: + ', ': false + 's, ': false + class `ReturnStatement`: + def `__init__`: + 'return : false + out_: false + def `make_signal_labels`: + : false + ', ': false + '.QLabel { color: ': false + ' ': false + ; }: false + def `update_signal_text`: + : false + : false + : false + class `VimIndicator`: + def `__init__`: + '#33cc33': false + normal: false + class `PythonConsole`: + def `interact`: + '>>> ': false + '... ': false + 'Type "help", "copyright", "credits" or "license" ': false + for more information.: false + Python %s on %s\n%s\n(%s)\n: false + %s\n: false + \n: false + \nKeyboardInterrupt\n: false + def `push`: + sys.excepthook: false + sys.stdout: false + sys.stderr: false + def `keyPressEvent`: + \n: false + def `pasteCode`: + \n: false + class `Script`: + def `asdict`: + _ScriptData: false + def `fromdict`: + _ScriptData: false + Script: false + name: false + script: false + filename: false + class `ScriptItemDelegate`: + def `displayText`: + *: false + _ScriptData: false + name: false + script: false + filename: false + class `OWPythonScript`: + Python Script: Skripta v Pythonu + Write a Python script and run it on input data or models.: Poženi ročno napisan program v Pythonu. + Transform: Predelava podatkov + icons/PythonScript.svg: false + program, function: program, function, funkcija + class `Inputs`: + Data: false + in_data: false + Learner: false + in_learner: false + Classifier: false + in_classifier: false + Object: false + in_object: false + class `Outputs`: + Data: false + out_data: false + Learner: false + out_learner: false + Classifier: false + out_classifier: false + Object: false + out_object: false + data: false + learner: false + classifier: false + object: false + List[_ScriptData]: false + name: false + Table from numpy: false + script: false + filename: false + def `__init__`: + Menlo: false + darwin: false + Courier: false + win32: false + cygwin: false + DejaVu Sans Mono: false + Editor: Urejevalnik + darkMode: false + Dark: false + Light: false + 0000: false + Preferences: Nastavitve + vimModeEnabled: false + Vim mode: Način Vim + Only for the coolest.: Samo, če si kul. + Library: Knjižnica + +: true + Add a new script to the library: Dodaj novo skripto v knjižnico + MINUS SIGN: true + Remove script from library: Odstrani skripto iz knjižnice + Update: Osveži + Save changes in the editor to library: Shrani spremembe iz urejevalnika v knjižnico + More: Več + More actions: Več možnosti + Import Script from File: Preberi skripto iz datoteke + Save Selected Script to File: Shrani izbrano skripto v datoteko + Undo Changes to Selected Script: Razveljavi spremembe izbrane skripte + Run: Poženi + Run script: Poženi skripto + &Save: &Shrani + Save script to file: Shrani skripto v datoteko + Console: Konzola + def `set_data`: + data: false + def `insert_data`: + data: false + def `remove_data`: + data: false + def `set_learner`: + learner: false + def `insert_learner`: + learner: false + def `remove_learner`: + learner: false + def `set_classifier`: + classifier: false + def `insert_classifier`: + classifier: false + def `remove_classifier`: + classifier: false + def `set_object`: + object: false + def `insert_object`: + object: false + def `remove_object`: + object: false + def `onAddScript`: + New script: Nova skripta + def `onAddScriptFromFile`: + Open Python Script: Odpri skripto v Pythonu + ~/: false + Python files (*.py)\nAll files(*.*): Programi v Pythonu (*.py)\nVse datoteke (*.*) + def `saveScript`: + ~/: false + Save Python Script: Shrani skripto v Pythonu + Python files (*.py)\nAll files(*.*): Programi v Pythonu (*.py)\nVse datoteke (*.*) + .py: false + w: false + def `initial_locals_state`: + in_: false + s: false + def `commit`: + _script: false + \nRunning script:\n: \nIzvajam skripto:\n + exec(_script): false + out_: false + "'{}' has to be an instance of '{}'.": "'{}' mora biti '{}'." + def `migrate_settings`: + libraryListSource: false + scriptLibrary: false + class `OWPythonScriptDropHandler`: + def `canDropFile`: + text/x-python: false + def `parametersFromFile`: + rt: false + _ScriptData: false + name: false + script: false + filename: false + scriptLibrary: false + def `is_same`: + _ScriptData: false + filename: false + __version__: false + scriptText: false + __main__: false +widgets/data/owrandomize.py: + class `OWRandomize`: + Randomize: Premešaj + Randomize features, class and/or metas in data table.: Premeša značilke, razrede in/ali meta spremenljivke. + Transform: Predelava podatkov + icons/Random.svg: false + randomize, random: randomize, random, naključno + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + def `__init__`: + Shuffled columns: Premešani stolpci + shuffle_class: false + Classes: Razredi + shuffle_attrs: false + Features: Značilke + shuffle_metas: false + Metas: Meta spremenljivke + Shuffled rows: Delež premešanih vrstic + None: Nič + scope_prop: false + All: Vse + random_seed: false + Replicable shuffling: Ponovljivo naključno mešanje + def `_set_scope_label`: + {}%: false + def `send_report`: + classes: razredi + features: značilke + metas: meta spremenljivke + none: nič + ' and ': ' in ' + ', ': true + Settings: Nastavitve + Shuffled columns: Premešani stolpci + Proportion of shuffled rows: Delež premešanih vrstic + {}%: false + Replicable: Ponovljivo + yes: da + no: ne + __main__: false + iris: false +widgets/data/owrank.py: + score_meta: false + name: false + shortname: false + scorer: false + problem_type: false + is_default: false + Information Gain: Informacijski prispevek + Info. gain: Inf. prisp + Information Gain Ratio: Delež inform. prispevka + Gain ratio: Delež inf. + Gini Decrease: Znižanje Ginijevega indeksa + Gini: true + ANOVA: true + χ²: true + ReliefF: true + FCBF: true + Univariate Regression: Univariatna regresija + Univar. reg.: Univar. reg. + RReliefF: true + def `get_method_scores`: + ignore: false + %s doesn't work on this data: false + '%s had to be computed separately for each ': false + variable: false + def `get_scorer_scores`: + %s doesn't work on this data: false + _: false + class `OWRank`: + Rank: Rangiranje + Rank and filter data features by their relevance.: Rangiraj in filtriraj spremenljivke glede na pomembnosti. + icons/Rank.svg: false + rank, filter: rank, filter, filtriraj + class `Inputs`: + Data: Podatki + Scorer: Kriterij + class `Outputs`: + Reduced Data: Reducirani podatki + Scores: Ocene + Features: Spremenljivke + class `Information`: + Data does not have a (single) target variable.: Podatki nimajo (ene) ciljne spremenljivke. + Missing values will be imputed as needed.: Manjkajoče vrednosti bodo nadomeščene. + class `Error`: + Cannot handle target variable type {}: Vrsta ciljne spremenljivke '{}' ni podprta. + 'Scorer {} inadequate: {}': Kriterij {} ni ustrezen: {} + Data does not have a single attribute.: Podatki ne vsebujejo nobene spremenljivke. + class `Warning`: + Variables with duplicated names have been renamed.: Spremenljivke s podvojenimi imeni so preimenovane. + def `__init__`: + Scoring Methods: Kriteriji pomembnosti + Select Attributes: Izbor spremenljivk + None: Nobena + All: Vse + Manual: Ročno + Best ranked:: Najboljše ocenjene: + nSelected: false + auto_apply: false + def `handleNewSignals`: + Running: V teku + def `on_done`: + '#': false + def `send_report`: + Input: Vhod + Ranks: Rangi + {:.3f}: false + Output: Izhod + def `create_scores_table`: + Feature: Spremenljivka + ', ': true + Feature Scores: Ocene spremenljivk + def `migrate_settings`: + headerState: false + sorting: false + __main__: false + heart_disease.tab: false + Learner: false +widgets/data/owsave.py: + ~{os.sep}: false + class `OWSave`: + Save Data: Shrani podatke + Save data to an output file.: Shrani podatke v datoteko. + icons/Save.svg: false + Data: Podatki + save data, export: save data, export, izvoz + class `Inputs`: + Data: Podatki + class `Error`: + Use Pickle format for sparse data.: Za redke podatke uporabi obliko 'pickle'. + def `__init__`: + add_type_annotations: false + Add type annotations to header: V glavo dodaj oznake tipov stolpcev + Some formats (Tab-delimited, Comma-separated) can include \n: Nekateri podatki (ločeni z vejico ali s tabulatorji)\n + additional information about variables types in header rows.: omogočajo shranjevanje informacije o vrstah stolpcev. + def `get_filters`: + write_file: false + EXTENSIONS: false + {w.DESCRIPTION} (*{w.EXTENSIONS[0]}): false + Compressed {w.DESCRIPTION} (*{w.EXTENSIONS[0]}.gz): Stisnjeni {w.DESCRIPTION} (*{w.EXTENSIONS[0]}.gz) + def `send_report`: + No: Ne + Yes: Da + File name: Ime datoteke + not set: ni nastavljeno + Format: Oblika + Type annotations: Oznake tipov + def `migrate_settings`: + def `migrate_to_version_2`: + compression: false + filter: false + filetype: false + compress: false + .gz: false + add_type_annotations: false + stored_name: false + .xlsx: false + def `initial_start_dir`: + name: false + __main__: false + iris: false +widgets/data/owselectbydataindex.py: + class `OWSelectByDataIndex`: + Select by Data Index: Izberi iste vrstice + Match instances by index from data subset.: Izberi primere, ki ustrezajo vrsticam iz podmnožice. + Transform: Predelava podatkov + icons/SelectByDataIndex.svg: false + _keywords: true + class `Inputs`: + Data: Podatki + Data Subset: Podmnožica podatkov + class `Outputs`: + Matching Data: Izbrani podatki + Data: false + Unmatched Data: Ostali podatki + Annotated Data: Označeni podatki + class `Warning`: + Input tables do not share any instances.: Vhodni tabeli nimata skupnih vrstic. + def `__init__`: + ' +Data rows keep their identity even when some or all original variables +are replaced by variables computed from the original ones. + +This widget gets two data tables ("Data" and "Data Subset") that +can be traced back to the same source. It selects all rows from Data +that appear in Data Subset, based on row identity and not actual data. +': ' +Vrstice v podatkih ohranijo svojo identiteto tudi, kadar so nekatere ali +celo vse izvirne spremenljivke zamenjane z drugimi, izračunanimi iz njih. + +Ta gradnik sprejme dve tabeli ("Podatki" in "Podmnožica"), ki izhajate +iz istega vira. Izbere tiste vrstice iz Podatki, ki se pojavijo tudi v +Podmnožica, pri čemer se ne ozira na vrednosti spremenljivk temveč na +identiteto vrstic. +' + def `send_report`: + def `data_info_text`: + No data.: Ni podatkov. + '{data.name}, ': true + "{len(data)} {pl(len(data), 'instance')}, ": "{len(data)} {plsi(len(data), 'vrstica')}, " + {nvars} {pl(nvars, 'variable')}: {nvars} {plsi(nvars, 'spremenljivka')} + Data: Podatki + Data Subset: Podmnožica podatkov + __main__: false + iris.tab: false +widgets/data/owselectcolumns.py: + class `VariablesListItemModel`: + application/x-Orange-VariableListModelData: false + def `mimeData`: + _items: false + def `dropMimeData`: + _items: false + _moved: false + class `SelectedVarsView`: + def `startDrag`: + _moved: false + class `PrimitivesView`: + def `acceptsDropEvent`: + _items: false + class `SelectAttributesDomainContextHandler`: + def `encode_setting`: + domain_role_hints: false + def `decode_setting`: + domain_role_hints: false + def `match`: + domain_role_hints: false + available: false + def `filter_value`: + domain_role_hints: false + class `OWSelectAttributes`: + Select Columns: Izbor stolpcev + 'Select columns from the data table and assign them to ': 'Izbor stolpcev in določitev njihovih vlog ' + data features, classes or meta variables.: (spremenljivka, ciljna spremenljivka, meta spremenljivka). + Transform: Predelava podatkov + icons/SelectColumns.svg: false + select columns, filter, attributes, target, variable: select columns, filter, attributes, target, variable, atributi, spremenljivke, stolpci + class `Inputs`: + Data: Podatki + Features: Spremenljivke + class `Outputs`: + Data: Podatki + Features: Spremenljivke + class `Warning`: + Features and data domain do not match: Tabela ne vsebuje nobene od podanih vhodnih spremenljivk. + Most widgets do not support multiple targets: Večina gradnikov podpira le eno ciljno spremenljivko. + def `__init__`: + Ignored: Odstranjeno + Features: Spremenljivke + use_input_features: false + Use input features: Uporabi vhodne spremenljivke + Always use input features: Vedno uporabi vhodne spremenljivke + Target: Cilj + Metas: Meta spremenljivke + >: true + Reset: Povrni + ignore_new_features: false + Ignore new variables by default: Privzeto odstrani nove spremenljivke + 'When the widget receives data with additional columns ': Če je ta možnost izbrana, bo gradnik v primeru,\n + 'they are added to the available attributes column if ': da prejme podatke z novimi stolpci, le-te\n + Ignore new variables by default is checked.: privzeto dodal med odstranjene spremenljivke. + auto_commit: false + def `__use_features_changed`: + use_features_box: false + def `set_data`: + attribute: false + class: false + meta: false + available: false + def `restore_hints`: + attribute: false + meta: false + class: false + available: false + def `update_domain_role_hints`: + available: false + attribute: false + class: false + meta: false + def `update_var_counts`: + {name} ({nvars}/{nall}): true + {name} ({nvars}): true + def `update_interface_state`: + >: true + <: true + def `send_report`: + Input data: Vhodni podatki + Output data: Izhodni podatki + No changes.: Ni sprememb. + {len(diff)} ({", ".join(x.name for x in diff)}): true + Removed: Odstranjene + __main__: false + brown-selected: false +widgets/data/owselectrows.py: + class `SelectRowsContextHandler`: + def `encode_setting`: + conditions: false + x: false + def `decode_setting`: + conditions: false + x: false + f: false + def `match`: + conditions: false + def `filter_value`: + conditions: false + class `FilterDiscreteType`: + Equal: false + NotEqual: false + In: false + IsDefined: false + class `OWSelectRows`: + Select Rows: Izberi vrstice + Select rows from the data based on values of variables.: Izberi vrstice glede na vrednosti spremenljivk. + icons/SelectRows.svg: false + Transform: Predelava podatkov + select rows, filter: select rows, filter, vrstice + class `Inputs`: + Data: Podatki + class `Outputs`: + Matching Data: Izbrani podatki + Unmatched Data: Neizbrani podatki + equals: je + equal: so + is not: ni + are not: niso + is below: je manj kot + are below: so manjše kot + is at most: je največ + are at most: so največ + is greater than: je več kot + are greater than: so večje kot + is at least: je vsaj + are at least: so vsaj + is between: je med + are between: so med + is outside: je izven + are outside: so izven + is defined: je znan + are defined: so znane + is: je + is one of: je eden izmed + is before: je pred + are before: so pred + is equal or before: je enak ali pred + are equal or before: so enake ali pred + is after: je za + are after: so za + is equal or after: je enak ali za + are equal or after: so enake ali za + contains: vsebuje + contain: vsebujejo + does not contain: ne vsebuje + do not contain: ne vsebujejo + begins with: se začne na + begin with: se začnejo na + does not begin with: se ne začne na + do not begin with: se ne začnejo na + ends with: se konča na + end with: se končajo na + does not end with: se ne konča na + do not end with: se ne končajo na + is not defined: ni definiran + are not defined: niso definirani + All variables: Vse spremenljivke + All numeric variables: Vse številske spremenljivke + All string variables: Vse besedilne spremenljivke + class `Error`: + {}: false + def `__init__`: + Conditions: Pogoji + Add Condition: Dodaj pogoj + Add All Variables: Dodaj vse spremenljivke + Remove All: Odstrani vse + purge_attributes: false + Remove unused values and constant features: Odstrani neuporabljene vrednosti in konstantne spremenljivke + purge_classes: false + Remove unused classes: Odstrani prazne razrede + auto_commit: false + def `add_row`: + ×: true + '* {font-size: 16pt; color: palette(button-text) }': true + '*:hover {color: palette(bright-text)}': true + def `add_all`: + Remove existing filters: Odstrani vse pogoje + 'This will replace the existing filters with ': 'Zamenjam obstoječe pogoje s ' + filters for all variables.: pogoji za vse spremenljivke? + def `_get_lineedit_contents`: + controls: false + def `_get_value_contents`: + controls: false + ', ': false + Type %s not supported.: false + def `set_new_values`: + defined: znan + ' one of': ' eden izmed' + ' and ': ' in ' + def `_values_to_floats`: + Some values could not be parsed as floats: Nekaterih vrednosti ni mogoče prebrati kot števila + ' in the current locale: {values}': "" + def `commit`: + invalid operand: false + def `send_report`: + No data.: Ni podatkov. + Data instances: Število primerov + {} {}: false + is one of: je eden izmed + {', '.join(valnames[:-1])} or {valnames[-1]}: {', '.join(valnames[:-1])} ali {valnames[-1]} + {attr} is {valstr}: {attr} je {valstr} + {attr} {name} {attr.values[value]}: false + {attr} {name} {' and '.join(map(repr, values))}: {attr} {name} {' in '.join(map(repr, values))} + {attr} {name} {' and '.join(values)}: {attr} {name} {' in '.join(values)} + Instances: Primerov + Condition: Pogoji + ' AND ': ' IN ' + no conditions: ni pogojev + Data: Podatki + Matching data: Izbrani podatki + Non-matching data: Neizbrani podatki + Output: Izhod + {match_inst} {pl(match_inst, 'instance')}: {match_inst} {plsi(match_inst, 'primer')} + None: Prazno + {nonmatch_inst} {pl(nonmatch_inst, 'instance')}: {nonmatch_inst} {plsi(nonmatch_inst, 'primer')} + def `migrate_context`: + conditions: false + class `CheckBoxPopup`: + def `__init__`: + ', ': false + class `DateTimeWidget`: + def `set_format`: + yyyy-MM-dd hh:mm:ss: false + %Y-%m-%d %H:%M:%S: false + yyyy-MM-dd: false + %Y-%m-%d: false + hh:mm:ss: false + %H:%M:%S: false + __main__: false + heart_disease: false +widgets/data/owsplit.py: + class `OWSplit`: + Split: Razdeli + Split text or categorical variables into indicator variables: Razdeli besedilne ali kategorične spremenljivke na spremenljivke. + Transform: Predelava podatkov + icons/Split.svg: false + text, columns, word, encoding, questionnaire, survey, term, counts, indicator: text, columns, word, encoding, questionnaire, survey, term, counts, indicator, besedilo, stolpci, besede, kodiranje, vprašalnik, raziskava, štetje, indikator + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + class `Warning`: + Data contains only numeric variables.: Podatki vsebujejo samo številske spremenljivke. + Categorical (No, Yes): Kategorične (Ne, Da) + Numerical (0, 1): Številske (0, 1) + Counts: Števci + ;: true + def `__init__`: + Variable: Stolpec + attribute: false + delimiter: false + 'Delimiter: ': 'Ločilo: ' + output_type: false + Output Values: Izhodne vrednosti + def `_get_new_columns`: + No: Ne + Yes: Da + __main__: false + tests/orange-in-education.tab: false +widgets/data/owsql.py: + def `is_postgres`: + display_name: false + PostgreSQL: false + class `OWSql`: + SQL Table: Branje SQL + orange.widgets.data.sql: false + Load dataset from SQL.: Naloži podatke iz baze SQL. + icons/SQLTable.svg: false + Data: Podatki + sql table, load: sql table, load, naloži, + class `Outputs`: + Data: Podatki + Attribute-valued dataset read from the input file.: Podatki prebrani iz baze + class `Information`: + Data description was generated from a sample.: Podatki vsebujejo samo vzorec. + class `Error`: + Please install a backend to use this widget.: Namestite knjižnico za podatkovno bazo. + def `_add_tables_controls`: + Data Selection: Podatki + data_source: false + Table:: Tabela + Custom SQL:: Prikrojen SQL + table: tabela + TABLE_NAME: IME_TABELE + materialize: false + 'Materialize to table ': 'Materializiraj v tabelo ' + Save results of the query in a table: Shrani podatke poizvedbe v tabelo + materialize_table_name: false + Execute: Izvedi + guess_values: false + Auto-discover categorical variables: Samodejno zaznaj kategorične spremenljivke + def `highlight_error`: + 'QLineEdit {border: 2px solid red;}': false + server: false + host: false + role: false + database: false + def `on_connection_error`: + \n: false + def `refresh_tables`: + Select a table: Izberi tabelo + def `select_table`: + Table: Tabela + (None): (Brez) + def `get_table`: + Table: false + (None): (Brez) + Query: Poizvedba + Custom SQL: Prilagojen SQL + Specify a table name to materialize the query: Določi ime materializirane tabele + 'DROP TABLE IF EXISTS ': false + 'CREATE TABLE ': false + ' AS ': false + 'ANALYZE ': false + 'Attribute discovery might take ': 'Samodejno zaznavanje v velikih tabelah lahko zahteva ' + a long time on large tables.\n: veliko časa.\n + Do you want to auto discover attributes?: Želite samodejno zaznavanje? + Yes: Da + No: Ne + Yes, on a sample: Da, na vzorcu + 'Data appears to be big. Do you really ': 'Podatki so videti veliki. Jih res želite ' + want to download it to local memory?\n: naložiti v pomnilnik?\n + 'Table length: {:,}. Limit {:,}': Dolžina tabele: {:,}. Omejitev {:,} + Yes, a sample: Da, vzorec + Warning: Opozorilo + Data is too big to download.\n: Podatki so preveliki. + Question: Vprašanje + want to download it to local memory?: naložiti v pomnilnik? + def `migrate_settings`: + host: false + port: false + username: false + password: false + __main__: false +widgets/data/owtable.py: + class `HeaderViewWithSubsetIndicator`: + \N{BULLET}: false + def `paintSection`: + ' ': false + def `sectionSizeFromContents`: + ' ': false + class `OWTable`: + Data Table: Tabela + View the dataset in a spreadsheet.: Pregled podatkov v tabeli. + icons/Table.svg: false + data table, view: data table, view, pogled, podatki + class `Inputs`: + Data: Podatki + Data Subset: Podmnožica podatkov + class `Outputs`: + Selected Data: Izbrani podatki + class `Warning`: + Cannot restore sorting.\n: Razvrščanja ni mogoče obnoviti.\n + 'Missing columns in input table: {}': Manjkajoči stolpci v vhodni tabeli: {} + Input table cannot be sorted due to implementation constraints.: Vhodne tabele ni mogoče razvrstiti zaradi izvedbenih omejitev. + rows: false + columns: false + def `__init__`: + Info: true + Variables: Spremenljivke + show_attribute_labels: false + Show variable labels (if present): Pokaži oznake spremenljivk + show_distributions: false + Visualize numeric values: Vizualiziraj številske vrednosti + color_by_class: false + Color by instance classes: Obarvaj primere glede na razred + Selection: Izbor + Clear Selection: Počisti izbor + select_rows: false + Select full rows: Izbiraj cele vrstice + Restore Original Order: Izvirni vrstni red + Show rows in the original order: Pokaži vrstice v izvirnem vrstnem redu + auto_commit: false + def `set_dataset`: + def `update`: + _update_info: false + def `handleNewSignals`: + rows: false + columns: false + def `_update_input_summary`: + No data.: Ni podatkov. + \n: false + def `__restore_sort`: + ', ': false + def `__encode_column_id`: + def `escape`: + \\: false + TARGET: false + META: false + FEATURES: false + \\BASKET({lookup[coldesc.role]}): false + def `__decode_column_id`: + \\: false + def `commit`: + rows: false + columns: false + __main__: false + iris: false +widgets/data/owtransform.py: + class `TransformRunner`: + def `run`: + Transforming...: Spreminjam... + class `OWTransform`: + Apply Domain: Spremeni domeno + Applies template domain on data table.: Spremeni vhodne podatke glede na domeno iz vzorca. + Transform: Predelava podatkov + icons/Transform.svg: false + apply domain, transform: apply domain, transform, predelava + class `Inputs`: + Data: Podatki + Template Data: Podatki z domeno + class `Outputs`: + Transformed Data: Spremenjeni podatki + class `Error`: + An error occurred while transforming data.\n{}: Napake med spreminjanjem domene.\n{} + def `__init__`: + ' +The widget takes Data, to which it re-applies transformations +that were applied to Template Data. + +These include selecting a subset of variables as well as +computing variables from other variables appearing in the data, +like, for instance, discretization, feature construction, PCA etc. +': ' +Gradnik prejme Podatke in na njih ponovno izvede spremembe, ki +so bile izvedena na vzorcu. + +Spremembe vključujejo izbor spremenljivk ter sestavljanje novih +spremenljivk iz obstoječih, kot na primer diskretizacijo, PCA in podobno. +' + def `send_report`: + Data: Podatki + Template data: Vzorec + Transformed data: Spremenjeni podatki + __main__: false + iris: false +widgets/data/owtranspose.py: + class `OWTranspose`: + Transpose: Transponiraj + Transpose data table.: Spremeni vrstice v stolpce in obratno. + Transform: Predelava podatkov + icons/Transpose.svg: false + transpose: transpose, spremeni + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + Feature: Spremenljivka + class `Warning`: + 'Values are not unique.\nTo avoid multiple ': Vrednosti niso unikatne.\n K vrednostim + 'features with the same name, values \nof ': "'{}' so zato dodane številke." + "'{}' have been augmented with indices.": "" + Categorical features have been encoded as numbers.: Kategorične vrednosti so predstavljene z indeksi. + class `Error`: + {}: false + def `__init__`: + feature_type: false + Output column names: Imena izhodnih stolpcev + Generic: Generična imena + feature_name: false + Type a prefix ...: Določi predpono ... + Custom feature name: Določi predpono imen spremenljivk + From column:: Po stolpcu: + feature_names_column: false + remove_redundant_inst: false + Remove redundant instance: Odstrani odvečni primer + Name for column with original column names: Ime stolpca z imeni izvornih stolpcev + output_column_name: false + Column name: Ime stolpca + def `commit`: + Column name: Ime stolpca + def `send_report`: + from variable: po spremenljivki + " '{}'": false + Feature names: Imena spremenljivk + Data: Podatki + def `migrate_settings`: + output_column_name: false + Feature name: Ime spremenljivke + __main__: false + iris: false +widgets/data/owunique.py: + class `OWUnique`: + Unique: Enkratni + icons/Unique.svg: false + Filter instances unique by specified key attribute(s).: Filtriraj primere tako, da se ključne vrednosti ne ponavljajo. + Transform: Predelava podatkov + unique, distinct, remove, duplicates, filter: unique, distinct, remove, duplicates, filter, enkratni, odstrani, podvojene, filtriraj + class `Inputs`: + Data: Podatki + class `Outputs`: + Data: Podatki + Last instance: Zadnji primer + First instance: Prvi primer + Middle instance: Srednji primer + Random instance: Naključni primer + Discard non-unique instances: Zavrzi skupine z več primeri + def `__init__`: + selected_vars: false + Group by: Upoštevane spremenljivke + tiebreaker: false + Instance to select in each group:: Primer, ki predstavlja posamično skupino: + autocommit: false + Commit: Uveljavi + __main__: false + iris: false +widgets/data/utils/histogram.py: + class `ProportionalBarItem`: + def `__init__`: + If colors are provided, they must match the shape of distribution: false + def `_draw_bars`: + '#ccc': false + class `Histogram`: + def `__init__`: + '#000': false + Border tuple must be of size 4.: false + def `_histogram`: + Cannot calculate histogram on empty array: false + def `_get_colors`: + '#ccc': false + __main__: false + iris: false + '#000': false +widgets/data/utils/models.py: + class `RichTableModel`: + def `__init__`: + _: false + def `headerData`: + \n: false + class `TableSliceProxy`: + def `setRowSlice`: + invalid stride: false +widgets/data/utils/preprocess.py: + class `StandardItemModel`: + def `moveRow`: + '`moveRow` did not succeed! Data model might be ': false + in an inconsistent state.: false + def `moveRows`: + '`moveRows` did not succeed! Data model might be ': false + in an inconsistent state.: false + class `Controller`: + application/x-qwidget-ref: false + class `SequenceFlow`: + class `Frame`: + def `__init__`: + Remove: Odstrani + def `dropEvent`: + application/x-internal-move: false + def `dragEnterEvent`: + application/x-internal-move: false + def `__startInternalDrag`: + application/x-internal-move: false +widgets/data/utils/tablesummary.py: + def `_sql_table_len`: + Future[int]: false + def `format_summary`: + def `format_part`: + ' ({perc:.1f} % missing data)': \n ({perc:.1f} % manjkajočih podatkov) + sparse: redki + tags: oznake + ' ({tag}, density {dens:.2f} %)': ' ({tag}, gostota {dens:.2f} %)' + {ninst} {pl(ninst, 'instance')}: {ninst} {plsi(ninst, 'primer')} + ' (no missing data)': \n (ni manjkajočih podatkov) + {nattrs} {pl(nattrs, 'feature')}: {nattrs} {plsi(nattrs, 'spremenljivka')} + No target variable.: Ni ciljne spremenljivke. + {nclasses} {pl(nclasses, 'outcome')}: {nclasses} {plsi(nclasses, 'ciljna spremenljivka|ciljni spremenljivki|ciljne spremenljivke|ciljnih spremenljivk')} + Numeric outcome: Številska ciljna spremenljivka + Target with {nvalues} {pl(nvalues, 'value')}: Ciljna spremenljivka {plsi_sz(nvalues)} {z_besedo(nvalues, 6, 'm')} {plsi(nvalues, 'razredom|razredoma|razredi')} + {nmetas} {pl(nmetas, 'meta attribute')}: {nmetas} meta {plsi(nmetas, 'spremenljivka')} + No meta attributes.: Ni meta spremenljivk. +widgets/data/utils/tableview.py: + class `DataTableView`: + def `__init__`: + darwin: false + class `RichTableView`: + def `__headerDataChanged`: + \n: false +widgets/data/utils/pythoneditor/brackethighlighter.py: + class `BracketHighlighter`: + '#0b0': false + '#a22': false + ({[: false + )}]: false + def `_iterateDocumentCharsForward`: + Time is over: false + def `_iterateDocumentCharsBackward`: + Time is over: false + def `_makeMatchSelection`: + darkMode: false + '#111111': false +widgets/data/utils/pythoneditor/completer.py: + module: false + class: false + instance: false + function: false + param: false + path: false + keyword: false + property: false + statement: false + class `CompletionWidget`: + def `__init__`: + QListWidget::item:selected {: false + 'background-color: lightgray;': false + }: false + def `show_list`: + point: false + def `update_list`: + end: false + start: false + text: false + def `_get_cached_icon`: + %s is not a valid jedi type: false + ..: false + icons: false + pythonscript: false + .svg: false + def `set_item_display`: + text: false + type: false + def `get_html_item_representation`: + ': false + : false + ': false + ': false +
      ': false + ' ': false +  : false + ': false +
      : false + def `hide`: + tooltip_widget: false + def `keyPressEvent`: + .: false + :: false + def `is_up_to_date`: + text: false + def `focusOutEvent`: + darwin: false + def `trigger_completion_hint`: + point: false + textEdit: false + newText: false + insertText: false + $: false + python: false + (: false + documentation: false + class `Completer`: + def `_send_completion_request`: + complete: false + _CompletionRequest: false + id: false + code: false + pos: false + def `_handle_complete_reply`: + complete: false + parent_header: false + msg_id: false + content: false + metadata: false + _jupyter_types_experimental: false + Jupyter API has changed, completions are unavailable.: false + cursor_start: false + type: false + : false +widgets/data/utils/pythoneditor/editor.py: + class `PythonEditor`: + e: false + w: false + n: false + \n: false + def `__init__`: + darkMode: false + '#111111': false + '#ffffff': false + '#444444': false + '#000000': false + '#ffee00': false + def `_initActions`: + Toggle comment line: false + Ctrl+/: false + Scroll up: false + Ctrl+Up: false + go-up: false + Scroll down: false + Ctrl+Down: false + go-down: false + Select and scroll Up: false + Ctrl+Shift+Up: false + Select and scroll Down: false + Ctrl+Shift+Down: false + Increase indentation: false + Tab: false + format-indent-more: false + Decrease indentation: false + Shift+Tab: false + format-indent-less: false + Autoindent line: false + Ctrl+I: false + Indent with 1 space: false + Ctrl+Shift+Space: false + Unindent with 1 space: false + Ctrl+Shift+Backspace: false + Undo: false + edit-undo: false + Redo: false + edit-redo: false + Move line up: false + Alt+Up: false + Move line down: false + Alt+Down: false + Delete line: false + Alt+Del: false + edit-delete: false + Cut line: false + Alt+X: false + edit-cut: false + Copy line: false + Alt+C: false + edit-copy: false + Paste line: false + Alt+V: false + edit-paste: false + Duplicate line: false + Alt+D: false + def `_onToggleCommentLine`: + def `isHashCommentSelected`: + '#': false + '# ': false + def `selectedText`: + \u2029: false + \n: false + def `eol`: + \r: false + \n: false + \r\n: false + Invalid EOL value: false + def `replaceText`: + Invalid start position %d: false + Invalid end position %d: false + def `_setSolidEdgeGeometry`: + 9: false + def `keyPressEvent`: + def `backspaceOverwrite`: + ' ': false + darwin: false + def `_updateTabStopWidth`: + ' ': false + def `lines`: + Invalid new value of "lines" attribute: false + \n: false + def `textForSaving`: + \n: false + def `_get_token_at`: + tokens: false + syntax_stack: false + def `mapToAbsPosition`: + Invalid line index %d: false + Invalid column index %d: false + def `mapToLineCol`: + Invalid absolute position %d: false + def `insert_completion`: + start: false + end: false + text: false + def `keyReleaseEvent`: + _: false + .: false + 'from ': false + 'import ': false + def `_chooseVisibleWhitespace`: + \t: false + ' ': false + def `_drawIndentMarkersAndEdge`: + def `drawWhiteSpace`: + ' ': false + def `effectiveEdgePos`: + \t: false + def `insertFromMimeData`: + ', ': false + "'": false + "'\""'\""'": false + def `get_current_word_and_position`: + def `is_special_character`: + ([^\d\W]\w*): false + ([^\d\W]\w*): false + class `LineNumberArea`: + def `__init__`: + line_numbers: false + def `__calculateWidth`: + 9: false + def `__allocateBits`: + A margin cannot request negative number of bits: false + def `setBlockValue`: + The margin ': false + "' did not allocate any bits for the values": false + "' must be a positive integer": false + "' value exceeds the allocated bit range": false + def `getBlockValue`: + The margin ': false + "' did not allocate any bits for the values": false +widgets/data/utils/pythoneditor/indenter.py: + class `Indenter`: + def `text`: + \t: false + ' ': false + def `autoIndentBlock`: + \n: false + def `onChangeSelectedBlocksIndent`: + def `indentBlock`: + ' ': false + def `spacesCount`: + ' ': false + def `unIndentBlock`: + \t: false + def `onShortcutIndentAfterCursor`: + def `insertIndent`: + \t: false + ' ': false + class `IndentAlgBase`: + def `computeIndent`: + \n: false + def `_makeIndentFromWidth`: + \t: false + ' ': false + def `_makeIndentAsColumn`: + \t: false + def `findBracketBackward`: + (: false + ): false + [: false + ]: false + {: false + }: false + Invalid bracket "%s": false + Not found: false + def `findAnyBracketBackward`: + (): false + []: false + {}: false + Not found: false + class `IndentAlgPython`: + def `_computeSmartIndent`: + )]}: false + ,: false + continue: false + break: false + pass: false + raise: false + return: false + 'raise ': false + 'return ': false + :: false + {[: false +widgets/data/utils/pythoneditor/lines.py: + class `Lines`: + def `_checkAndConvertIndex`: + Invalid block index: false + def `__setitem__`: + Attempt to replace %d lines with %d lines: false + def `insert`: + Invalid block index: false +widgets/data/utils/pythoneditor/rectangularselection.py: + class `RectangularSelection`: + text/rectangular-selection: false + def `_visibleCharPositionGenerator`: + \t: false + def `_visibleToRealColumn`: + \t: false + def `cursors`: + Rectangular selection area is too big: false + def `copy`: + \n: false + utf8: false + def `_indentUpTo`: + \t: false + ' ': false + def `paste`: + utf8: false +widgets/data/utils/pythoneditor/vim.py: + a: false + z: false + Key_: false + _: false + def `isChar`: + ' ': false + normal: false + insert: false + replace character: false + '#33cc33': false + '#ff9900': false + '#ff3300': false + class `Vim`: + def `extraSelections`: + '#ffcc22': false + '#000000': false + class `Insert`: + '#ff9900': false + def `text`: + insert: false + class `ReplaceChar`: + '#ee7777': false + def `text`: + replace char: false + class `Replace`: + '#ee7777': false + def `text`: + replace: false + class `BaseCommandMode`: + gg: false + def `_moveCursor`: + gg: false + 'Not expected motion ': false + class `BaseVisual`: + '#6699ff': false + def `_processChar`: + gg: false + \n: false + def `cmdJoinLines`: + ' ': false + def `cmdInternalPaste`: + \n: false + class `Visual`: + visual: false + class `VisualLines`: + visual lines: false + class `Normal`: + '#33cc33': false + normal: false + def `_processChar`: + gg: false + def `cmdJoinLines`: + ' ': false + def `cmdInternalPaste`: + \n: false + def `cmdCompositeDelete`: + gg: false +widgets/data/utils/pythoneditor/tests/run_all.py: + __main__: false + .: false + test_*: false + Suite created: false + Run done: false + OK: false + Failed: false +widgets/evaluate/__init__.py: + Evaluate: Vrednotenje + orange.widgets.evaluate: false + Evaluate model performance: Vrednotenje uspešnosti modela + '#C3F3F3': false + icons/Category-Evaluate.svg: false +widgets/evaluate/owcalibrationplot.py: + metric_definition: false + name: false + functions: false + short_names: false + explanation: false + Calibration curve: Kalibracijska krivulja + Classification accuracy: Klasifikacijska točnost + F1: true + Sensitivity and specificity: Senzitivnost in specifičnost + sens: senz + spec: spec + # Tule puščam TP, TN in tako naprej. Moremo to kako prevesti? + '

      Sensitivity (falling) is the proportion of correctly ': '

      Senzitivnost (padajoča) je delež pravilno ' + detected positive instances (TP / P).

      : zaznanih pozitivnih primerov, TP / P.

      + '

      Specificity (rising) is the proportion of detected ': '"

      Specifičnost (naraščajoča) je delež pravilno ' + negative instances (TN / N).

      : zaznanih negativnih primerov, TN / N.

      + Precision and recall: Preciznost in priklic + prec: spec + recall: priklic + '

      Precision (rising) is the fraction of retrieved instances ': '

      Preciznost (naraščajoča) je delež zaznanih relevantnih primerov, ' + that are relevant, TP / (TP + FP).

      : TP / (TP + FP).

      + '

      Recall (falling) is the proportion of discovered relevant ': '

      Priklic (padajoč) je delež odkritih relevantnih primerov, ' + instances, TP / P.

      : TP / P.

      + Pos and neg predictive value: Poz in neg nap. vrednost + PPV: true + TPV: true + '

      Positive predictive value (rising) is the proportion of ': '

      Pozitivna napovedna vrednost (naraščajoča) je delež ' + correct positives, TP / (TP + FP).

      : dejansko pozitivnih, TP / (TP + FP).

      + '

      Negative predictive value is the proportion of correct ': '

      Negativna napovedna vrednost (naraščajoča) je delež ' + negatives, TN / (TN + FN).

      : dejanstko negativnih, , TN / (TN + FN).

      + True and false positive rate: Delež zaznanih poz in neg + TPR: true + FPR: true + '

      True and false positive rate are proportions of detected ': '"

      Delež zaznanih pozitivnih in negativnih primerov izmed vseh ' + and omitted positive instances

      : pozitivnih oz. negativnih primerov. + class `ParameterSetter`: + def `axis_items`: + item: false + class `OWCalibrationPlot`: + Calibration Plot: Kalibracijska krivulja + Calibration plot based on evaluation of classifiers.: Kalibracijska krivulja na osnovi rezultatov vrednotenja modelov. + icons/CalibrationPlot-symbolic.svg: false + calibration plot: calibration plot, krivulja + class `Inputs`: + Evaluation Results: Rezultati vrednotenja + class `Outputs`: + Calibrated Model: Kalibrirani model + class `Error`: + 'Calibration plot requires a categorical ': Kalibracijska krivulja zahteva kategorično ciljno spremenljivko. + target variable.: "" + Empty result on input. Nothing to display.: Ni rezultatov za prikaz. + Remove test data instances with unknown classes.: Odstrani testne primere z neznanim razredom. + All data instances belong to target class.: Vsi testni primeri pripadajo ciljnemu razredu. + No data instances belong to target class.: Noben testni primer ne pripada ciljnemu razredu. + class `Warning`: + Test folds where all data belongs to (non)-target are not shown.: Testne množice, v katerih vsi podatki pripadajo (ne)-ciljnemu razredu, so izpuščene. + Instance for which the model couldn't compute probabilities are: Primeri, za katere model ne more izračunati verjetnosti, so izpuščeni. + skipped.: "" + No valid data for model(s) {}: Ni veljavnih podatkov za model(e) {} + class `Information`: + "Can't output a model: {}": Na izhodu ni modela: {} + plot: false + def `__init__`: + Settings: Nastavitve + target_index: false + Target:: Cilj: + display_rug: false + Show rug: Pokaži preprogo + fold_curves: false + Curves for individual folds: Krivulje za podmnožice + selected_classifiers: false + classifier_names: false + Classifier: Modeli + Metrics: Metrika + score: false + output_calibration: false + Sigmoid calibration: Sigmoidna kalibracija + Isotonic calibration: Izotonična kalibracija + Output model calibration: Kalibracija izhodnega modela + Info: Rezultati + auto_commit: false + bottom: false + left: false + def `_set_explanation`: + bottom: false + Predicted probability: Napovedana verjetnost + Threshold probability to classify as positive: Prag za klasifikacijo v pozitivni razred + left: false + def `_initialize`: + learner_names: false + '#{}': false + def `_rug`: + pen: false + pairs: false + def `_prob_curve`: + +: false + def `_setup_plot`: + ', ': false + k: false + def `get_info_text`: + def `elided`: + ...: false + " + + + + ": "
      Threshold: p={self.threshold:.2f}
      + + + + " + "
      Prag:{self.threshold:.2f}
      + + + + + ": "
      Threshold:p = {self.threshold:.2f}
      +
      + + + + + " + " + + {"""".join(f"""" + for n in short_names)} + ": false + : false + : false + : false + : false +
      Prag:p = {self.threshold:.2f}
      +
      {n}
      {elided(name)}:/{curve[ind]:.3f}
      : false + def `send_report`: + Target class: Ciljni razred + Output model calibration: Kalibracija izhodnega modela + Sigmoid calibration: Sigmoidna kalibracija + Isotonic calibration: Izotonična kalibracija + __main__: false +widgets/evaluate/owconfusionmatrix.py: + class `BorderedItemDelegate`: + def `paint`: + t: false + r: false + b: false + l: false + class `OWConfusionMatrix`: + Confusion Matrix: Matrika zmot + 'Display a confusion matrix constructed from ': 'Pokaži matriko zmot glede na ' + the results of classifier evaluations.: rezultate vrednotenja. + icons/ConfusionMatrix-symbolic.svg: false + confusion matrix: confusion matrix + class `Inputs`: + Evaluation Results: Rezultati vrednotenja + class `Outputs`: + Selected Data: Izbrani podatki + Number of instances: Število primerov + Proportion of predicted: Delež od napovedanih + Proportion of actual: Delež od dejanskih + Sum of probabilities: Vsota verjetnosti + Number of correctly and incorrectly classified instances: Število pravilno oz. napačno uvrščenih primerov + 'Number of instances, distributed across columns ': 'Podobno kot število primerov, le da je vsak primer\n"razmazan" po stolpcih ' + according to predicted probabilities: glede na napovedane verjetnosti. + 'Clicking on cells or in headers outputs the corresponding ': 'Izberite eno ali več celic (ali kliknite glavo tabele) in pripadajoči ' + data instances: primeri bodo na izhodu. + click_cell: false + class `Error`: + Confusion Matrix cannot show regression results.: Matrika zmot obravnava klasifikacijo, ne regresije. + Evaluation Results input contains invalid values: Rezultati vrednotenja vsebujejo neveljavne vrednosti. + Empty result on input. Nothing to display.: Ni podatkov za prikaz. + def `__init__`: + selected_learner: false + learners: false + Learners: Modeli + Output: Izhod + append_predictions: false + Predictions: Napovedi + append_probabilities: false + Probabilities: Verjetnosti + autocommit: false + selected_quantity: false + 'Show: ': 'Pokaži: ' + Select Correct: Izberi pravilne + Select Misclassified: Izberi napačne + Clear Selection: Počisti izbor + def `_init_table`: + Predicted: Napovedana vrednost + Actual: Dejanska vrednost + br: false + ' ': false + def `set_results`: + N-ARY SUMMATION: false + learner_names: false + Learner #{i + 1}: Model #{i + 1} + def `_prepare_data`: + {}({}): false + p({value}): true + def `_update`: + {}: false + {:2.1f} %: false + {:2.1f}: false + NA: NN + trbl: false + 'actual: {}\npredicted: {}': dejanski: {}\nnapovedani: {} + t: false + l: false + def `send_report`: + Confusion matrix for {} (showing {}): Matrika zmot za model {} (prikazano {}) + def `migrate_settings`: + selected_learner: false + __main__: false + iris: false +widgets/evaluate/owfeatureaspredictor.py: + class `OWFeatureAsPredictor`: + Feature as Predictor: Stolpec kot napoved + Use a column as probabilities or predictions: Uporabi stolpec kot verjetnosti ali napovedi + icons/FeatureAsPredictor-symbolic.svg: false + column predictor: true + class `Inputs`: + Data: Podatki + class `Outputs`: + Learner: Učni algoritem + Model: Model + class `Error`: + Data has no target variable.: Podatki nimajo ciljne spremenljivke. + No useful variables: Ni uporabnih spremenljivk. + def `__init__`: + auto_apply: false + def `_update_controls`: + logistic: logistično + linear: linearno + Transform through {shape} function: Pretvori z {shape} funkcijo + Use {shape} regression to fit the model's coefficients: Uporabi {shape} regresijo za nastavitev koeficientov modela + def `send_report`: + Predict values from: Napoved vrednosti iz + Applied transformation: Uporabljena transformacija + logistic: logistična + linear: linearna + Intercept: Presečišče + Coefficient: Koeficient + __main__: false + heart_disease: false +widgets/evaluate/owliftcurve.py: + CurveData: false + contacted: false + respondents: false + thresholds: false + class `ParameterSetter`: + Line: Črta + Default Line: Privzeta črta + Solid line: Polna črta + Dash line: Prekinjena črta + def `axis_items`: + item: false + class `OWLiftCurve`: + Performance Curve: Krivulja zmogljivosti + 'Construct and display a performance curve ': 'Sestavi in pokaže krivuljo zmogljivosti ' + from the evaluation of classifiers.: iz rezultatov vrednotenja. + icons/LiftCurve-symbolic.svg: false + performance curve, lift, cumulative gain, precision, recall, curve: performance curve, lift, cumulative gain, precision, recall, curve, dvig, kumulativni dobiček, preciznost, priklic, krivulja + class `Inputs`: + Evaluation Results: Rezultati vrednotenja + class `Outputs`: + Calibrated Model: Kalibriran model + class `Warning`: + Some curves are undefined; check models and data: Nekatere krivulje niso definirane; preverite modele in podatke. + class `Error`: + No defined curves; check models and data: Ni veljavnih krivulj; preverite modele in podatke. + class `Information`: + "Can't output a model: {}": Ni izhodnega modela: {} + plot: false + P Rate: Delež pozitivnih + Recall: Priklic + Lift: Dvig + TP Rate: Delež resničnih pozitivnih + Precision: Preciznost + def `__init__`: + Curve: Krivulja + target_index: false + 'Target: ': 'Cilj: ' + curve_type: false + Lift Curve: Krivulja dviga + Cumulative Gains: Kumulativni dobiček + Precision Recall: Preciznost - Priklic + selected_classifiers: false + classifier_names: false + Models: Modeli + Settings: Nastavitve + show_threshold: false + Show thresholds: Pokaži prag + show_points: false + Show points: Pokaži točke + Area under the curve: Površina pod krivuljo + /: false + auto_commit: false + bottom: false + left: false + def `_initialize`: + learner_names: false + '#{i}': false + def `_set_axes_labels`: + bottom: false + left: false + def `_setup_plot`: + k: false + def `_plot_curve`: + def `tip`: + '{xlabel}: {round(x, 3)}\n': false + '{ylabel}: {round(y, 3)}\n': false + 'Threshold: {round(data, 3)}': Prag: {round(data, 3)} + def `_plot`: + hoverable: false + tip: false + symbol: false + o: false + symbolSize: false + symbolPen: false + symbolBrush: false + data: false + stepMode: false + right: false + def `_update_info`: + /: false +
      : false + {round(area, 3)}: false + : false + : false + '': false + : false + : false + def `_set_tooltip`: + '
      Probability threshold(s):': ' {plsi(len(self.plot.curve_items), "prag|praga|pragi")} verjetnosti:' + data: false +
      : false + '': false + {round(threshold, 3)}: false +
      : false + def `commit`: + data: false + def `send_report`: + Target class: Ciljni razred + def `cumulative_gains`: + array dimensions don't match: false + mergesort: false + __main__: false +widgets/evaluate/owparameterfitter.py: + def `_search`: + Calculating...: Računam... + class `ParameterSetter`: + Gridlines: Mrežne črte + Show: Pokaži + def `axis_items`: + item: false + class `FitterPlot`: + def `clear_all`: + bottom: false + left: false + def `set_data`: + bottom: false + left: false + '#6fa255': false + '#3a78b6': false + '#333': false + pen: false + width: false + symbol: false + s: false + Train: Učni + CV: Prečno prev. + def `help_event`: +
      {self.classifier_names[clf_idx]}: {area}
      : false + : false + : + : false + : false + : + : false + : false +
      Train:Učni:{round(scores[0], 3)}
      CV:Prečno preverjanje:{round(scores[1], 3)}
      : false + def `__get_index_at`: + height: false + class `RangePreview`: + def `paintEvent`: + {self.__steps[-1]}: false + ', ': false + 'Steps: ': 'Koraki: ' + class `OWParameterFitter`: + Parameter Fitter: Umerjanje parametrov + Fit learner for various values of fitting parameter.: Umeri model za različne vrednosti parametrov. + icons/ParameterFitter-symbolic.svg: false + parameter, fitter, tuning: parameter, fitter, tuning, umerjanje, prilagajanje + graph.plotItem: false + class `Inputs`: + Data: Podatki + Learner: Učni algoritem + class `Error`: + {}: false + At least {N_FOLD} instances are needed.: Potrebujemo vsaj {N_FOLD} primerov. + "Invalid values for '{}': {}": Neveljavne vrednosti za '{}': {} + Minimum must be less than maximum.: Minimum mora biti manjši od maksimuma. + Data has no target.: Podatki nimajo ciljne spremenljivke. + class `Warning`: + {} has no parameters to fit.: {} nima parametrov za umerjanje. + def `_add_controls`: + Settings: Nastavitve + parameter_index: false + type: false + Range:: Območje: + minimum: false + From:: Od: + maximum: false + To:: Do: + Manual:: Ročno: + manual_steps: false + e.g. 10, 20, ..., 50: npr. 10, 20, ..., 50 + auto_commit: false + def `initial_parameters`: + classification: false + def `_steps_from_manual`: + ...: false + ', ': false + def `_set_range_controls`: + The widget currently supports only int parameters: false + Enter a list of values: Vnesite seznam vrednosti + {tip} between {param.min} and {param.max}.: {tip} med {param.min} in {param.max}. + {tip} greater or equal to {param.min}.: {tip} večjih ali enakih {param.min}. + {tip} smaller or equal to {param.max}.: {tip} manjših ali enakih {param.max}. + def `send_report`: + Settings: Nastavitve + Parameter: Parameter + Range: Območje + ', ': false + Plot: Graf + __main__: false + housing: false +widgets/evaluate/owpermutationplot.py: + def `permutation`: + Calculating...: Računam... + class `ParameterSetter`: + Gridlines: Mrežne črte + Show: Pokaži + def `axis_items`: + item: false + class `PermutationPlot`: + def `__init__`: + Correlation between original Y and permuted Y (%): Korelacija med izvirnim Y in permutiranim Y (%) + bottom: false + def `set_data`: + left: false + AUC: true + '#000': true + '#333': true + pen: false + symbol: false + o: false + brush: false + '#6fa255': true + s: false + '#3a78b6': true + size: false + hoverable: false + tip: false + 'x: {x:.3g}\ny: {y:.3g}': true + Train: Učna + CV: Prečno + class `OWPermutationPlot`: + Permutation Plot: Permutacijski grafikon + Permutation analysis plotting: Grafični prikaz permutacijske analize + icons/PermutationPlot-symbolic.svg: false + _keywords: permutation, plot + graph.plotItem: false + class `Inputs`: + Data: Podatki + Learner: Učni algoritem + class `Error`: + {}: false + At least {N_FOLD} instances are needed.: Potrebujemo vsaj {N_FOLD} primerov. + def `_add_controls`: + Settings: Nastavitve + n_permutations: false + Permutations:: Permutacije: + Info: Informacije + def `__set_info`: + No data available.: Ni podatkov. + ' + + + + + + + + + + + + + + + + +
      Corr = 0Corr = 100
      Train{intercept_tr:.4f}{y_tr:.4f}
      CV{intercept_cv:.4f}{y_cv:.4f}
      + ': ' + + + + + + + + + + + + + + + + +
      Kor. = 0Kor. = 100
      Učni{intercept_tr:.4f}{y_tr:.4f}
      Prečno{intercept_cv:.4f}{y_cv:.4f}
      + ' + def `send_report`: + Settings: Nastavitve + Permutations: Permutacije + Info: Informacije + Plot: Graf + __main__: false + iris: false +widgets/evaluate/owpredictions.py: + PredictorSlot: false + predictor: false + name: false + results: false + (None): (Ne kaži) + Difference: razlika + Absolute difference: absolutna razlika + Relative: relativna napaka + Absolute relative: absolutna relativna napaka + Don't show columns with errors: Ne kaži stolpcev z napakami + Show difference between predicted and actual value: Pokaži razliko med napovedano in resnično vrednostjo + Show absolute difference between predicted and actual value: Pokaži absolutno razliko med napovedano in resnično vrednostjo + Show relative difference between predicted and actual value: Pokaži relativno napako + Show absolute value of relative difference between predicted and actual value: Pokaži absolutno vrednost relativne napake + class `OWPredictions`: + Predictions: Napovedi + icons/Predictions-symbolic.svg: false + Display predictions of models for an input dataset.: Pokaži napovedi modela na tabeli podatkov. + predictions: predictions + class `Inputs`: + Data: Podatki + Predictors: Modeli + class `Outputs`: + Selected Predictions: Izbrane napovedi + Predictions: Napovedi + Evaluation Results: Rezultati vrednotenja + class `Warning`: + Empty dataset: Prazna tabela + Some model(s) predict a different target (see more ...)\n{}: Nekateri modeli napovedujejo drugo spremenljivko (več...)\n{} + 'Instances with missing targets ': 'Primeri z manjkajočimi ciljnimi vrednostmi niso ' + are ignored while scoring.: upoštevani pri vrednotenju. + class `Error`: + Some predictor(s) failed (see more ...)\n{}: V nekaterih modelih je prišlo do napake (več...)\n{} + Some scorer(s) failed (see more ...)\n{}: Nekatere cenilke niso izračunani (več...)\n{} + (None): (Ne kaži) + Classes in data: razrede iz tabele + Classes known to the model: razrede modelov + Classes in data and model: razrede iz tabele in modelov + Don't show probabilities: Ne kaži verjetnosti + Show probabilities for classes in the data: Pokaži verjetnosti razredov v tabeli + Show probabilities for classes known to the model,\n: Pokaži verjetnosti razredov, ki jih napovedujejo modeli,\n + including those that don't appear in this data: vključno s temi, ki jih v podatkih ni. + Show probabilities for classes in data that are also\n: Pokaži verjetnosti razredov, ki se pojavijo v podatkih\n + known to the model: in so znani tudi modelom. + (Average over classes): (Povprečje prek razredov) + def `__init__`: + Show probabilities for: Pokaži verjetnosti za + shown_probs: false + show_probability_errors: false + Show classification errors: Pokaži klasifikacijske napake + Show 1 - probability assigned to the correct class: Pokaže 1 - verjetnost, napovedano pravilnemu razredu + 'Shown regression error: ': Prikazana napaka: + show_reg_errors: false + See tooltips for individual options: Poglej namige ob posameznih možnostih + Restore Original Order: Izvirni vrstni red + Show rows in the original order: Pokaži vrstice v izvirnem vrstnem redu + show_scores: false + Show perfomance scores: Pokaži rezultate vrednotenja + Target class:: Ciljni razred: + target_class: false + def `_call_predictors`: + '{predictor.name}: {err}': false + def `_update_scores`: + N/A: NN + NA: NN + {score:.3f}: false + def `_update_score_table_visibility`: + \n: false + def `_set_errors`: + \n: false + '- {p.predictor.name}: {p.results}': false + - {pred.name} predicts '{pred.domain.class_var.name}': false + def `_get_details`: + Data:
      : Podatki:
      +
      : false + "Model: {n_predictors} {pl(n_predictors, 'model')}": Modeli: {n_predictors} {plsi(n_predictors, 'model')} + ' ({n_predictors - n_valid} failed)': ' ({n_predictors - n_valid} z napako)' +
        : false +
      • {name}
      • : false +
      : false + Model:
      No model on input.: Model:
      Ni vhodnih modelov. + def `_update_prediction_delegate`: + ignore: false + .*All-NaN.*: false + def `_add_classification_out_columns`: + {name} ({value}): false + def `_add_error_out_columns`: + {slot.predictor.name} (error): {slot.predictor.name} (napaka) + def `send_report`: + \n: false +
      : false + '
      Showing probabilities for ': '
      Verjetnosti za ' + all classes known to the model.: razrede, ki jih napovedujejo modeli. + all classes that appear in the data.: razrede, ki se pojavijo v podatkih. + 'all classes that appear in the data ': 'razrede, ki jih napovedujejo modeli ' + and are known to the model.: in se pojavljajo v podatkih. + "'{self.class_var.values[class_idx]}.'": false + Info: true + Data & Predictions: Podatki in napovedi + Scores: Rezultati + Target class: Ciljni razred + def `migrate_settings`: + score_table: false + def `migrate_context`: + target_class: false + class `ClassificationItemDelegate`: + def `__init__`: + ' : ': false + {{dist[{i}]:.2f}}: false + -: true + {probs} → {{value!s}}: true + {value!s}: false + p({', '.join(tooltip_probabilities)}): true + def `sizeHint`: + {x}.{x}{x}: false + class `ErrorDelegate`: + def `sizeHint`: + X: false + class `ClassificationErrorDelegate`: + def `displayText`: + ?: true + {value:.3f}: false + class `RegressionItemDelegate`: + def `__init__`: + {{value:{(target_format or '%.2f')[1:]}}}: false + class `RegressionErrorDelegate`: + def `displayText`: + ?: true + -∞: true + ∞: true + class `PredictionsModel`: + def `headerData`: + error: false + def `errorColumn`: + ignore: false + def `tool_tip`: + {value:!s} {dist:!s}: false + __main__: false + iris.tab: false + To err is human: false +widgets/evaluate/owrocanalysis.py: + ROCPoints: false + fpr: false + tpr: false + thresholds: false + ROCCurve: false + points: false + hull: false + ROCAveragedVert: false + tpr_std: false + ROCAveragedThresh: false + fpr_std: false + ROCData: false + merged: false + folds: false + avg_vertical: false + avg_threshold: false + PlotCurve: false + curve: false + curve_item: false + hull_item: false + def `plot_curve`: + +: false + PlotAvgCurve: false + confint_item: false + def `plot_avg_curve`: + +: false + Some: false + val: false + PlotCurves: false + merge: false + class `OWROCAnalysis`: + ROC Analysis: Analiza ROC + 'Display the Receiver Operating Characteristics curve ': 'Pokaže krivuljo ROC za ' + based on the evaluation of classifiers.: rezultate vrednotenja. + icons/ROCAnalysis-symbolic.svg: false + roc analysis, analyse: roc analysis, analyse + class `Inputs`: + Evaluation Results: Rezultati vrednotenja + class `Outputs`: + Calibrated Model: Kalibriran model + class `Information`: + "Can't output a model: {}": Ne morem sestaviti modela: {} + plot: false + def `__init__`: + Plot: Krivulja + target_index: false + Target: Ciljni razred + Classifiers: Modeli + selected_classifiers: false + classifier_names: false + Curves: Krivulje + roc_averaging: false + Merge Predictions from Folds: Združi napovedi na podmnožicah + Mean TP Rate: Povprečni delež resničnih poz. + Mean TP and FP at Threshold: Povprečni res. poz. in laž. neg. na pragu + Show Individual Curves: Pokaži posamične krivulje + display_convex_curve: false + Show convex ROC curves: Pokaži konveksne krivulje + display_convex_hull: false + Show ROC convex hull: Pokaži konveksno ovojnico + Analysis: Analiza + display_def_threshold: false + Default threshold (0.5) point: Privzeti prag (0.5) + display_perf_line: false + Show performance line: Pokaži črto delovanja + fp_cost: false + FP Cost:: Cena lažnega neg. + fn_cost: false + FN Cost:: Cena lažnega poz. + target_prior: false + ' %': true + Auto: false + Prior probability:: Apriorna verjetnost: + bottom: false + FP Rate (1-Specificity): Delež lažnih pozitivnih (1 - specifičnost) + left: false + TP Rate (Sensitivity): Delež resničnih pozitivnih (senzitivnost) + def `_initialize`: + learner_names: false + '#{}': true + def `_set_target_prior`: + 'color: gray;': false + def `_setup_plot`: + def `merge_averaging`: + {:.3f}: false + Some ROC curves are undefined: Nekatere krivulje ROC niso definirane. + All ROC curves are undefined: Nobene krivulja ROC ni definirana. + def `_update_axes_ticks`: + def `enumticks`: + {x:.2f}: false + bottom: false + left: false + def `_on_mouse_moved`: + Thresholds:\n: Pragi:\n + \n: false + ({:s}) {:.3f}: false + def `_on_target_prior_changed`: + 'color: black;': false + def `send_report`: + Target class: Ciljni razred + Costs: Cene + FP = {}, FN = {}: Lažni pozitivni = {}, lažni negativni = {} + Target probability: Ciljna verjetnost + {} %: false + def `interp`: + xp and fp must have the same shape: false + right: false + def `roc_curve_vertical_average`: + No curves: false + def `roc_curve_threshold_average`: + No curves: false + left: false + RocPoint: false + threshold: false + def `_create_results`: + heart_disease: false + 1100111001001000: false + __main__: false +widgets/evaluate/owtestandscore.py: + class `InputLearner`: + Try[Orange.evaluation.Results]: false + Try[float]: false + class `Try`: + class `Success`: + __value: false + def `__repr__`: + {}({!r}): false + class `Fail`: + __exception: false + def `__repr__`: + {}({!r}): false + class `State`: + Waiting: false + Running: false + Done: false + Cancelled: false + class `OWTestAndScore`: + Test and Score: Testiraj in meri + Cross-validation accuracy estimation.: Ocenjevanje modeliranja s prečnim preverjanjem. + icons/TestLearners1-symbolic.svg: false + test and score, cross validation, cv: test and score, cross validation, cv, prečno preverjanje + Orange.widgets.evaluate.owtestlearners.OWTestLearners: false + class `Inputs`: + Data: Podatki + Test Data: Testni podatki + Learner: Algoritmi učenja + Preprocessor: Predprocesorji + class `Outputs`: + Predictions: Napovedi + Evaluation Results: Rezultati vrednotenja + (None, show average over classes): (Brez, pokaži poprečje prek razredov) + class `Error`: + Test dataset is empty.: Tabela testnih podatkov je prazna + Test data input requires a target variable.: Testni podatki morajo imeti ciljno spremenljivko. + Number of folds exceeds the data size: Števil podmnožic presega število primerov. + 'Test and train datasets ': 'Učni in testni podatki ' + have different target variables.: nimajo iste ciljne spremenljivke. + Not enough memory.: Premalo pomnilnika. + Test data may be incompatible with train data.: Testni podatki niso združljivi z učnimi. + {}: false + class `Warning`: + Instances with unknown target values were removed from{}data.: Primeri z manjkajočo ciljni spremenljivko so odstranjenih iz{}podatkov. + Missing separate test data input.: Na vhodu ni testnih primerov. + Some scores could not be computed.: Nekaterih mer ni bilo mogoče izračunati. + 'Test data is present but unused. ': 'Testni primeri so podani, a neuporabljeni. ' + Select 'Test on test data' to use it.: Izberite 'Testiraj na testnih primerih'. + "Can't run stratified {}-fold cross validation; ": 'Ne morem uporabiti {}-kratnega stratificiranega prečnega preverjanja; ' + the least common class has only {} instances.: najmanjši razred ima samo {} primerov. + class `Information`: + Train data has been sampled: Učni primeri so bili vzorčeni. + Test data has been sampled: Testni primeri so bili vzorčeni. + Test data has been transformed to match the train data.: Testni podatki so transformirani, da ustrezajo učnim. + Stratification is ignored for regression: Testiranje regresije ne uporablja stratifikacije. + Stratification is ignored when there are: Stratifikacija ne deluje z več ciljnimi spremenljivkami. + ' multiple target variables.': "" + def `__init__`: + resampling: false + Cross validation: Prečno preverjanje + n_folds: false + 'Number of folds: ': Število podmnožic + cv_stratified: false + Stratified: Stratificirano + Cross validation by feature: Prečno po spremenljivki + fold_feature: false + Random sampling: Naključno vzorčenje + n_repeats: false + 'Repeat train/test: ': Število ponovitev: + sample_size: false + 'Training set size: ': 'Delež učne množice: ' + {} %: false + shuffle_stratified: false + Leave one out: Izpusti enega + Test on train data: Testiraj na učnih primerih + Test on test data: Testiraj na testnih primerih + class_selection: false + Evaluation results for target: Rezultati vrednotenja za cilj + comparison_criterion: false + Compare models by:: Primerjaj modele glede na: + use_rope: false + 'Negligible diff.: ': Zanemarljiva razlika + rope: false + 'Table shows probabilities that the score for the model in ': '"Tabela kaže verjetnost, da je model v ' + 'the row is higher than that of the model in the column. ': 'vrstici dejansko boljši od modela v stolpcu. ' + 'Small numbers show the probability that the difference is ': Majhne številke pomenijo zanemarljivo verjetnost. + negligible.: + def `set_train_data`: + Train dataset is empty.: Tabela učnih primerov je prazna. + Train data input requires a target variable.: Učni primeri morajo imeti ciljno spremenljivko. + Target variable has no values.: Cijna spremenljivka nima vrednosti. + Target variable has only one value.: Ciljna spremenljivka ima samo eno vrednost. + Data has no features to learn from.: Podatki nimajo spremenljivk za učenje. + def `_which_missing_data`: + ' ': false + ' train ': ' učnih ' + ' test ': ' testnih ' + def `update_stats_model`: + {:.3f}: false + {} (error): {} (napaka) + {name} failed with error:\n: {name} je javil napako:\n + '{exc.__class__.__name__}: {exc!s}': false + \n: false + def `_scores_by_folds`: + weighted: false + def `_fill_table`: + {p0:.3f}
      {rope:.3f}: false + p({row_name} > {col_name}) = {p0:.3f}\n: false + p({row_name} = {col_name}) = {rope:.3f}: false + {p1:.3f}
      {rope:.3f}: false + p({col_name} > {row_name}) = {p1:.3f}\n: false + p({col_name} = {row_name}) = {rope:.3f}: false + {p0:.3f}: false + p({row_name} > {col_name}) = {p0:.3f}: false + {p1:.3f}: false + p({col_name} > {row_name}) = {p1:.3f}: false + def `_set_cells_na`: + NA: false + comparison cannot be computed: false + def `send_report`: + 'Stratified ': 'Stratificirano ' + Sampling type: Vrsta vzorčenja + {}{}-fold Cross validation: {}{}-kratno prečno preverjanje. + Leave one out: Izpusti enega + '{}Shuffle split, {} random samples with {}% data ': '{}vzorčenje, {} naključnih vzorcev z {}% podatkov ' + No sampling, test on training data: Ni vzorčenja, testiranje na učnih podatkih. + No sampling, test on testing data: Ni vzorčenja, testiranje na testnih podatkih. + Target class: Ciljni razred + (): false + Settings: Nastavitve + Scores: Rezultati + def `migrate_settings`: + resampling: false + context_settings: false + classes: false + score_table: false + def `__update`: + self.resampling %s: false + def `__submit`: + Running: V teku + def `__task_complete`: + Future[Results]: false + testing error (in __task_complete):: false + \n: false + def `results_add_by_model`: + def `is_empty`: + models: false + row_indices: false + probabilities: false + def `results_one_vs_rest`: + I({}=={}): false + False: false + True: false + __main__: false + iris: false +widgets/evaluate/utils.py: + def `check_results_adequacy`: + invalid_results: false + Categorical target variable is required.: Potrebna je kategorična spremenljivka. + Empty result on input. Nothing to display.: Prazni rezultati vrednotenja. + Results contain invalid values.: Rezultati vrednotenja vsebujejo neveljavne vrednosti + def `check_can_calibrate`: + each training data sample produces a different model: vsaka podmnožica učnih podatkov sestavi drug model + 'test results do not contain stored models - try testing ': 'rezultati vrednotenja ne vsebujejo modelov - poskusite testirati ' + on separate data or on training data: na ločenih podatkih ali na učni množici. + select a single model - the widget can output only one: izberite posamični model + cannot calibrate non-binary models: ne morem kalibrirati nebinarnih modelov + \n - {problem}: true + def `results_for_preview`: + heart_disease: false + l2: false + l1: false + LR l2: false + LR l1: false + SVM: false + Nu SVM: false + def `learner_name`: + name: false + def `usable_scorers`: + abstract: false + priority: false + def `scorer_caller`: + def `thunked`: + ignore: false + ((F-score|Precision)) is ill-defined.*: false + weighted: false + Model_: false + Train_: false + Test_: false + class `SelectableColumnsHeader`: + def `__init__`: + 'border: none; background-color: {col.name(QColor.NameFormat.HexRgb)}': false + class `ScoreTable`: + class `ItemDelegate`: + def `displayText`: + {value:.3f}: false + def `update_header`: + Model: true + Model_: false + Train: Učenje + Train time [s]: Čas učenja [s] + Train_: false + Test: Test + Test time [s]: Čas testiranja [s] + Test_: false + ' ({score.name})': false + def `migrate_to_show_scores_hints`: + show_score_hints: false + shown_scores: false +widgets/evaluate/tests/base.py: + class `EvaluateTest`: + def `setUp`: + y: false + a: false + b: false + datasets/lenses.tab: false + majority: false + knn-3: false + knn-1: false + def `test_many_evaluation_results`: + widget: false + iris: false + Evaluation Results: Rezultati vrednotenja +widgets/model/__init__.py: + Model: true + orange.widgets.model: false + Prediction: Napoved + '#FAC1D9': false + icons/Category-Model.svg: false +widgets/model/owadaboost.py: + class `OWAdaBoost`: + AdaBoost: true + 'An ensemble meta-algorithm that combines weak learners ': 'Skupinski algoritem učenja, ki združuje šibke algoritme ' + "and adapts to the 'hardness' of each training sample. ": in prilagaja 'težavnosti' učnih primerov. + icons/AdaBoost-symbolic.svg: false + Orange.widgets.classify.owadaboost.OWAdaBoostClassification: false + Orange.widgets.regression.owadaboostregression.OWAdaBoostRegression: false + adaboost, boost: adaboost, boost + class `Inputs`: + Learner: Učni algoritem + Linear: Linearna + Square: Kvadratna + Exponential: Eksponentna + class `Error`: + The base learner does not support weights.: Učni algoritem ne podpira uteži. + def `add_main_layout`: + Base estimator:: Osnovni model: + n_estimators: false + Number of estimators:: Število modelov: + learning_rate: false + Learning rate:: Hitrost učenja: + loss_index: false + Loss (regression):: Funkcija izgube (za regresijo) + Reproducibility: Ponovljivost + random_seed: false + Fixed seed for random generator:: Seme generatorja naključnih števil: + use_random_seed: false + def `set_base_learner`: + INVALID: NEVELJAVEN + def `get_learner_parameters`: + Base estimator: Osnovni model + Number of estimators: Število modelov + Loss (regression): Izguba (za regresijo) + __main__: false + iris: false +widgets/model/owcalibratedlearner.py: + class `OWCalibratedLearner`: + Calibrated Learner: Kalibriran učni algoritem + 'Wraps another learner with probability calibration and ': 'Ovije drug učni algoritem z modelom za kalibracijo verjetnosti ' + decision threshold optimization: in optimiranje klasifikacijskega praga. + icons/CalibratedLearner-symbolic.svg: false + calibrated learner, calibration, threshold: calibrated learner, calibration, threshold, kalibracija, prag + Sigmoid calibration: Sigmoidna kalibracija + Isotonic calibration: Izotonična kalibracija + No calibration: Brez kalibracije + Sigmoid: Sigmoidna + Isotonic: Izotonična + Optimize classification accuracy: Optimiraj napovedno točnost + Optimize F1 score: Optimiraj F1 + No threshold optimization: Brez optimizacije praga + CA: točnost + F1: F1 + class `Inputs`: + Base Learner: Učni algoritem + def `add_main_layout`: + calibration: false + Probability calibration: Kalibracija verjetnosti + threshold: false + Decision threshold optimization: Optimiranje napovednega praga + def `_set_default_name`: + ' + ': false + def `get_learner_parameters`: + Calibrate probabilities: Kalibracija verjetnosti + Threshold optimization: Optimiranje praga + __main__: false + heart_disease: false +widgets/model/owconstant.py: + class `OWConstant`: + Constant: Konstanta + 'Predict the most frequent class or mean value ': 'Vedno napove isti, večinski razred oz. povprečno vrednost ' + from the training set.: učnih primerov. + icons/Constant-symbolic.svg: false + Orange.widgets.classify.owmajority.OWMajority: false + Orange.widgets.regression.owmean.OWMean: false + constant, majority, mean: constant, majority, mean, večina, povprečje + __main__: false + iris: false +widgets/model/owcurvefit.py: + isclose: false + inf: false + nan: false + arccos: false + arccosh: false + arcsin: false + arcsinh: false + arctan: false + arctan2: false + arctanh: false + ceil: false + copysign: false + cos: false + cosh: false + degrees: false + e: false + exp: false + expm1: false + fabs: false + floor: false + fmod: false + gcd: false + hypot: false + isfinite: false + isinf: false + isnan: false + ldexp: false + log: false + log10: false + log1p: false + log2: false + pi: false + power: false + radians: false + remainder: false + sin: false + sinh: false + sqrt: false + tan: false + tanh: false + trunc: false + round: false + abs: false + any: false + all: false + class `Parameter`: + def `__repr__`: + 'Parameter(name={self.name}, initial={self.initial}, ': false + 'use_lower={self.use_lower}, lower={self.lower}, ': false + use_upper={self.use_upper}, upper={self.upper}): false + class `ParametersWidget`: + def `_setup_gui`: + Name: Ime + Initial value: Začetna vrednost + Lower bound: Spodnja meja + Upper bound: Zgornja meja + +: false + def `_add_row`: + p{row_id + 1}: true + ×: true + minimum: false + maximum: false + class `OWCurveFit`: + Curve Fit: Prileganje krivulje + Fit a function to data.: Prilagodi krivuljo podatkom. + icons/CurveFit-symbolic.svg: false + curve fit, function: curve fit, function, krivulja, prilagajanje + class `Outputs`: + Coefficients: Koeficienti + class `Warning`: + Duplicated parameter name.: Podvojeno ime parametra + "Unused parameter '{}' in ": Neuporabljen parameter '{}' + "'Parameters' declaration.": "" + Provide data on the input.: Priskrbite vhodne podatke. + class `Error`: + Invalid expression.: Nepravilen izraz. + Missing a fitting parameter.\n: Manjkajoč parameter. + Use 'Feature Constructor' widget instead.: Uporabite gradnik Sestavi značilke. + Unknown parameter '{}'.\n: Neznan parameter '{}'.\n + Declare the parameter in 'Parameters' box: Deklarirajte ga v škatli 'Parametri' + 'Some parameters and features have the same ': 'Nekateri parametri in spremenljivke imajo ' + name '{}'.: enaka imena '{}'. + Select Feature: Izberite spremenljivko + Select Parameter: Izberite parameter + Select Function: Izberite funkcijo + def `add_main_layout`: + Parameters: Parametri + Expression: Izrazi + expression: false + Expression...: Izraz... + _feature: false + _parameter: false + _function: false + def `__on_function_added`: + arctan2: false + copysign: false + fmod: false + gcd: false + hypot: false + isclose: false + ldexp: false + power: false + remainder: false + (,): false + (): false + def `get_learner_parameters`: + Expression: Izraz + def `check_data`: + Data has no continuous features.: Podatki nimajo numeričnih spremenljivk + def `__validate_expression`: + eval: false + __main__: false + housing: false +widgets/model/owgradientboosting.py: + class `LearnerItemModel`: + Extreme Gradient Boosting (xgboost): true + xgboost: false + Extreme Gradient Boosting Random Forest (xgboost): true + Gradient Boosting (catboost): true + catboost: false + def `_add_data`: + {name}: false + {lib} is not installed: {lib} ni nameščen + class `BaseEditor`: + def `_add_main_layout`: + callback: false + alignment: false + controlWidth: false + Basic Properties: Osnovne lastnosti + n_estimators: false + Number of trees:: Število dreves: + learning_rate: false + 'Learning rate: ': 'Hitrost učenja: ' + random_state: false + Replicable training: Ponovno učenje + Growth Control: Nadzor rasti + max_depth: false + 'Limit depth of individual trees: ': 'Omejitev globine posamičnih dreves: ' + Subsampling: Podvzorčenje + def `get_arguments`: + n_estimators: false + learning_rate: false + random_state: false + max_depth: false + def `get_learner_parameters`: + Method: Metoda + Number of trees: Število dreves + Learning rate: Hitrost učenja + Replicable training: Ponovno učenje + Yes: Da + No: Ne + Maximum tree depth: Največja globina drevesa + class `RegEditor`: + def `_add_main_layout`: + Regularization:: Regularizacija + lambda_index: false + def `_set_lambda_label`: + 'Lambda: {}': true + def `get_arguments`: + reg_lambda: false + def `get_learner_parameters`: + Regularization strength: Moč regularizacije + class `GBLearnerEditor`: + def `_add_main_layout`: + subsample: false + 'Fraction of training instances: ': 'Delež učnih primerov: ' + min_samples_split: false + 'Do not split subsets smaller than: ': 'Ne deli podmnožic manjših od: ' + def `get_arguments`: + subsample: false + min_samples_split: false + def `get_learner_parameters`: + Fraction of training instances: Delež učnih primerov + Stop splitting nodes with maximum instances: Ne deli vozlišč z manj primeri kot + class `CatGBLearnerEditor`: + def `_add_main_layout`: + colsample_bylevel: false + 'Fraction of features for each tree: ': 'Delež spremenljivk za vsako drevo: ' + def `get_arguments`: + colsample_bylevel: false + def `get_learner_parameters`: + Fraction of features for each tree: Delež spremenljivk za vsako drevo + class `XGBBaseEditor`: + def `_add_main_layout`: + callback: false + alignment: false + controlWidth: false + subsample: false + 'Fraction of training instances: ': 'Delež učnih primerov: ' + colsample_bytree: false + 'Fraction of features for each tree: ': 'Delež spremenljivk za posamezno drevo: ' + colsample_bylevel: false + 'Fraction of features for each level: ': 'Delež spremenljivk za posamezno stopnjo: ' + colsample_bynode: false + 'Fraction of features for each split: ': 'Delež spremenljivk za posamezno delitev: ' + def `get_arguments`: + subsample: false + colsample_bytree: false + colsample_bylevel: false + colsample_bynode: false + def `get_learner_parameters`: + Fraction of training instances: Delež učnih primerov + Fraction of features for each tree: Delež spremenljivk za vsako drevo + Fraction of features for each level: Delež spremenljivk za posamezno stopnjo + Fraction of features for each split: Delež spremenljivk za posamezno delitev + class `OWGradientBoosting`: + Gradient Boosting: true + Predict using gradient boosting on decision trees.: Napovedovanje z modelom Gradient Boosting na odločitvenih drevesih. + icons/GradientBoosting-symbolic.svg: false + gradient boosting, catboost, gradient, boost, tree, forest, xgb, gb, extreme: gradient boosting, catboost, gradient, boost, tree, forest, xgb, gb, extreme, drevo, gozd + def `add_main_layout`: + Method: Metoda + method_index: false + __main__: false + iris: false +widgets/model/owknn.py: + class `OWKNNLearner`: + kNN: k sosedov + Predict according to the nearest training instances.: Napoveduje glede na najbližje sosede iz učnih primerov. + icons/KNN-symbolic.svg: false + Orange.widgets.classify.owknn.OWKNNLearner: false + Orange.widgets.regression.owknnregression.OWKNNRegression: false + knn, k nearest, knearest, neighbor, neighbour: knn, k nearest, knearest, neighbor, neighbour, najbližji, sosedi + uniform: false + distance: false + euclidean: false + manhattan: false + chebyshev: false + mahalanobis: false + cosine: false + Uniform: Enake + By Distances: Po razdalji + Euclidean: Evklidska + Manhattan: Manhattanska + Chebyshev: Čebiševa + Mahalanobis: Mahalanobisova + Cosine: Kosinusna + def `add_main_layout`: + Neighbors: Sosedi + n_neighbors: false + Number of neighbors:: Število sosedov: + metric_index: false + Metric:: Metrika: + weight_index: false + Weight:: Uteži: + def `get_learner_parameters`: + Number of neighbours: Število sosedov + Metric: Metrika + Weight: Uteži + __main__: false + iris: false +widgets/model/owlinearregression.py: + class `OWLinearRegression`: + Linear Regression: Linearna regresija + 'A linear regression algorithm with optional L1 (LASSO), ': 'Linearna regresija z ' + L2 (ridge) or L1L2 (elastic net) regularization.: različnimi vrstami regularizacije. + icons/LinearRegression-symbolic.svg: false + Orange.widgets.regression.owlinearregression.OWLinearRegression: false + linear regression, ridge, lasso, elastic net: linear regression, ridge, lasso, elastic net, regresija, regularizacija + class `Outputs`: + Coefficients: Koeficienti + No regularization: Brez regularizacije + Ridge regression (L2): Ridge (L2) + Lasso regression (L1): Lasso (L1) + Elastic net regression: Elastična mreža + def `add_main_layout`: + Parameters: Parametri + fit_intercept: false + Fit intercept (unchecking it fixes it to zero): Uporabi prosti člen (sicer bo enak 0) + Regularization: Regularizacija + reg_type: false + Regularization strength:: Moč regularizacije: + alpha_index: false + Elastic net mixing:: Mešanica elastične mreže + L1: true + l2_ratio: false + L2: true + def `_set_alpha_label`: + 'Alpha: {}': α = {} + def `_set_l2_ratio_label`: + '{:.{}f} : {:.{}f}': false + def `update_model`: + coef: false + name: false + intercept: false + coefficients: koeficienti + def `get_learner_parameters`: + No Regularization: Brez regularizacije + Ridge Regression (L2) with α={}: Ridge (L2) z α={} + Lasso Regression (L1) with α={}: Lasso (L1) z α={} + Elastic Net Regression with α={}: Elastična mreža z α={} + ' and L1:L2 ratio of {}:{}': ' in L1:L2 v razmerju {}:{}' + Regularization: Regularizacija + Fit intercept: Prosti člen + No: Ne + Yes: Da + __main__: false + housing: false +widgets/model/owloadmodel.py: + class `OWLoadModel`: + Load Model: Naloži model + Load a model from an input file.: Preberi model iz datoteke. + Orange.widgets.classify.owloadclassifier.OWLoadClassifier: false + icons/LoadModel-symbolic.svg: false + load model, file, open, model: load model, file, open, model, datoteka, odpri + class `Outputs`: + Model: true + class `Error`: + An error occured while reading '{}': Napaka pri branju '{}' + ;;: false + def `__init__`: + File: Datoteka + ...: true + Reload: Ponovno naloži + def `browse_file`: + Open Model File: Odpri datoteko z modelom + def `open_file`: + rb: false + class `OWLoadModelDropHandler`: + def `canDropFile`: + .pkcls: false + def `parametersFromFile`: + recent_paths: false + __main__: false +widgets/model/owlogisticregression.py: + class `OWLogisticRegression`: + Logistic Regression: Logistična regresija + 'The logistic regression classification algorithm with ': Logistična regresija z regularizacijo. + LASSO (L1) or ridge (L2) regularization.: "" + icons/LogisticRegression-symbolic.svg: false + Orange.widgets.classify.owlogisticregression.OWLogisticRegression: false + logistic regression: logistic regression, regresija, regularizacija + class `Outputs`: + Coefficients: Koeficienti + Lasso (L1): true + Ridge (L2): true + None: Brez + l1: false + l2: false + class `Warning`: + Weighting by class may decrease performance.: Uteževanje glede na razred lahko upočasni učenje. + def `add_main_layout`: + penalty_type: false + 'Regularization type: ': 'Vrsta regularizacije: ' + Strength:: Moč: + Weak: Šibka + margin-top:6px: false + C_index: false + Strong: Močna + class_weight: false + Balance class distribution: Uravnoteži razrede + Weigh classes inversely proportional to their frequencies.: Uteži razrede obratno sorazmerno njihovi pogostosti + def `set_c`: + C={}: true + C={:.3f}: true + N/A: NN + def `create_learner`: + balanced: false + def `get_learner_parameters`: + Regularization: Regularizacija + '{}, C={}, class weights: {}': {}, C={}, uteži razredov: {} + def `create_coef_table`: + name: ime + intercept: odsek + coefficients: koeficienti + __main__: false + zoo: false +widgets/model/ownaivebayes.py: + class `OWNaiveBayes`: + Naive Bayes: Naivni Bayes + 'A fast and simple probabilistic classifier based on ': 'Preprost model, ki temelji na Bayesovi formuli ' + Bayes' theorem with the assumption of feature independence.: in predpostavlja pogojno neodvisnost spremenljivk. + icons/NaiveBayes-symbolic.svg: false + Orange.widgets.classify.ownaivebayes.OWNaiveBayes: false + naive bayes: naive bayes, bayes, pogojna neodvisnost + __main__: false + iris: false +widgets/model/owneuralnetwork.py: + class `Task`: + def `setFuture`: + future is already set: false + class `OWNNLearner`: + Neural Network: Nevronska mreža + 'A multi-layer perceptron (MLP) algorithm with ': Večnivojski perceptron z vzvratnim širjenjem + backpropagation.: ' (backpropagation).' + icons/NN-symbolic.svg: false + neural network, mlp: neural network, mlp, večnivojskost, mreža + identity: false + logistic: false + tanh: false + relu: false + Identity: Identiteta + Logistic: Logistična + ReLu: ReLu + lbfgs: false + sgd: false + adam: false + L-BFGS-B: true + SGD: true + Adam: true + 100,: false + class `Warning`: + 'ANN without hidden layers is equivalent to logistic ': 'Nevronska mreža brez skritih nivojev je enaka logistični ' + 'regression with worse fitting.\nWe recommend using ': 'regresiji s slabšim algoritmom prileganja.\nPriporočamo uporabo ' + logistic regression.: logistične regresije. + def `add_main_layout`: + Neurons in hidden layers:: Nevroni v skritih nivojih + hidden_layers_input: false + 'A list of integers defining neurons. Length of list ': 'Seznam števil, ki določijo število nevronov. Dolžina seznama ' + defines the number of layers. E.g. 4, 2, 2, 3.: določi število nivojev. Npr. 4, 2, 2, 3 + e.g. 10,: npr. 10, + Activation:: Aktivacija: + activation_index: false + Solver:: Algoritem reševanja: + solver_index: false + alpha_index: false + Maximal number of iterations:: Omejitev števila ponovitev: + max_iterations: false + Max iterations:: Omejitev ponovitev: + replicable: false + Replicable training: Ponovljivo učenje + def `set_alpha`: + Regularization, α={}:: Regularizacija, α={}: + def `setup_layout`: + Cancel: Prekini + def `get_learner_parameters`: + Hidden layers: Skriti nivoji + ', ': false + Activation: Aktivacija + Solver: Algoritem reševanja + Alpha: Alfa + Max iterations: Omejitev števila ponovitev + Replicable training: Ponovljivo učenje + def `get_hidden_layers`: + \d+: false + def `__update`: + max_iter: false + def `migrate_settings`: + alpha: false + alpha_index: false + __main__: false + iris: false +widgets/model/owpls.py: + class `OWPLS`: + PLS: true + Partial Least Squares Regression widget for multivariate data analysis: PLS regresija za multivariatno analizo podatkov + icons/PLS-symbolic.svg: false + partial least squares: partial least squares, pls, regresija + class `Outputs`: + Coefficients and Loadings: Koeficienti in uteži + Data with Scores: Podatki z vrednostmi + Components: Komponente + class `Warning`: + 'Sparse input data: default preprocessing is to scale it.': Redki vhodni podatki: privzeta predobdelava je skaliranje. + def `add_main_layout`: + Optimization Parameters: Optimizacijski parametri + n_components: false + 'Components: ': 'Komponente: ' + max_iter: false + 'Iteration limit: ': 'Omejitev iteracij: ' + scale: false + Scale features and target: Skaliraj spremenljivke in ciljno spremenljivko + def `_create_output_coeffs_loadings`: + coef ({v.name}): koef ({v.name}) + coef * X_sd ({v.name}): koef × X_sd ({v.name}) + w*c {i + 1}: true + Variable name: Ime spremenljivke + Variable role: Vloga spremenljivke + Feature: Značilka + Target: Cilj + intercept: odsek + Coefficients and Loadings: Koeficienti in uteži + def `set_data`: + Data has no target variable.\n: Podatki nimajo ciljne spremenljivke.\n + Select one with the Select Columns widget.: Izberite jo z gradnikom Izberi stolpce. + def `create_learner`: + preprocessors: false + __main__: false + housing: false +widgets/model/owrandomforest.py: + class `OWRandomForest`: + Random Forest: Naključni gozd + Predict using an ensemble of decision trees.: Skupinsko napovedovanje z drevesi. + icons/RandomForest-symbolic.svg: false + Orange.widgets.classify.owrandomforest.OWRandomForest: false + Orange.widgets.regression.owrandomforestregression.OWRandomForestRegression: false + random forest: random forest, drevesa + class `Error`: + Insufficient number of attributes ({}): Nezadostno število spremenljivk ({}) + class `Warning`: + Weighting by class may decrease performance.: Uteževanje razredov lahko upočasni izvajanje. + def `add_main_layout`: + Basic Properties: Osnovne lastnosti + n_estimators: false + 'Number of trees: ': 'Število dreves: ' + max_features: false + 'Number of attributes considered at each split: ': 'Število obravnavanih spremenljivk ob vsaki delitvi: ' + use_max_features: false + use_random_state: false + Replicable training: Ponovljivo učenje + class_weight: false + Balance class distribution: Uravnoteži razrede + Weigh classes inversely proportional to their frequencies.: Uteži razrede obratno sorazmerno njihovi pogostosti. + Growth Control: Nadzor rasti + max_depth: false + 'Limit depth of individual trees: ': 'Omejitev globine posamičnih dreves: ' + use_max_depth: false + min_samples_split: false + 'Do not split subsets smaller than: ': 'Ne deli množic z manj primeri kot: ' + use_min_samples_split: false + def `create_learner`: + n_estimators: false + max_features: false + random_state: false + max_depth: false + min_samples_split: false + class_weight: false + balanced: false + def `get_learner_parameters`: + Number of trees: Število dreves + Maximal number of considered features: Število obravnavanih spremenljivk + unlimited: neomejeno + Replicable training: Ponovljivo učenje + No: Ne + Yes: Da + Maximal tree depth: Največja globina dreves + Stop splitting nodes with maximum instances: Ne deli množic z manj primeri kot + Class weights: Uteževanje razredov + __main__: false + iris: false +widgets/model/owrules.py: + class `CustomRuleClassifier`: + def `__init__`: + Rule ordering: Urejenost pravil + Covering algorithm: Način prekrivanja + def `predict`: + ordered: urejena + exclusive: eno pravilo + unordered: neurejena + weighted: utežena + class `CustomRuleLearner`: + Custom rule inducer: false + def `__init__`: + Rule ordering: Urejenost pravil + Covering algorithm: Način prekrivanja + exclusive: eno pravilo + weighted: utežena + Gamma: Gama + Beam width: Širina snopa + Restrict to equality: Omejitev na enakost + Evaluation measure: Ocena kvalitete pravila + entropy: entropija + laplace: Laplacova natančnost + wracc: WRACC + Minimum rule coverage: Najmanjše število pokritih primerov + Maximum rule length: Največja dolžina pravila + Default alpha: Zahtevana statistična značilnost + Parent alpha: Značilnost prednika + def `fit_storage`: + ordered: urejena + weighted: utežena + unordered: neurejena + class `OWRuleLearner`: + CN2 Rule Induction: Odločitvena pravila + Induce rules from data using CN2 algorithm.: Sestavi odločitvena pravila z algoritmom CN2. + icons/CN2RuleInduction-symbolic.svg: false + Orange.widgets.classify.owrules.OWRuleLearner: false + cn2 rule induction: cn2 rule induction + ordered: urejena + unordered: neurejena + exclusive: eno pravilo + weighted: utežena + entropy: entropija + laplace: Laplacova natančnost + wracc: WRACC + def `add_main_layout`: + Rule ordering: Urejenost pravil + rule_ordering: false + Ordered: Urejena + Unordered: Neurejena + Covering algorithm: Način prekrivanja + covering_algorithm: false + Exclusive: Z enim pravilom + Weighted: Uteženo + gamma: false + γ:: true + Rule search: Iskanje pravil + evaluation_measure: false + Evaluation measure:: Mera kvalitete: + Entropy: Entropija + Laplace accuracy: Laplacova natančnost + WRAcc: WRACC + beam_width: false + Beam width:: Širina snopa: + Rule filtering: Presejanje pravil + min_covered_examples: false + Minimum rule coverage:: Najmanjše število pokritih primerov: + max_rule_length: false + Maximum rule length:: Najdaljša dolžina pravila: + default_alpha: false + Statistical significance (default α):: Statistična značilnost (privzeti α): + checked_default_alpha: false + parent_alpha: false + Relative significance (parent α):: Relativna značilnost (predniko α): + checked_parent_alpha: false + restrict_equality: false + Restrict operator for categorical values to equality: Omeji operator za kategorične vrednosti na enakost + def `get_learner_parameters`: + Rule ordering: Urejenost pravil + Covering algorithm: Način prekrivanja + Gamma: Gama + Evaluation measure: Ocena kvalitete pravila + Restrict to equality: Omejitev na enakost + Beam width: Širina snopa + Minimum rule coverage: Najmanjše število pokritih primerov + Maximum rule length: Največja dolžina pravila + Default alpha: Zahtevana statistična značilnost + Parent alpha: Značilnost prednika + __main__: false + iris: false +widgets/model/owsavemodel.py: + class `OWSaveModel`: + Save Model: Shrani model + Save a trained model to an output file.: Shrani model v datoteko. + icons/SaveModel-symbolic.svg: false + Orange.widgets.classify.owsaveclassifier.OWSaveClassifier: false + save model, save: save model, save + class `Inputs`: + Model: true + Pickled model (*.pkcls): Pickle z modelom (*.pickle) + def `do_save`: + wb: false + __main__: false +widgets/model/owscoringsheet.py: + class `ScoringSheetRunner`: + def `run`: + Learning...: Učenje... + class `OWScoringSheet`: + Scoring Sheet: Točkovalnik + A fast and explainable classifier.: Hitra in razložljiva klasifikacija. + icons/ScoringSheet-symbolic.svg: false + orangecontrib.prototypes.widgets.owscoringsheet.OWScoringSheet: false + scoring sheet: true + class `Information`: + If the number of input features used is too low for the number of decision \n: Če je število uporabljenih vhodnih značilk premajhno za število odločitvenih \n + parameters, the number of decision parameters will be adjusted to fit the model.: parametrov, bo število odločitvenih parametrov prilagojeno modelu. + def `add_main_layout`: + Preprocessing: Predobdelava + num_attr_after_selection: false + Number of Attributes After Feature Selection:: Število spremenljivk po izboru značilk: + Model Parameters: Parametri modela + num_decision_params: false + Maximum Number of Decision Parameters:: Največje število odločitvenih parametrov: + max_points_per_param: false + Maximum Points per Decision Parameter:: Največje število točk na odločitveni parameter: + custom_features_checkbox: false + Custom number of input features: Ročno določeno število značilk + num_input_features: false + Number of Input Features Used:: Število uporabljenih vhodnih značilk: + __main__: false +widgets/model/owsgd.py: + class `OWSGD`: + Stochastic Gradient Descent: Stohastični gradientni spust + 'Minimize an objective function using a stochastic ': 'Minimiziranje ciljne funkcije z uporabo stohastičnega ' + approximation of gradient descent.: približka gradientnega spusta. + icons/SGD-symbolic.svg: false + Orange.widgets.regression.owsgdregression.OWSGDRegression: false + stochastic gradient descent, sgd: stochastic gradient descent, sgd + class `Outputs`: + Coefficients: Koeficienti + Squared Loss: Kvadratna izguba + squared_error: false + Huber: Huberjeva + huber: false + ε insensitive: ε-neobčutljiva + epsilon_insensitive: false + Squared ε insensitive: Kvadratna ε-neobčutljiva + squared_epsilon_insensitive: false + Hinge: Rob (Hinge) + hinge: false + Logistic regression: Logistična regresija + log_loss: false + Modified Huber: Modificirana Huberjeva + modified_huber: false + Squared Hinge: Kvadrat roba (Squared Hinge) + squared_hinge: false + Perceptron: true + perceptron: false + None: Brez + Lasso (L1): true + l1: false + Ridge (L2): true + l2: false + Elastic Net: Elastična mreža + elasticnet: false + Constant: Konstanta + constant: false + Optimal: Optimalno + optimal: false + Inverse scaling: Inverzno skaliranje + invscaling: false + def `_add_algorithm_to_layout`: + Loss functions: Funkcije izgube + cls_loss_function_index: false + cls_epsilon: false + 'ε: ': false + 'Classification: ': 'Klasifikacija: ' + reg_loss_function_index: false + reg_epsilon: false + 'Regression: ': 'Regresija: ' + def `_add_regularization_to_layout`: + Regularization: Regularizacija + penalty_index: false + l1_ratio: false + 'Mixing: ': 'Mešanje: ' + alpha: false + 'Strength (α): ': 'Moč (α): ' + def `_add_learning_params_to_layout`: + Optimization: Optimizacija + learning_rate_index: false + 'Learning rate: ': 'Hitrost učenja: ' + eta0: false + 'Initial learning rate (η0): ': 'Začetna hitrost učenja (η0): ' + power_t: false + 'Inverse scaling exponent (t): ': 'Inverzni eksponent skaliranja (t): ' + max_iter: false + 'Number of iterations: ': 'Število iteracij: ' + tol: false + 'Tolerance (stopping criterion): ': 'Toleranca (zaustavitveni kriterij): ' + tol_enabled: false + shuffle: false + Shuffle data after each iteration: Premešaj podatke po vsaki iteraciji + random_state: false + 'Fixed seed for random shuffling: ': 'Določeno seme za naključno mešanje: ' + use_random_state: false + def `_on_cls_loss_change`: + huber: false + epsilon_insensitive: false + squared_epsilon_insensitive: false + def `_on_reg_loss_change`: + huber: false + epsilon_insensitive: false + squared_epsilon_insensitive: false + def `_on_regularization_change`: + l1: false + l2: false + elasticnet: false + def `_on_learning_rate_change`: + constant: false + invscaling: false + def `create_learner`: + random_state: false + def `get_learner_parameters`: + Classification loss function: Klasifikacijska funkcija izgube + huber: false + epsilon_insensitive: false + squared_epsilon_insensitive: false + Epsilon (ε) for classification: Epsilon (ε) za klasifikacijo + Regression loss function: Regresijska funkcija izgube + Epsilon (ε) for regression: Epsilon (ε) za regresijo + Regularization: Regularizacija + l1: false + l2: false + elasticnet: false + Regularization strength (α): Moč regularizacije (α) + Elastic Net mixing parameter (L1 ratio): Parameter mešanja pri elastični mreži (delež L1) + Learning rate: Hitrost učenja + constant: false + invscaling: false + Initial learning rate (η0): Začetna hitrost učenja (η0) + Inverse scaling exponent (t): Inverzni eksponent skaliranja (t) + Shuffle data after each iteration: Premešaj podatke po vsaki iteraciji + Random seed for shuffling: Naključno seme za mešanje + def `update_model`: + coef: false + name: false + intercept: false + coefficients: koeficienti + def `migrate_settings`: + max_iter: false + n_iter: false + tol_enabled: false + __main__: false + iris: false +widgets/model/owstack.py: + class `OWStackedLearner`: + Stacking: Sklad modelov + Stack multiple models.: Sestavi več modelov v model. + icons/Stacking-symbolic.svg: false + stacking, ensemble: stacking, ensemble, sklad + Stack: Sklad + class `Inputs`: + Learners: Modeli + Aggregate: Združevalnik + def `create_learner`: + preprocessors: false + aggregate: false + def `get_learner_parameters`: + Base learners: Osnovni modeli + Aggregator: Združevalnik + default: privzeti + __main__: false + iris: false +widgets/model/owsvm.py: + class `OWSVM`: + SVM: true + 'Support Vector Machines map inputs to higher-dimensional ': Metoda podpornih vektorjev. + feature spaces.: "" + icons/SVM-symbolic.svg: false + Orange.widgets.classify.owsvmclassification.OWSVMClassification: false + Orange.widgets.regression.owsvmregression.OWSVMRegression: false + svm, support vector machines: svm, support vector machines, podporni vektorji + class `Outputs`: + Support Vectors: Podporni vektorji + Support vectors: Podporni vektorji + class `Warning`: + Input data is sparse, default preprocessing is to scale it.: Podatki so redki; privzeti predprocesor jih skalira. + auto: true + Linear: Linearno + x⋅y: true + Polynomial: Polinomsko + (g x⋅y + c)d: true + RBF: true + exp(-g|x-y|²): true + Sigmoid: Sigmoidno + tanh(g x⋅y + c): true + def `_add_type_box`: + svm_type: false + SVM Type: Vrsta SVM + SVM: true + C: false + epsilon: false + Cost (C):: Cena (C): + Regression loss epsilon (ε):: Koeficient izgube pri regresiji (ε): + ν-SVM: true + nu_C: false + nu: false + Regression cost (C):: Cena (pri regresiji) (C): + Complexity bound (ν):: Meja kompleksnosti (ν): + def `_add_kernel_box`: + Kernel: Jedro + kernel_type: false + 'Kernel: %(kernel_eq)s': Jedro: %(kernel_eq)s + gamma: false + ' g: ': true + coef0: false + ' c: ': true + degree: false + ' d: ': true + def `_add_optimization_box`: + Optimization Parameters: Parametri optimizacije + tol: false + 'Numerical tolerance: ': 'Numerična toleranca: ' + max_iter: false + 'Iteration limit: ': 'Največje število ponovitev: ' + limit_iter: false + def `create_learner`: + linear: false + poly: false + rbf: false + sigmoid: false + kernel: false + degree: false + gamma: false + coef0: false + probability: false + tol: false + max_iter: false + preprocessors: false + def `get_learner_parameters`: + SVM type: Vrsta SVM + SVM, C={}, ε={}: true + ν-SVM, ν={}, C={}: true + Numerical tolerance: Numerična toleranca + {:.6}: false + Iteration limt: Število ponovitev: + unlimited: neomejeno + def `_report_kernel_parameters`: + Kernel: Jedro + Linear: Linearno + Polynomial, ({g:.4} x⋅y + {c:.4}){d}: Polinomsko, ({g:.4} x⋅y + {c:.4}){d} + RBF, exp(-{:.4}|x-y|²): true + Sigmoid, tanh({g:.4} x⋅y + {c:.4}): Sigmoidno, tanh({g:.4} x⋅y + {c:.4}) + def `migrate_settings`: + degree: false + __main__: false + iris: false +widgets/model/owtree.py: + class `OWTreeLearner`: + Tree: Drevo + A tree algorithm with forward pruning.: Gradnja drevesa z rezanjem. + icons/Tree-symbolic.svg: false + Orange.widgets.classify.owclassificationtree.OWClassificationTree: false + Orange.widgets.regression.owregressiontree.OWRegressionTree: false + Orange.widgets.classify.owclassificationtree.OWTreeLearner: false + Orange.widgets.regression.owregressiontree.OWTreeLearner: false + tree, classification tree: tree, classification tree, klasifikacijsko drevo + 'Min. number of instances in leaves: ': 'Min. število primerov v listu: ' + limit_min_leaf: false + min_leaf: false + 'Do not split subsets smaller than: ': 'Ne deli podmnožic manjših od: ' + limit_min_internal: false + min_internal: false + 'Limit the maximal tree depth to: ': 'Omeji globino drevesa na: ' + limit_depth: false + max_depth: false + 'Stop when majority reaches [%]: ': 'Ustavi delitev, ko delež večinskega razreda doseže [%]: ' + limit_majority: false + sufficient_majority: false + def `add_main_layout`: + Parameters: Parametri + binary_trees: false + Induce binary tree: Sestavi binarno drevo + def `get_learner_parameters`: + Pruning: Rezanje + ', ': true + 'at least {self.min_leaf} ': 'vsaj {self.min_leaf} ' + {pl(self.min_leaf, "instance")} in leaves: {plsi(self.min_leaf, "primer")} v listih + 'at least {self.min_internal} ': 'vsaj {self.min_internal} ' + {pl(self.min_internal, "instance")} in internal nodes: {plsi(self.min_internal, "primer")} v notranjih vozliščih + maximum depth {self.max_depth}: dovoljena globina je {self.max_depth} + None: Brez + Splitting: Delitev + 'Stop splitting when majority reaches %d%% ': Ustavi, ko delež večinskega razreda doseže %d%% + (classification only): (samo za klasifikacijo) + Binary trees: Dvojiško drevo + No: Ne + Yes: Da + __main__: false + iris: false +widgets/obsolete/owtable.py: + TableSlot: false + input_id: false + table: false + summary: false + view: false + class `OWDataTable`: + Orange Obsolete: Zastarelo + Orange.widgets.data.owtable.OWDataTable: false + Data Table: Tabela + View the dataset in a spreadsheet.: Pregled podatkov v tabeli. + ../data/icons/Table.svg: false + _keywords: false + class `Inputs`: + Data: Podatki + class `Outputs`: + Selected Data: Izbrani podatki + class `Warning`: + Multiple Data inputs are deprecated.\n: Uporaba več podatkovnih vnosov je zastarela.\n + This functionality will be removed soon.\n: Ta funkcija bo kmalu odstranjena.\n + Use multiple Tables instead.: Namesto tega uporabite več tabel. + def `__init__`: + Info: true + Variables: Spremenljivke + show_attribute_labels: false + Show variable labels (if present): Pokaži oznake spremenljivk + show_distributions: false + Visualize numeric values: Vizualiziraj številske vrednosti + color_by_class: false + Color by instance classes: Obarvaj primere glede na razred + Selection: Izbor + select_rows: false + Select full rows: Izbiraj cele vrstice + Restore Original Order: Izvirni vrstni red + Show rows in the original order: Pokaži vrstice v izvirnem vrstnem redu + auto_commit: false + def `set_dataset`: + name: false + Data: Podatki + def `insert_dataset`: + name: false + Data: Podatki + def `handleNewSignals`: + def `update`: + _update_info: false + def `_set_input_summary`: + No data.: Ni podatkov. + \n: false + __main__: false + iris: false + brown-selected: false + housing: false +widgets/report/owreport.py: + OWReport: false + HAVE_REPORT: false + __main__: false + iris: false +widgets/report/report.py: + DataReport: false + describe_data: false + describe_data_brief: false + describe_domain: false + describe_domain_brief: false + def `describe_domain`: + def `clip_attrs`: + ' (total: {nitems} {desc})': ' (skupno: {nitems} {desc})' + Features: Spremenljivke + features: {plsi(len(domain.attributes), "spremenljivka")} + Meta attributes: Meta atributi + meta attributes: meta {plsi(len(domain.metas), "spremenljivka")} + Target: Ciljna spremenljivka + target variables: ciljne spremenljivke + def `describe_data`: + Data instances: Število primerov + def `describe_domain_brief`: + Features: Spremenljivke + None: brez + Meta attributes: Meta atributi + Target: Ciljna spremenljivka + Class '{}': Razred '{}' + Numeric variable '{}': Numerična ciljna spremenljivka '{}' + Targets: Ciljne spremenljivke + def `describe_data_brief`: + Data instances: Število primerov +widgets/report/tests/__init__.py: + def `suite`: + test*.py: false + __main__: false + suite: false +widgets/tests/__init__.py: + def `load_tests`: + _in_load_tests: false + test*.py: false +widgets/tests/base.py: + class `WidgetTest`: + data with just nans: false + abc: false + y: false + m: false + data without rows: false + data with just attributes: false + no data (after having attributes): false + data with just class: false + data with just continouos outcome: false + no data (after having class): false + data with just metas: false + x: false + with without attributes, class or metas: false + no data (after seeing a ghost): false + def `__init_subclass__`: + test_zero_size_data: false + def `test_zero_size_data`: + widget: false + not tested because .widget is not set: false + inputs: false + def `test_has_keywords`: + widget: false + _final_class: false + name: false + \nFile "{file}", line {inspect.getsourcelines(widget_class)[1]}.: false + keywords: false + Widget {widget_class.__name__} must define a 'keywords': false + class attribute.\n: false + If none are needed, set keywords='_keywords'.: false + utf-8: false + ^\s+keywords\s*=\s*\[: false + "'keywords' class attribute must be a comma-separated string, ": false + not a list.: false + Orange.: false + class `BaseParameterMapping`: + def `__init__`: + both: false + def `__str__`: + both: false + %s (%s): false + class `ParameterMapping`: + def `_default_values`: + {} is not supported: false + def `_default_get_value`: + {} is not supported: false + def `_default_set_value`: + {} is not supported: false + class `WidgetLearnerTestMixin`: + def `init`: + testing_dataset_cls: false + testing_dataset_reg: false + Model: false + Classifier: false + Predictor: false + def `test_has_unconditional_apply`: + unconditional_apply: false + def `test_input_preprocessor`: + Preprocessor not added to widget preprocessors: false + Preprocessors were not passed to the learner: false + def `test_input_preprocessors`: + `PreprocessorList` was not added to preprocessors: false + def `test_input_preprocessor_disconnect`: + Preprocessors not removed on disconnect.: false + def `test_output_learner`: + Does not initialize the learner output: false + Does not send a new learner instance on `Apply`.: false + def `test_output_learner_name`: + Learner Name: false + def `test_output_model_name`: + Model Name: false + def `_get_param_value`: + both: false + def `test_parameters`: + Mismatching setting for parameter '%s': false + def `test_params_trigger_settings_changed`: + apply(%s): false + def `_should_check_parameter`: + classification: false + regression: false + both: false + class `WidgetOutputsTestMixin`: + def `init`: + iris: false + def `_select_data`: + Subclasses should implement select_data: false + class `ProjectionWidgetTestMixin`: + def `init`: + iris: false + def `test_setup_graph`: + Did not finish in the specified {timeout}ms timeout: false + def `test_attr_label_metas`: + zoo: false + def `test_plot_once`: + heart_disease: false + def `test_subset_data_color`: + Did not finish in the specified {timeout}ms timeout: false + '#46befa': false + brush: false + '#000000': false + def `test_dragging_tooltip`: + heart_disease: false + def `test_sparse_data`: + iris: false + def `test_saved_selection`: + Did not finish in the specified {timeout}ms timeout: false + def `test_hidden_effective_variables`: + c1: false + hidden: false + c2: false + cls: false + a: false + b: false + def `test_visual_settings`: + Helvetica: false + Fonts: false + Font family: false + Title: false + Font size: false + Italic: false + Label: false + Categorical legend: false + Numerical legend: false + Annotations: false + Foo: false + class `AnchorProjectionWidgetTestMixin`: + def `test_embedding_missing_values`: + heart_disease: false + def `test_sparse_data`: + iris: false + def `test_visual_settings`: + Helvetica: false + Fonts: false + Anchor: false + Font size: false + Italic: false + class `datasets`: + def `path`: + datasets: false + def `missing_data_1`: + missing_data_1.tab: false + def `missing_data_2`: + missing_data_2.tab: false + def `missing_data_3`: + missing_data_3.tab: false + def `data_one_column_vals`: + a: false + b: false + c: false + y: false + n: false + ynyn: false + def `datasets`: + testing_dataset_cls: false + testing_dataset_reg: false + def `open_widget_classes`: + __init_subclass__: false +widgets/tests/utils.py: + def `possible_duplicate_table`: + iris: false +widgets/unsupervised/__init__.py: + Unsupervised: Nenadzorovano učenje + orange.widgets.unsupervised: false + Unsupervised learning.: Nenadzorovano učenje + '#CAE1EF': true + icons/Category-Unsupervised.svg: false +widgets/unsupervised/owcorrespondence.py: + class `OWCorrespondenceAnalysis`: + Correspondence Analysis: Korespondenčna analiza + Correspondence analysis for categorical multivariate data.: Korespondenčna analiza kategoričnih multivariatnih podatkov. + icons/CorrespondenceAnalysis-symbolic.svg: false + correspondence analysis: correspondence analysis + class `Inputs`: + Data: Podatki + class `Outputs`: + Coordinates: Koordinate + plot.plotItem: false + class `Error`: + Empty dataset: Prazna tabela podatkov + No categorical data: Ni kategoričnih podatkov + def `__init__`: + Variables: Spremenljivke + Axes: Osi + component_x: false + X:: true + component_y: false + Y:: true + Contribution to Inertia: Prispevek k vztrajnosti + \n: true + auto_commit: false + def `set_data`: + ignore: false + combo box 'component_[xy]' .*: false + def `commit`: + Component {i + 1}: Komponenta {i + 1} + Variable: Spremenljivka + Value: Vrednost + def `_update_CA`: + ignore: false + combo box 'component_[xy]' .*: false + def `update_XY`: + {}: false + def `_setup_plot`: + def `get_minmax`: + inf: false + -inf: false + bottom: false + Component {} ({:.1f}%): Komponenta {} ({:.1f}%) + left: false + def `_update_info`: + \n\n: false + 'Axis 1: {:.2f}\n': Os 1: {:.2f}\n + 'Axis 2: {:.2f}': Os 2: {:.2f} + def `send_report`: + Data instances: Vhodni primeri + Selected variable: Izbrana spremenljivka + Selected variables: Izbrane spremenljivke + {} and {}: {} in {} + ', ': false + def `correspondence`: + ignore: false + CA: false + U: false + D: false + V: false + row_factors: false + col_factors: false + row_sums: false + column_sums: false + __main__: false + titanic: false +widgets/unsupervised/owdbscan.py: + class `OWDBSCAN`: + DBSCAN: true + Density-based spatial clustering.: Gručenje na podlagi prostorske gostote. + icons/DBSCAN-symbolic.svg: false + density based clustering, clustering: density based clustering, clustering, gostota, gručenje + class `Inputs`: + Data: Podatki + class `Error`: + 'Not enough unique data instances. ': 'Ni dovolj primerov. ' + At least two rows (with any defined values) are required.: Potrebna sta vsaj dva, ki imata vsaj eno nemanjkajočo vrednost. + The data does not contain any features.: Podatki ne vsebujejo nobenih spremenljivk. + Euclidean: Evklidska + euclidean: false + Manhattan: Manhattanska + cityblock: false + Cosine: Kosinusna + cosine: false + def `__init__`: + Parameters: Parametri + min_samples: false + Core point neighbors: Število sosedov + eps: false + Neighborhood distance: Velikost soseščine + Distance Metric: Mera razdalje + metric_idx: false + normalize: false + Normalize features: Normiraj spremenljivke + auto_commit: false + Data items sorted by score: Primeri iz podatkov, urejeni po oceni + Distance to the k-th nearest neighbour: Razdalja do k-tega najbližjega soseda + def `_plot_graph`: + red: false + def `send_data`: + Cluster: Gruča + C%d: G%d + DBSCAN Core: Jedro DBSCAN-a + 0: false + 1: false + __main__: false + iris.tab: false +widgets/unsupervised/owdistancefile.py: + class `OWDistanceFile`: + Distance File: Branje razdalj + orange.widgets.unsupervised.distancefile: false + Read distances from a file.: Prebere matriko razdalj iz datoteke. + icons/DistanceFile-symbolic.svg: false + distance file, load, read, open: distance file, load, read, open, naloži, preberi, odpri + class `Outputs`: + Distances: Razdalje + class `Error`: + Data was not loaded:{}: Branje ni uspelo:{} + 'Matrix is not square. ': 'Matrika ni pravokotna. ' + Reformat the file and use the File widget to read it.: Preoblikujte datoteko in jo preberite z gradnikom Datoteka. + def `__init__`: + Distance File: Datoteka z razdaljami + ...: true + Reload: Ponovno naloži + Options: Opcije + auto_symmetric: false + Treat triangular matrices as symmetric: Obravnavaj trikotne matrike kot simetrične + 'If matrix is triangular, this will copy the data to the ': 'Če je matrika trikotna, bo to preslikalo podatke ' + other triangle: še v nasprotni trikotnik. + Browse documentation datasets: Prebrskaj podatke iz dokumentacije + def `browse_file`: + File: Datoteka + Cannot find the directory with documentation datasets: Ne najdem imenika z datotekami iz dokumentacije + ~/: false + Open Distance File: Odpri datoteko razdalj + All Readable Files (*.xlsx *.dst);;: Vse berljive datoteke (*.xlsx *.dst);; + Excel File (*.xlsx);;: Excelova datoteka (*.xlsx);; + Distance File (*.dst): Razdalje (*.dst) + def `open_file`: + .: false + (none): (prazno) + ' \n': false + def `send_report`: + No data was loaded.: Podatki niso naloženi. + File name: Ime datoteke + class `OWDistanceFileDropHandler`: + def `parametersFromFile`: + recent_paths: false + def `canDropFile`: + .dst: false + .xlsx: false + __main__: false +widgets/unsupervised/owdistancemap.py: + class `DistanceMapItem`: + def `hoverMoveEvent`: + '{}, {}: {:.3f}': false + class `OWDistanceMap`: + Distance Map: Slika razdalj + Visualize a distance matrix.: Prikaže matriko razdalj s toplo gredo. + icons/DistanceMap-symbolic.svg: false + distance map: distance map, slika razdalj + class `Inputs`: + Distances: Razdalje + class `Outputs`: + Selected Data: Izbrani podatki + Features: Spremenljivke + class `Error`: + Empty distance matrix: Prazna matrika razdalj + Distance matrix is not symmetric.: Matrika razdalj ni simetrična. + grid_widget: false + def `__init__`: + sorting: false + Element Sorting: Urejanje + None: (Brez) + Clustering: Gručenje + Clustering with ordered leaves: Gručenje z urejenimi listi + Colors: Barve + annotation_idx: false + Annotations: Oznake + Enumeration: Oštevilčenje + autocommit: false + def `set_distances`: + 'Cluster ordering was disabled due to the input ': Urejeno gručenje + matrix being to big: ' je izključeno, ker je podatkov preveč.' + 'Clustering was disabled due to the input ': Gručenje + def `set_items`: + None: (Brez) + Enumeration: Oštevilčenje + Attribute names: Imena spremenljivk + Name: Imena + def `_update_labels`: + Attribute names: Imena spremenljivk + def `send_report`: + Sorting: Urejenost + Annotations: Oznake + __main__: false + iris: false +widgets/unsupervised/owdistancematrix.py: + class `OWDistanceMatrix`: + Distance Matrix: Matrika razdalj + View distance matrix.: Pokaže matriko razdalj. + icons/DistanceMatrix-symbolic.svg: false + distance matrix: distance matrix + class `Inputs`: + Distances: Razdalje + class `Outputs`: + Distances: Razdalje + Selected Data: Izbrani podatki + Table: Tabela + class `Error`: + Distance matrix is empty.: Matrika razdalj je prazna. + def `__init__`: + annotation_idx: false + 'Labels: ': 'Oznake: ' + None: Brez + Enumeration: Oštevilčenje + auto_commit: false + def `set_distances`: + None: Brez + Enumerate: Oštevilčenje + Labels: Oznake + Attribute names: Imena spremenljivk + Name: Imena + def `_choose_label`: + Enumerate: Oštevilčenje + def `_update_labels`: + Attribute names: Imena spremenljivk + Labels: Oznake + def `_set_selection`: + wrong data for symmetric selection: false + wrong data for asymmetric selection: false + def `send_report`: + def `cell`: + ' style="background-color: {brush.color().name()}"': false + {label:.{ndec}f}: false + {label}\n: false + : false + : false + : false + : false +
      : false + __main__: false + zoo: false +widgets/unsupervised/owdistances.py: + Euclidean (normalized): Evklidska (normalizirana) + Square root of summed difference between normalized values: Kvadratni koren vsote razlik med normaliziranimi vrednostmi + Euclidean: Evklidska + Square root of summed difference between values: Kvadratni koren vsote razlik med vrednostmi + Manhattan (normalized): Manhattanska (normalizirana) + Sum of absolute differences between normalized values: Vsota absolutnih razlik med normaliziranimi vrednostmi + Manhattan: Manhattanska + Sum of absolute differences between values: Vsota absolutnih razlik med vrednostmi + Mahalanobis: Mahalanobisova + Mahalanobis distance: Mahalanobisova razdalja + Hamming: Hammingova + Hamming distance: Hammingova razdalja + Cosine: kosinusna + Cosine distance: kosinusna razdalja + Pearson: Pearsonova + Pearson correlation; distance = 1 - ρ/2: Pearsonova korelacija; razdalja = 1 - ρ/2 + Pearson (absolute): Pearsonova (absolutna) + Absolute value of Pearson correlation; distance = 1 - |ρ|: Absolutna vrednost Pearsonove korelacije; razdalja = 1 - |ρ| + Spearman: Spearmanova + Spearman correlation; distance = 1 - ρ/2: Spearmanova korelacija; razdalja = 1 - ρ/2 + Spearman (absolute): Spearmanova (absolutna) + Jaccard: Jaccardova + Jaccard distance: Jaccardova razdalja + class `DistanceRunner`: + def `run`: + Calculating...: Računam ... + axis: false + impute: false + callback: false + normalize: false + class `OWDistances`: + Distances: Razdalje + Compute a matrix of pairwise distances.: Izračunaj matriko razdalj. + icons/Distance-symbolic.svg: false + distances: distances + class `Inputs`: + Data: Podatki + class `Outputs`: + Distances: Razdalje + class `Error`: + No numeric features: Ni številskih spremenljivk + No binary features: Ni binarnih spremenljivk + {} requires dense data.: {} zahteva goste podatke. + Not enough memory: Premalo pomnilnika + Problem in calculation:\n{}: Težava pri izračunu:\n{} + Mahalanobis handles up to 1000 {}.: Mahalanobisova razdalja zmore do 1000 {}. + Data is too large (> {MAX_ITEMS} items).: Podatki so preveliki (> {MAX_ITEMS} primerov). + class `Warning`: + Ignoring categorical features: Kategoričnih spremenljivk ne upoštevam + Ignoring non-binary features: Ne-binarnih spremenljivk ne upoštevam + Some metrics don't support sparse data\n: Nekatere metrike ne podpirajo redkih podatkov\n + 'and were disabled: {}': in so bile onemogočene: {} + Missing values were imputed: Manjkajoče vrednosti so bile nadomeščene + Data has no features: Podatki nimajo spremenljivk. + def `__init__`: + axis: false + Rows: Vrstice + Columns: Stolpce + Compare: Primerjaj + Distance Metric: Razdalja + autocommit: false + def `refresh_radios`: + ', ': false + def `compute_distances`: + def `_fix_discrete`: + fallback: false + def `_check_tractability`: + rows: vrstic + columns: stolpcev + def `send_report`: + Distances Between: Razdalje med + Rows: vrsticami + Columns: stolpci + Metric: Razdalja + def `migrate_settings`: + normalized_dist: false + metric_idx: false + metric_id: false + __main__: false + iris: false +widgets/unsupervised/owdistancetransformation.py: + class `OWDistanceTransformation`: + Distance Transformation: Transformacija razdalj + Transform distances according to selected criteria.: Transformira razdalje v matriki. + icons/DistancesTransformation-symbolic.svg: false + distance transformation: distance transformation + class `Inputs`: + Distances: Razdalje + class `Outputs`: + Distances: Razdalje + No normalization: Brez normiranja + To interval [0, 1]: V interval [0, 1] + To interval [-1, 1]: V interval [-1, 1] + 'Sigmoid function: 1/(1+exp(-X))': Sigmoidna funkcija: 1/(1+exp(-X)) + No inversion: Brez obračanja + -X: true + 1 - X: true + max(X) - X: true + 1/X: true + def `__init__`: + normalization_method: false + Normalization: Normiranje + inversion_method: false + Inversion: Obračanje + autocommit: false + def `send_report`: + inversion ({}): obračanje ({}) + normalization ({}): normiranje ({}) + Model parameters: Parametri modela + Transformation: Transformacija + ', ': false + None: Brez + __main__: false + iris: false +widgets/unsupervised/owhierarchicalclustering.py: + OWHierarchicalClustering: false + Single: Minimalna + Average: Povprečna + Weighted: Utežena + Complete: Maksimalna + Ward: Wardova + single: false + average: false + weighted: false + complete: false + ward: false + class `SaveStateSettingsHandler`: + def `initialize`: + __session_state_data: false + def `pack_data`: + __session_state_data: false + class `OWHierarchicalClustering`: + Hierarchical Clustering: Hierarhično gručenje + 'Display a dendrogram of a hierarchical clustering ': 'Prikaži dendrogram hierarhičnega razvrščanja v skupine, ' + constructed from the input distance matrix.: zgrajenega iz matrike razdalj. + icons/HierarchicalClustering-symbolic.svg: false + hierarchical clustering: hierarchical clustering, dendrogram, razvrščanje, skupine + class `Inputs`: + Distances: Razdalje + Data Subset: Podmnožica podatkov + class `Outputs`: + Selected Data: Izbrani podatki + Enumeration: Številčenje + Name: Ime + scene: false + class `Error`: + Distance matrix is empty.: Matrika razdalj je prazna + Some distances are infinite: Nekatere razdalje so neskončne. + Distance matrix is not symmetric.: Matrika razdalj ni simetrična + class `Warning`: + 'Unused data subset: distances do not refer to data instances': Neuporabljeni podatki: matrika razdalj se ne nanaša na podane vhodne podatke. + Some data from the subset does not appear in distance matrix: Nekateri izmed primerov v vhodnih podatkih se ne pojavijo v matriki razdalj. + Subset data refers to a different table: Podmnožica podatkov pripada drugi tabeli podatkov. + Pruned cluster doesn't show colors and indicate subset: Porezano gručenje ne kaže barv in podmnožic. + 'Variables with too many values may ': 'Spremenljivke z veliko vrednostmi lahko ' + degrade the performance of downstream widgets.: poslabšajo delovanje kasnejših gradnikov. + def `__init__`: + M: false + linkage: false + Linkage: Razdalja med gručami + None: Brez + Annotations: Oznake + annotation: false + label_only_subset: false + Show labels only for subset: Pokaži oznake samo za podmnožico + color_by: false + Color by:: Barva: + pruning: false + Pruning: Rezanje + max_depth: false + Max depth:: Največja globina + selection_method: false + Selection: Izbor + Manual: Ročno + Height ratio:: Razmerje višine: + cut_ratio: false + ' %': true + Top N:: Prvih N: + top_n: false + zoom_factor: false + Zoom: Povečava + Zoom in: Povečaj + Zoom out: Pomanjšaj + Reset zoom: Ponastavi zoom + autocommit: false + top: false + bottom: false + def `_set_items`: + Name: Ime + def `_update_labels`: + label_model: false + Enumeration: Številčenje + Name: Ime + \n: false + ' ': false + ', ': true + def `commit`: + items: false + Cluster: Gruča + C{i + 1}: true + Other: Drugo + cluster: Gruča + def `save_state`: + version: false + selection_state: false + def `set_restore_state`: + selection_state: false + def `send_report`: + manual: ročno + at {:.1f} of height: pri {:.1f} višine + top {self.top_n} {pl(self.top_n, 'cluster')}: {plsi(self.top_n, 'vrhnja|vrhnji|vrhnje|vrhnjih')} {self.top_n} {plsi(self.top_n, 'gruča')} + Linkage: Razdalja med gručami + Annotation: Oznake + Pruning: Rezanje + {} levels: {} nivojev + Selection: Izbor + def `migrate_context`: + annotation: false + None: false + def `main`: + iris: false + __main__: false +widgets/unsupervised/owkmeans.py: + class `ClusterTableModel`: + def `data`: + {:.3f}: false + NA: NN + class `OWKMeans`: + k-Means: K gruč + 'k-Means clustering algorithm with silhouette-based ': Gručenje v k gruč in izbor s silhueto. + quality estimation.: "" + icons/KMeans-symbolic.svg: false + k-means, kmeans, clustering: k-means, kmeans, clustering, gručenje + class `Inputs`: + Data: Podatki + class `Outputs`: + Annotated Data: Označeni podatki + Centroids: Centroidi + class `Error`: + 'Clustering failed\nError: {}': Gručenje ni uspelo.\nNapaka: {} + Too few ({}) unique data instances for {} clusters: Premalo ({}) neponovljenih primerov za {} gruč. + Data is missing features.: Podatki nimajo spremenljivk. + class `Warning`: + Silhouette scores are not computed for >{} samples: Silhuet za več kot {} primerov ne računam. + Too few ({}) unique data instances for {} clusters: Premalo ({}) neponovljenih primerov za {} gruč. + Sparse data cannot be normalized: Redkih podatkov ni možno normirati. + Initialize with KMeans++: Začni s KMeans++ + k-means++: false + Random initialization: Začni naključno + random: false + def `migrate_settings`: + auto_apply: false + auto_commit: false + def `__init__`: + optimize_k: false + Number of Clusters: Število gruč + Fixed:: Določeno: + k: false + From: Od + k_from: false + to: do + k_to: false + Preprocessing: Predprocesiranje + normalize: false + Normalize columns: Normiraj stolpce + Initialization: Postopek + smart_init: false + 'Re-runs: ': Število ponovitev + n_init: false + 'Maximum iterations: ': 'Največje število korakov: ' + max_iterations: false + Silhouette Scores: Silhuete + auto_commit: false + def `send_data`: + Cluster: Gruča + C%d: G%d + Silhouette: Silhuete + centroids: centroidi + {self.data.name} centroids: centroidi {self.data.name} + def `send_report`: + Number of clusters: Število gruč + Optimization: Optimizacija + {}, {} re-runs limited to {} steps: {}, {} ponovitev, omejenih na {} korakov + Data: Podatki + Silhouette scores for different numbers of clusters: Silhuete za različno število gruč + __main__: false + heart_disease: false +widgets/unsupervised/owlouvainclustering.py: + Euclidean: Evklidska + l2: false + Manhattan: Manhattanska + l1: false + Cosine: Kosinusna + cosine: false + class `OWLouvainClustering`: + Louvain Clustering: Louvainsko gručenje + Detects communities in a network of nearest neighbors.: Poišče zgostitve v mreži najbližjih sosedov. + icons/LouvainClustering-symbolic.svg: false + community: community, gručenje, clustering + class `Inputs`: + Data: Podatki + class `Outputs`: + Network: Mreža + class `Information`: + Press Apply to recompute clusters and send new data: Pritisnite Uveljavi za izračun novih gruč in pošiljanje podatkov + class `Error`: + No features in data: Podatki ne vsebujejo spremenljivk + def `__init__`: + Info: Info + No data on input.: Ni podatkov na vhodu + Preprocessing: Predprocesiranje + normalize: false + Normalize data: Normiraj podatke + apply_pca: false + Apply PCA preprocessing: Predprocesiraj s PCA + pca_components: false + 'PCA Components: ': Število komponent + Graph parameters: Parametri mreže + metric_idx: false + Distance metric: Mera razdalje + k_neighbors: false + k neighbors: Število sosedov + resolution: false + Resolution: Ločljivost + %.1f: false + 'The resolution parameter affects the number of clusters to find. ': Ločljivost vpliva na število najdenih gruč. + 'Smaller values tend to produce more clusters and larger values ': Manjša ločljivost običajno vodi v več gruč, večja ločljivost pa v manj. + retrieve less clusters.: "" + auto_commit: false + def `commit`: + Running...: Tečem... + def `__set_partial_results`: + pca_projection: false + graph: false + partition: false + def `__set_results`: + {num_clusters} {pl(num_clusters, 'cluster')} found.: {plsi(num_clusters, 'Najdena|Najdeni|Najdene|Najdenih')} {z_besedo(num_clusters, 1, 'f')} {plsi(num_clusters, 'gruča')}. + def `_send_data`: + Cluster: Gruča + C%d: G%d + def `set_data`: + Clustering not yet run.: Gručenje še ni steklo. + def `clear`: + No data on input.: Ni podatkov na vhodu. + def `send_report`: + , {self.pca_components} {pl(self.pca_components, 'component')}: , {self.pca_components} {plsi(self.pca_components, 'komponenta')} + Normalize data: Normiranje podatkov + PCA preprocessing: Predprocesiranje s PCA + Metric: Mera razdalje + k neighbors: Število sosedov + Resolution: Ločljivost + def `migrate_settings`: + context_settings: false + apply_pca: false + k_neighbors: false + metric_idx: false + normalize: false + pca_components: false + resolution: false + def `run_on_data`: + Computing PCA...: Računam PCA... + pca_projection: false + Building graph...: Sestavljam mrežo... + graph: false + Detecting communities...: Iščem zgostitve... + partition: false + def `run_on_graph`: + Detecting communities...: Iščem zgostitve... + partition: false + __main__: false + iris: false +widgets/unsupervised/owmanifoldlearning.py: + class `ManifoldParametersEditor`: + def `_create_spin_parameter`: + 0: false + def `_create_combo_parameter`: + _values: false + _index: false + def `__combo_parameter_update`: + _index: false + _values: false + def `_create_radio_parameter`: + _values: false + _index: false + def `__radio_parameter_update`: + _index: false + _values: false + class `TSNEParametersEditor`: + euclidean: false + manhattan: false + chebyshev: false + jaccard: false + Euclidean: Evklidska + Manhattan: Manhattanska + Chebyshev: Čebiševa + Jaccard: Jaccardova + pca: false + PCA: true + random: false + Random: Naključno + def `__init__`: + metric: false + Metric:: Metrika: + perplexity: false + Perplexity:: Zmedenost (perplexity): + early_exaggeration: false + Early exaggeration:: Zgodnje pretiravanje (exaggeration): + learning_rate: false + Learning rate:: Hitrost učenja: + n_iter: false + Max iterations:: Največje število ponovitev: + initialization: false + Initialization:: Uporabi: + def `get_report_parameters`: + Metric: Metrika + metric: false + Perplexity: Zmedenost (perplexity) + perplexity: false + Early exaggeration: Zgodnje pretiravanje (exaggeration) + early_exaggeration: false + Learning rate: Hitrost učenja + learning_rate: false + Max iterations: Največje število ponovitev + n_iter: false + Initialization: Uporabi + initialization: false + class `MDSParametersEditor`: + PCA: false + PCA (Torgerson): true + random: false + Random: Naključno + def `__init__`: + max_iter: false + Max iterations:: Največje število ponovitev: + init_type: false + Initialization:: Uporabi: + def `get_parameters`: + n_init: false + def `get_report_parameters`: + Max iterations: Največje število ponovitev + max_iter: false + Initialization: Uporabi + init_type: false + class `IsomapParametersEditor`: + def `__init__`: + n_neighbors: false + Neighbors:: Sosedi: + def `get_report_parameters`: + Neighbors: Sosedi + n_neighbors: false + class `LocallyLinearEmbeddingParametersEditor`: + standard: false + Standard: Standardno + modified: false + Modified: Modificirano + hessian: false + Hessian eigenmap: Hessijev lastni zemljevid + ltsa: false + Local: Lokalno + def `__init__`: + method: false + Method:: Metoda: + n_neighbors: false + Neighbors:: Sosedi: + max_iter: false + Max iterations:: Največje število ponovitev: + def `get_report_parameters`: + Method: Metoda + method: false + Neighbors: Sosedi + n_neighbors: false + Max iterations: Največje število ponovitev + max_iter: false + class `SpectralEmbeddingParametersEditor`: + nearest_neighbors: false + Nearest neighbors: Najbližji sosedje + rbf: false + RBF kernel: Jedro RBF + def `__init__`: + affinity: false + Affinity:: Povezanost: + def `get_report_parameters`: + Affinity: Povezanost + affinity: false + class `OWManifoldLearning`: + Manifold Learning: Večplastno učenje + Nonlinear dimensionality reduction.: Nelinearno zmanjšanje dimenzionalnosti. + icons/Manifold-symbolic.svg: false + manifold learning: manifold learning + class `Inputs`: + Data: Podatki + class `Outputs`: + Transformed Data: Spremenjeni podatki + Transformed data: Spremenjeni podatki + class `Error`: + 'For chosen method and components, ': 'Za izbrano metodo in komponente ' + neighbors must be greater than {}: morajo biti sosedje večji od {} + {}: false + Sparse data is not supported.: Redki podatki niso podprti. + Out of memory: Premalo pomnilnika + class `Warning`: + Disconnected graph, embedding may not work: Graf je nepovezan, vložitev morda ne bo delovala. + Creating {} components\n: Računam {} komponent.\n + The number of components is limited by the number of variables.: Število komponent je omejeno s številom spremenljivk. + def `migrate_settings`: + tsne_editor: false + init_index: false + initialization_index: false + metric_index: false + def `__init__`: + Method: Metoda + manifold_method_index: false + Output: Izhod + n_components: false + Components:: Število komponent: + 0: false + def `commit`: + def `_handle_disconnected_graph_warning`: + Graph is not fully connected: Graf ni povsem povezan + "for method='hessian', n_neighbors ": false + must be greater than [n_components: false + ' * (n_components + 3) / 2]': false + def `_create_output_table`: + C{}: false + def `send_report`: + Method: Metoda + Number of components: Število komponent + Method parameters: Parametri metode + Data: Podatki + __main__: false + brown-selected: false +widgets/unsupervised/owmds.py: + def `run_mds`: + Running...: Tečem... + precomputed: false + eps: false + class `OWMDSGraph`: + def `update_pairs`: + pairs: false + on: false + class `OWMDS`: + MDS: Večrazsežnostno lestvičenje + 'Two-dimensional data projection by multidimensional ': 'Dvodimenzionalna projekcija večdimenzionalnih podatkov, ' + scaling constructed from a distance matrix.: ki poskuša ohraniti razdalje med primeri. + icons/MDS-symbolic.svg: false + mds, multidimensional scaling, multi dimensional scaling: mds, multidimensional scaling, multi dimensional scaling, večdimenzionalnost + class `Inputs`: + Distances: Razdalja + Every iteration: po vsakem koraku + Every 5 steps: vsakih pet korakov + Every 10 steps: vsakih deset korako + Every 25 steps: vsakih 25 korakov + Every 50 steps: vsakih 50 korakov + None: nikoli + mds-x: false + mds-y: false + class `Error`: + Input data needs at least 2 rows: Podatki morajo imeti vsaj dva primera. + Distance matrix is not symmetric: Matrika razdalj ni simetrična. + Input matrix must be at least 2x2: Matrika razdalj mora biti velika vsaj 2x2 + Data has no attributes: Podatki nimajo spremenljivk + Data and distances dimensions do not match.: Podatki in matrika razdalj so različno veliki + Out of memory: Premalo pomnilnika + Error during optimization\n{}: Napaka med računanjem\n{} + def `__init__`: + Stress: Napetost + def `_add_controls`: + Show similar pairs:: Pokaži podobne pare: + connected_pairs: false + def `_add_controls_optimization`: + Optimize: Izračun + PCA: PCA + Randomize: Razmeči + Jitter: Potresi + Start: Začni + Refresh:: Izris: + refresh_rate: false + 'Kruskal Stress: -': Kruskalova napetost: - + def `_initialize`: + labels: false + def `init_attr_values`: + labels: false + def `_toggle_run`: + Resume: Nadaljuj + def `_run`: + Stop: Stoj + PCA: Pca + random: false + def `on_done`: + Start: Začni + def `update_stress`: + -: false + {self.stress:.3f}: false + 'Kruskal Stress: {stress_val}': Kruskalova napetost: {stress_val} + def `on_exception`: + Start: Začni + def `do_initialization`: + Start: Začni + def `get_size_data`: + Stress: Napetost + def `get_stress`: + euclidean: false + def `migrate_settings`: + label_only_selected: false + symbol_opacity: false + alpha_value: false + symbol_size: false + point_width: false + jitter: false + jitter_size: false + graph: false + auto_commit: false + autocommit: false + connected_pairs: false + def `migrate_context`: + color_value: false + attr_color: false + shape_value: false + attr_shape: false + size_value: false + attr_size: false + label_value: false + attr_label: false + Stress: false + graph: false + ' + + + + + + + + + +': false + __main__: false + iris: false +widgets/unsupervised/owpca.py: + component variance: varianca kompomente + cumulative variance: skupna varianca + class `OWPCA`: + PCA: PCA + Principal component analysis with a scree-diagram.: Analiza osnovnih komponent. + icons/PCA-symbolic.svg: false + pca, principal component analysis, linear transformation: pca, principal component analysis, linear transformation, osnovne komponente, linearna transformacija + class `Inputs`: + Data: Podatki + class `Outputs`: + Transformed Data: Spremenjeni podatki + Transformed data: Spremenjeni podatki + Data: Podatki + Components: Komponente + PCA: PCA + plot.plotItem: false + class `Warning`: + 'All components of the PCA are trivial (explain 0 variance). ': Vse komponente so trivialne + Input data is constant (or near constant).: Vhodni podatki so konstantni (ali skoraj konstantni) + class `Error`: + At least 1 feature is required: Potreben je vsaj en faktor. + At least 1 data instance is required: Tabela s podatki je prazna. + def `__init__`: + Components Selection: Izbor komponent + ncomponents: false + All: Vse + variance_covered: false + %: true + Components:: Komponente: + Explained variance:: Razložena varianca: + Options: Opcije + normalize: false + Normalize variables: Normiraj spremenljivke + maxp: false + Show only first: Pokaži le prvih + auto_commit: false + Principal Components: Osnovne komponente + Proportion of variance: Delež variance + def `set_data`: + Data has been sampled: Podatki so bili vzorčeni + def `_update_axis`: + bottom: false + def `commit`: + variance: false + components: komponente + PC{i + 1}: Komp{i + 1} + def `send_report`: + Normalize data: Normiranje podatkov + Selected components: Število komponent + Explained variance: Razložena varianca + {self.variance_covered:.3f} %: false + def `migrate_settings`: + variance_covered: false + ncomponents: false + decomposition_idx: false + batch_size: false + address: false + auto_update: false + __main__: false + housing: false +widgets/unsupervised/owsavedistances.py: + class `OWSaveDistances`: + Save Distance Matrix: Shrani matriko razdalj + Save distance matrix to an output file.: Shrani matriko razdalj v datoteko. + icons/SaveDistances-symbolic.svg: false + save distance matrix, distance matrix, save: save distance matrix, distance matrix, matrika, razdalje + Excel File (*.xlsx): Excelova datoteka (*.xlsx) + Distance File (*.dst): Datoteka razdalj (*.dst) + class `Warning`: + Associated data was not saved.: Pripeta tabela podatkov ni shranjena. + Data associated with {} was not saved.: Podatki o {} niso shranjeni. + class `Inputs`: + Distances: Razdalje + def `do_save`: + columns: stolpcih + rows: vrsticah + def `send_report`: + Input: Vhod + none: brez + File name: Ime datoteke + not set: ni nastavljeno + def `_description`: + ' and ': ' in ' + row: vrstic + column: stolpcev + ; {labels} labels: ; oznake {labels} + {len(dist)}-dimensional matrix{labels}: {len(dist)}-dimenzionalna matrika{labels} + __main__: false + iris: false +widgets/unsupervised/owsom.py: + class `SomSharedValueCompute`: + def `__getstate__`: + __hash: false + class `OWSOM`: + Self-Organizing Map: SOM + Computation of self-organizing map.: Samodejni zemljevid. + icons/SOM-symbolic.svg: false + self-organizing map, som: self-organizing map, som, samodejni zemljevid + class `Inputs`: + Data: Podatki + class `Outputs`: + Selected Data: Izbrani podatki + view: false + OptControls: false + shape: false + auto_dim: false + spin_x: false + spin_y: false + initialization: false + start: false + class `Information`: + 'The parameter settings have been changed. Press "Start" to ': 'Parametri so bili spremenjeni. Pritisnite "Začni", da ' + rerun with the new settings.: zaženete z novimi nastavitvami. + class `Warning`: + SOM ignores categorical variables.: SOM ne upošteva kategoričnih spremenljivk. + Some data instances have undefined value of '{}'.: Nekatere vrstice imajo neznane vrednosti '{}'. + "'{}' has no defined values.": "'{}' ima neznane vrednosti." + {}: false + Data contains a single numeric column.: Podatki imajo le eno številsko spremenljivko. + class `Error`: + Data contains no numeric columns.: Podatki nimajo številskih spremenljivk. + SOM needs at least two data rows without missing values.: SOM potrebuje vsaj dva primera brez neznanih vrednosti. + def `__init__`: + SOM: SOM + Hexagonal grid: Šestkotna mreža + Square grid: Pravokotna mreža + auto_dimension: false + Set dimensions automatically: Samodejno nastavi velikost mreže + ×: false + initialization: false + Initialize with PCA: Uporabi PCA + Random initialization: Naključni razpored + Replicable random: Ponovljivo naključje + Restart: Ponovno poženi + Color: Barva + attr_color: false + (Same color): (Enobarvno) + pie_charts: false + Show pie charts: Pokaži tortne diagrame + size_by_instances: false + Size by number of instances: Velikost odraža število primerov + def `set_data`: + def `set_warnings`: + {missing} data {pl(missing, "instance")} with undefined value(s) {pl(missing, "is|are")} not shown.: {missing} {plsi(missing, "primer")} z neznanimi vrednostmi {pl(missing, "ni prikazan|nista prikazana|niso prikazani|ni prikazanih")}. + def `enable_controls`: + Start: Začni + Stop: Stoj + def `_draw_same_color`: + {n} instances: {n} {plsi(n, "primer")} + def `_get_color_column`: + ignore: false + def `_tooltip`: + (N/A): (NN) + \N{NON-BREAKING HYPHEN}: false + '': false + ' + + + + + ': false +
      + + {escape(val).replace("-", nbhp)}: + + {n} ({n / tot * 100:.1f} %) +
      : false + def `update_output`: + som_cell: false + r{row + 1}c{col + 1}: false + som_row: false + som_col: false + som_error: false + Unselected: Neizbrani + No: Ne + Yes: Da + def `_bin_names`: + < {labels[0]}: true + {x} - {y}: true + ≥ {labels[-1]}: true + def `send_report`: + Self-organizing map colored by '{self.attr_color.name}': Samodejni zemljevid, pobarvan glede na '{self.attr_color.name}' + def `migrate_settings`: + selection: false + __main__: false + iris: false +widgets/unsupervised/owtsne.py: + PCA: true + pca: false + Spectral: Spektralna + spectral: false + Euclidean: Evklidska + l2: false + Manhattan: Manhattan + l1: false + Cosine: Kosinusna + cosine: false + class `Task`: + def `validate`: + Task: false + Both `distance_matrix` and `data` cannot be `None`: false + precomputed: false + '`distance_metric` must be set to `precomputed` when using ': false + a distance matrix: false + spectral: false + '`initialization_method` must be set to `spectral` when ': false + using a distance matrix: false + '`distance_metric` cannot be set to `precomputed` when no ': false + distance matrix is provided: false + Data normalization is not supported for sparse data: false + class `TSNERunner`: + def `compute_tsne_preprocessing`: + Preprocessing data...: Predobdelujem podatke... + preprocessed_data: false + def `compute_normalization`: + Normalizing data...: Normaliziram podatke... + normalized_data: false + def `compute_pca`: + Computing PCA...: Računam PCA... + pca_projection: false + def `compute_initialization`: + Preparing initialization...: Pripravljam začetno stanje... + pca: false + spectral: false + Unrecognized initialization scheme `{task.initialization_method}`!: false + initialization: false + def `compute_affinities`: + Finding nearest neighbors...: Iščem najbližje sosede... + precomputed: false + affinities: false + def `compute_tsne`: + Running optimization...: Optimizacija teče... + precomputed: false + tsne_embedding: false + def `run_optimization`: + tsne_embedding: false + def `run`: + preprocessing: false + normalization: false + pca: false + init: false + aff: false + tsne: false + precomputed: false + class `invalidated`: + def `__str__`: + %s(%s): false + ', ': false + =: false + preprocessed_data: false + normalized_data: false + pca_projection: false + initialization: false + affinities: false + tsne_embedding: false + class `OWtSNE`: + t-SNE: false + Two-dimensional data projection with t-SNE.: Dvodimenzionalna projekcija podatkov s t-SNE. + icons/TSNE-symbolic.svg: false + t-sne, tsne: tsne, projekcija podatkov + t-SNE-x: false + t-SNE-y: false + class `Inputs`: + Distances: Razdalje + class `Information`: + 'The parameter settings have been changed. Press ': 'Parametri so spremenjeni. Pritisnite ' + \"Start\" to rerun with the new settings.: '"Začni" za ponovni izračun.' + class `Warning`: + The input data contains a large number of features, which may slow: Vhodni podatki vsebujejo veliko spremeljivk, kar lahko upočasni + ' down t-SNE computation. Consider enabling PCA preprocessing.': ' t-SNE. Priporočamo predobdelavo s PCA.' + class `Error`: + Input data needs at least 2 rows: Podatki morajo vsebovati vsaj dva primera. + Input data needs at least 2 attributes: Podatki morajo vsebovati vsaj dve spremenljivki. + Input data is constant: Podatki so konstantni. + No projection due to no valid data: Ni veljavnih podatkov - ni projekcije. + Distance matrix is not symmetric: Matrika razdalj ni simetrična. + Input matrix must be at least 2x2: Matrika mora biti vsaj 2x2. + Data and distance dimensions do not match: Dimenzije podatkov in razdalj se ne ujemajo. + def `_add_controls_start_box`: + Preprocessing: Predobdelava + normalize: false + Normalize data: Normiraj podatke + use_pca_preprocessing: false + Apply PCA preprocessing: Uporabi predobdelavo s PCA + pca_components: false + PCA Components:: Komponente PCA: + Parameters: Parametri + initialization_method_idx: false + Initialization:: Začetno stanje: + distance_metric_idx: false + Distance metric:: Mera razdalje: + perplexity: false + Perplexity:: Zmedenost (perplexity): + multiscale: false + Preserve global structure: Ohrani globalni razpored: + exaggeration: false + %.2f: false + Exaggeration:: Pretiravanje (exaggeration): + Start: Začni + def `_stop_running_task`: + Start: Začni + def `check_data`: + ignore: false + Degrees of freedom .*: false + def `_toggle_run`: + Resume: Nadaljuj + def `enable_controls`: + Precomputed distances provided. Preprocessing is unnecessary!: Kadar so na voljo predizračunane razdalje, predobdelava ni potrebna. + Spectral: Spektralna + 'Only spectral intialization is supported with precomputed ': 'Kadar uporabljamo predizračunane razdalje, je na voljo le spektralna ' + distance matrices.: inicializacija. + Precomputed distances provided.: Na voljo so predizračunane razdalje. + Data normalization is not supported on sparse matrices.: Normalizacija redkih podatkov ni podprta. + def `run`: + Stop: Prekini + precomputed: false + spectral: false + def `__ensure_task_same_for_preprocessing`: + precomputed: false + def `__ensure_task_same_for_normalization`: + precomputed: false + def `__ensure_task_same_for_pca`: + precomputed: false + def `__ensure_task_same_for_affinities`: + precomputed: false + def `on_partial_result`: + preprocessed_data: false + normalized_data: false + pca_projection: false + initialization: false + affinities: false + tsne_embedding: false + Unrecognized partial result called with `%s`: false + def `on_done`: + Start: Začni + def `cancel`: + Start: Začni + def `migrate_settings`: + selection_indices: false + selection: false + max_iter: false + def `migrate_context`: + attr_color: false + graph: false + attr_size: false + attr_shape: false + attr_label: false + __main__: false + iris: false +widgets/utils/PDFExporter.py: + PDFExporter: false +widgets/utils/SVGExporter.py: + SVGExporter: false +widgets/utils/__init__.py: + def `to_html`: + <=: false + ≤: false + >=: false + ≥: false + <: false + <: false + >: false + >: false + =\\=: false + ≠: false + def `dumpObjectTree`: + QObject: false + {indent}{type} "{name}": false + ' ': false + def `qname`: + {0.__module__}.{0.__qualname__}: false + _T1: false + _E: false + _A: false + _B: false + def `instance_tooltip`: + def `show_part`: + {} = {}: false + ... and {n_vars - max_shown + 1} others: ... in še {n_vars - max_shown + 1} {plsi(n_vars - max_shown + 1, "spremenljivka|spremenljivki|druge spremenljivke|drugih spremenljivk")} + {}:
      : false +
      : false + Class: Razred + Classes: Razredi + Meta: Meta + Metas: Meta + Feature: Spremenljivka + Features: Spremenljivke +
      : false +widgets/utils/annotated_data.py: + Data: Podatki + Selected: Izbrani podatki + def `domain_with_annotation_column`: + No: Ne + Yes: Da + def `create_annotated_table`: + No: Ne + Yes: Da + def `group_values`: + G{}: true + Unselected: Neizbrani +widgets/utils/buttons.py: + VariableTextPushButton: false + SimpleButton: false + FixedSizeButton: false + def `tooltip_with_shortcut`: + {}: false + {}: false +   : false +widgets/utils/classdensity.py: + def `compute_density`: + C_CONTIGUOUS: false +widgets/utils/colorgradientselection.py: + class `ColorGradientSelection`: + def `__init__`: + gradient-combo-box: false + Low gradient threshold: Spodnja meja gradienta + 'Applying a low threshold will squeeze the ': 'Določa vrednost, pri kateri se začne ' + gradient from the lower end: barva spreminjati + Range:: Razpon: + def `__update_center_visibility`: + Center at:: Središče v: +widgets/utils/colorpalettes.py: + Palette: false + IndexedPalette: false + DiscretePalette: false + LimitedDiscretePalette: false + DiscretePalettes: false + DefaultDiscretePalette: false + DefaultDiscretePaletteName: false + HuePalette: false + DefaultRGBColors: false + Dark2Colors: false + Glasbey: false + ContinuousPalette: false + ContinuousPalettes: false + BinnedContinuousPalette: false + DefaultContinuousPalette: false + DefaultContinuousPaletteName: false + ColorIcon: false + get_default_curve_colors: false + patch_variable_colors: false + NAN_COLOR: false + class `Palette`: + PaletteFlags: false + Palette.Flags: false + def `__init__`: + _: false + class `DiscretePalette`: + def `from_colors`: + Custom: Paleta + class `LimitedDiscretePalette`: + def `__init__`: + "LimitedDiscretePalette: argument 'force_hsv' is deprecated; ": false + use 'force_glasbey' instead: false + custom: false + class `HuePalette`: + def `__init__`: + custom: false + class `ContinuousPalette`: + def `from_colors`: + Custom: Paleta + class `BinnedContinuousPalette`: + def `from_palette`: + can't create palette from '{type(palette).__name__}': false + Default: Privzeta + Dark: Temna + default: false + dark: false + glasbey: false + linear_bgyw_20_98_c66: false + Blue-Green-Yellow: Modra-Zelena-Rumena + linear_bmy_10_95_c78: false + Blue-Magenta-Yellow: Modra-Vijolična-Rumena + linear_grey_10_95_c0: false + Dim gray: Temno siva + linear_inferno: false + Inferno: true + linear_viridis: false + Viridis: true + diverging_bwr_40_95_c42: false + Coolwarm: Hladno-topla + diverging_gkr_60_10_c40: false + Green-Red: Zelena-Rdeča + diverging_protanopic_deuteranopic_bwy_60_95_c32: false + Diverging protanopic: Divergentna protanopična + Color-blind friendly: Prijazna do barvno slepih + diverging_tritanopic_cwr_75_98_c20: false + Diverging tritanopic: Divergentna triptanopična + linear_protanopic_deuteranopic_kbw_5_98_c40: false + Linear protanopic: Linearna protanopična + linear_tritanopic_krjcw_5_95_c24: false + Linear tritanopic: Linearna tritanopična + isoluminant_cgo_80_c38: false + Isoluminant: Enako svetla + Other: Druge + rainbow_bgyr_35_85_c73: false + Rainbow: Mavrica + def `patch_variable_colors`: + def `set_colors`: + palette: false + def `continuous_set_colors`: + colors: false + def `set_palette`: + palette: false + colors: false + def `continuous_get_colors`: + 'ContinuousVariable.color is deprecated; ': false + use ContinuousVariable.palette: false + colors: false + palette: false + def `continuous_get_palette`: + palette: false + colors: false + def `discrete_get_colors`: + def `retrieve_colors`: + palette: false + colors: false + def `discrete_set_colors`: + colors: false + def `discrete_get_palette`: + palette: false + colors: false +widgets/utils/combobox.py: + ComboBoxSearch: false + ComboBox: false + ItemStyledComboBox: false + TextEditCombo: false + class `ItemStyledComboBox`: + def `initStyleOption`: + QStyleOptionComboBox: false + class `TextEditCombo`: + def `__init__`: + editable: false + activated: false +widgets/utils/concurrent.py: + FutureWatcher: false + FutureSetWatcher: false + methodinvoke: false + TaskState: false + ConcurrentMixin: false + ConcurrentWidgetMixin: false + PyOwned: false + class `_TaskDepotThread`: + def `__new__`: + Already exists: false + class `_TaskRunnable`: + def `run`: + transfer: false + class `FutureRunnable`: + def `run`: + Exception in worker thread.: false + class `ThreadExecutor`: + def `__init__`: + Invalid `threadPool` type '{}': false + def `submit`: + 'Cannot schedule new futures after ': false + shutdown.: false + Use `submit_task` to run `Task`s: false + def `submit_task`: + `submit_task` will be deprecated: false + 'Cannot schedule new futures after ': false + shutdown.: false + def `__make_task_runnable`: + "Can only submit Tasks from it's own ": false + thread.: false + Can not submit Tasks with a parent.: false + class `Task`: + def `__init__`: + `Task` has been deprecated: false + def `_execute`: + Exception in Task: false + class `ConcurrentMixin`: + def `start`: + `task` must be callable!: false + def `_on_task_done`: + 'Starting new task from ': false + {'on_done' if ex is None else 'on_exception'} is forbidden: false +widgets/utils/datacaching.py: + def `getCached`: + __data_cache: false + def `setCached`: + __data_cache: false + def `delCached`: + __data_cache: false +widgets/utils/dendrogram.py: + DendrogramWidget: false + Point: false + x: false + y: false + Element: false + anchor: false + path: false + class `DendrogramWidget`: + def `_create_label`: + C{i + 1}: false +widgets/utils/distmatrixmodel.py: + class `DistMatrixModel`: + diverging_tritanopic_cwr_75_98_c20: false +widgets/utils/domaineditor.py: + class `VarTableModel`: + feature: spremenljivka + target: ciljna + meta: meta + skip: izpusti + categorical: kategorična + numeric: številska + text: besedilna + datetime: časovna + def `headerData`: + Name: Ime + Type: Vrsta + Role: Vloga + Values: Vrednosti + class `VarTypeDelegate`: + def `setEditorData`: + numeric: številska + datetime: časovna + class `DomainEditor`: + def `_is_missing`: + nan: true + def `parse_domain`: + def `discrete_value_display`: + ', ': false + , ...: false +widgets/utils/encodings.py: + utf-8: false + utf-16: false + utf-32: false + iso8859-1: false + cp1252: false + iso8859-2: false + cp1250: false + shift_jis: false + iso2022_jp: false + gb18030: false + euc_kr: false + Unicode (UTF-8): false + Unicode (UTF-16): false + utf-16-le: false + Unicode (UTF-16LE): false + utf-16-be: false + Unicode (UTF-16BE): false + Unicode (UTF-32): false + utf-32-le: false + Unicode (UTF-32LE): false + utf-32-be: false + Unicode (UTF-32BE): false + utf-7: false + Unicode (UTF-7): false + ascii: false + English (US-ASCII): false + Western Europe (ISO Latin 1): false + iso8859-15: false + Western Europe (ISO-8859-15): false + Western Europe (Windows-1252): false + mac_roman: false + Western Europe (Mac OS Roman): false + Central and Eastern Europe (ISO Latin 2): false + Central and Eastern Europe (Windows-1250): false + mac_latin2: false + Central and Eastern Europe (Mac Latin-2): false + iso8859-3: false + Esperanto, Maltese (ISO Latin 3): false + iso8859-4: false + Baltic Languages (ISO Latin 4): false + cp1257: false + Baltic Languages (Windows-1257): false + iso8859-13: false + Baltic Languages (ISO-8859-13): false + iso8859-16: false + South-Eastern Europe (ISO-8859-16): false + iso8859-5: false + Cyrillic (ISO-8859-5): false + cp1251: false + Cyrillic (Windows-1251): false + mac_cyrillic: false + Cyrillic (Mac OS Cyrillic): false + koi8-r: false + Cyrillic (KOI8-R): false + koi8-u: false + Cyrillic (KOI8-U): false + iso8859-14: false + Celtic Languages (ISO-8859-14): false + iso8859-10: false + Nordic Languages (ISO-8859-10): false + mac_iceland: false + Icelandic (Mac Iceland): false + iso8859-7: false + Greek (ISO-8859-7): false + cp1253: false + Greek (Windows-1253): false + mac_greek: false + Greek (Mac Greek): false + iso8859-8: false + Hebrew (ISO-8859-8): false + cp1255: false + Hebrew (Windows-1255): false + iso8859-6: false + Arabic (ISO-8859-6): false + cp1256: false + Arabic (Windows-1256): false + iso8859-9: false + Turkish (ISO-8859-9): false + cp1254: false + Turkish (Windows-1254): false + mac_turkish: false + Turkish (Mac Turkish): false + iso8859-11: false + Thai (ISO-8859-11): false + Japanese (ISO-2022-JP): false + iso2022_jp_1: false + Japanese (ISO-2022-JP-1): false + iso2022_jp_2: false + Japanese (ISO-2022-JP-2): false + iso2022_jp_2004: false + Japanese (ISO-2022-JP-2004): false + iso2022_jp_3: false + Japanese (ISO-2022-JP-3): false + Japanese (Shift JIS): false + shift_jis_2004: false + Japanese (Shift JIS 2004): false + euc_jp: false + Japanese (EUC-JP): false + iso2022_kr: false + Korean (ISO-2022-KR): false + Korean (EUC-KR): false + gb2312: false + Simplified Chinese (GB 2312): false + gbk: false + Chinese (GBK): false + Chinese (GB 18030): false + big5: false + Traditional Chinese (BIG5): false + big5hkscs: false + Traditional Chinese (BIG5-HKSC): false + cp1258: false + Vietnamese (Windows-1258): false + koi8-t: false + Tajik (KOI8-T): false + class `SelectEncodingsWidget`: + def `__init__`: + -top-heading-text: false + Select all: Izberi vse + '#selected-text-encodings': false + def `encodings_model`: + '; ': false + def `main`: + Select encodings visible in text encoding menus: Izberi kodiranja, ki bodo na voljo v menuju + __main__: false +widgets/utils/filedialogs.py: + open_filename_dialog_save: false + open_filename_dialog: false + RecentPath: false + RecentPathsWidgetMixin: false + RecentPathsWComboMixin: false + stored_recent_paths_prepend: false + OWUrlDropBase: false + def `dialog_formats`: + All readable files ({});;: Vse berljive datoteke ({});; + *: false + ' *': false + ;;: false + {} (*{}): false + def `get_stored_default_recent_paths`: + recent_paths: false +widgets/utils/graphicslayoutitem.py: + SimpleLayoutItem: false + scaled: false + class `SimpleLayoutItem`: + __anchorThis: false + __anchorItem: false + item: false + __resizeContents: false + __aspectMode: false + __transform: false + __scale: false + def `__init__`: + sizePolicy: false +widgets/utils/graphicsscene.py: + GraphicsScene: false + graphicsscene_help_event: false +widgets/utils/graphicstextlist.py: + TextListWidget: false + class `_FuncArray`: + func: false + length: false + class `TextListBase`: + def `__init__`: + sizePolicy: false +widgets/utils/graphicsview.py: + GraphicsWidgetView: false + class `GraphicsWidgetView`: + def `__init__`: + Zoom in: Povečaj + zoom-in-action: false + Zoom out: Zmanjšaj + zoom-out-action: false + Actual Size: Izvirna velikost + zoom-reset-action: false + Zoom to fit: Prilagodi oknu + zoom-to-fit-action: false + setShortcutVisibleInContextMenu: false + def `main`: + def `context`: + Aspect mode: false + Ignore: false + Keep: false + Keep by expanding: false + __main__: false +widgets/utils/headerview.py: + class `HeaderView`: + def `__init__`: + QHeaderView: false + def `initStyleOptionForIndex`: + textElideMode: false +widgets/utils/image.py: + def `qimage_from_array`: + Wrong number of channels (need 3 or 4, got {c}: false +widgets/utils/intervalslider.py: + class `IntervalSlider`: + def `__init__`: + set{opt[0].upper()}{opt[1:]}: false + def `setOrientation`: + IntervalSlider supports only horizontal direction: false +widgets/utils/itemdelegates.py: + class `FixedFormatNumericColumnDelegate`: + def `displayText`: + f: false + def `template`: + X: false + .: false + -: false + def `sizeHint`: + X: false +widgets/utils/itemmodels.py: + PyListModel: false + VariableListModel: false + PyListModelTooltip: false + DomainModel: false + AbstractSortTableModel: false + PyTableModel: false + TableModel: false + ModelActionsWidget: false + ListSingleSelectionModel: false + select_row: false + select_rows: false + signal_blocking: false + create_list_model: false + def `_as_contiguous_range`: + Non-contiguous range.: false + class `AbstractSortTableModel`: + def `mapFromTableRows`: + Orange.widgets.utils.itemmodels.AbstractSortTableModel.mapFromSourceRows: false + def `mapToTableRows`: + Orange.widgets.utils.itemmodels.AbstractSortTableModel.mapToSourceRows: false + class `PyTableModel`: + def `data`: + {:.{}{}}: false + f: false + e: false + def `_check_sort_order`: + Can't modify PyTableModel when it's sorted: false + class `VariableListModel`: + application/x-Orange-VariableList: false + def `data`: + None: Brez + def `variable_labels_tooltip`: + %s = %s: false +
      Variable Labels:
      :
      Oznake spremenljivke:
      +
      : false + def `discrete_variable_tooltip`: + '%s
      Categorical with %i values: ': '%s
      Kategorična, %i vrednost(i): ' + ', ': false + %r: false + def `time_variable_toltip`: + %s
      Time: %s
      Časovna + def `continuous_variable_toltip`: + %s
      Numeric: %s
      Številska + def `string_variable_tooltip`: + %s
      Text: %s
      Besedilna + class `DomainModel`: + def `prevent_modification`: + def `e`: + {} can be modified only by calling 'set_domain': false + <: false + <: false + >: false + >: false + class `TableModel`: + Column: false + var: false + role: false + background: false + format: false + Basket: false + vars: false + density: false + def `__init__`: + def `format_sparse`: + ', ': false + {}={}: false + def `format_sparse_bool`: + ', ': false + len(sourcedata) > 2 ** 31 - 1: false + def `columnSortKeyData`: + Orange.widgets.utils.itemmodels.TableModel.sortColumnData: false + def `headerData`: + {...}: false + def `_tooltip`: + %s: false +
      : false + %s = %s: false +widgets/utils/listfilter.py: + class `VariablesListItemView`: + def `acceptsDropEvent`: + _items: false + class `VariableFilterProxyModel`: + def `filter_accepts_variable`: + ' ': false + %s=%s: false + def `variables_filter`: + def `update_completer_model`: + %s=%s: false + def `update_completer_prefix`: + ' ': false + Filter the list of available variables.: Filtriraj seznam spremenljivk, ki so na voljo + Filter: true +widgets/utils/matplotlib_export.py: + scatterplot_code: false + scene_code: false +widgets/utils/messages.py: + UnboundMsg: false + MessageGroup: false + MessagesMixin: false + WidgetMessagesMixin: false +widgets/utils/multi_target.py: + Multiple targets are not supported.: Večrazredni problemi niso podprti. + def `check_multiple_targets_input`: + def `new_f`: + multiple_targets_data: false +widgets/utils/overlay.py: + OverlayWidget: false + MessageWidget: false + MessageOverlayWidget: false +widgets/utils/owbasesql.py: + class `OWBaseSql`: + class `Outputs`: + Data: Podatki + class `Error`: + {}: true + def `_setup_gui`: + Server: Strežnik + {}:{}: true + Database[/Schema]: Baza[/Shema] + Database or optionally Database/Schema: Baza ali, opcijsko, Baza/Shema + {}/{}: true + Username: Uporabniško ime + Password: Geslo + Connect: Poveži + def `_credential_manager`: + 'SQL Table: {}:{}': true + def `_parse_host_port`: + :: false + def `_check_db_settings`: + /: false + def `on_connection_success`: + Host: Strežnik + Port: Vrata + Database: Baza + User name: Uporabniško ime + def `on_connection_error`: + \n: true + def `send_report`: + No database connection.: Ni povezave z bazo. + Database: Baza + Data: Podatki +widgets/utils/owlearnerwidget.py: + class `OWBaseLearnerMeta`: + def `__new__`: + def `abstract_widget`: + name: false + def `copy_outputs`: + Outputs: false + LEARNER: false + "'{}' must declare attribute LEARNER": false + class `OWBaseLearner`: + class `Error`: + {}: false + Fitting failed.\n{}: Učenje ni uspelo.\n{} + Sparse data is not supported.: Redki podatki niso podprti. + Out of memory.: Premalo pomnilnika. + class `Warning`: + Press Apply to submit changes.: Pritisnite Uveljavi za uveljavitev sprememb + class `Information`: + Ignoring default preprocessing.\n: Ignoriram privzete predprocesorje.\n + 'Default preprocessing, such as scaling, one-hot encoding and ': 'Privzeto predprocesiranje, kot je skaliranje, kodiranje z indikatorji in ' + 'treatment of missing data, has been replaced with user-specified ': 'vstavljanje manjkajočih vrednosti, je zamenjano s predprocesorji, ki jih je ' + 'preprocessors. Problems may occur if these are inadequate ': določil uporabnik. Če ti niso ustrezni za te podatke, bo modeliranje neuspešno. + for the given data.: "" + class `Inputs`: + Data: Podatki + Preprocessor: Predprocesor + class `Outputs`: + Learner: Učni algoritem + Model: Model + Classifier: Klasifikator + Predictor: Prediktor + def `__init__`: + unconditional_apply: false + def `set_data`: + Data contains multiple target variables.\n: Podatki vsebujejo več ciljnih spremenljivk.\n + Select a single one with the Select Columns widget.: Izberite eno od njih z gradnikom Izberi stolpce. + Data has no target variable.\n: Podatki nimajo ciljne spremenljivke.\n + Select one with the Select Columns widget.: Izberete jo lahko z gradnikom Izberi stolpce. + def `handleNewSignals`: + use_default_preprocessors: false + preprocessors: false + def `check_data`: + Dataset is empty.: Tabela primerov je prazna + Data contains a single target value.: Podatki vsebujejo eno samo ciljno vrednost. + Data has no features to learn from.: Poatki nimajo spremenljivk za učenje. + def `send_report`: + Name: Ime + Model parameters: Parametri modela + Data: Podatki + def `setup_layout`: + Classification: Klasifikacija + Regression: Regresija + def `add_learner_name_widget`: + learner_name: false + Name: Ime + The name will identify this model in other widgets: Ime, pod katerim je algoritem viden v sledečih gradnikih + def `get_widget_description`: + outputs: false + inputs: false +widgets/utils/pathutils.py: + class `PathItem`: + def `from_dict`: + PathItem: false + type: false + AbsPath: false + path: false + VarPath: false + name: false + relpath: false + '{type_}: unknown type': false + AbsPath: false + VarPath: false + class `AbsPath`: + AbsPath: false + path: false + def `__new__`: + nt: false + /: false + def `as_dict`: + type: false + AbsPath: false + path: false + class `VarPath`: + VarPath: false + name: false + relpath: false + def `__new__`: + invalid relpath '{}': false + nt: false + /: false + def `as_dict`: + type: false + VarPath: false + name: false + relpath: false + def `prettyfypath`: + ~/: false + ~: false +widgets/utils/progressbar.py: + ProgressBarMixin: false + def `_warn_deprecated_arg`: + "'processEvents' argument is deprecated.\n": false + 'It does nothing and will be removed in the future (passing it ': false + will raise a TypeError).: false +widgets/utils/saveplot.py: + save_plot: false +widgets/utils/settings.py: + _T: false + def `QSettings_readArray`: + def `normalize_spec`: + len(spec) != 2: false +widgets/utils/signals.py: + Input: false + Output: false + InputSignal: false + OutputSignal: false + Single: false + Multiple: false + Default: false + NonDefault: false + Explicit: false + Dynamic: false + WidgetSignalsMixin: false + AttributeList: false +widgets/utils/slidergraph.py: + class `SliderGraph`: + def `__init__`: + bottom: false + left: false + def `_update_horizontal_lines`: + {:.3f}: false +widgets/utils/spinbox.py: + class `DoubleSpinBox`: + def `__init__`: + stepType: false + def `textFromValue`: + f: false + def `__adaptiveDecimalStep`: + 1.01: false + stepType: false + def `sizeHint`: + X: false + .: false + ' ': false + -: false +widgets/utils/sql.py: + Download (and sample if necessary) the SQL data first: false + def `check_sql_input`: + def `new_f`: + download_sql_data: false + def `check_sql_input_sequence`: + def `new_f`: + download_sql_data: false +widgets/utils/state_summary.py: + def `format_variables_string`: + —: true + categorical: kategorična + numeric: številska + time: časovna + string: besedilna + {i} {j}: {i} {plsi(i, dict(kategorična="kategorična|kategorični|kategorične|kategoričnih", številska="številska|številski|številske|številskih", besedilna="besedilna|besedilni|besedilne|besedilnih", časovna="časovna|časovni|časovne|časovnih")[j])} + {sum(counts)} ({", ".join(var_string)}): false + {counts[0]} {attrs[0]}: {counts[0]} {plsi(counts[0], dict(kategorična="kategorična|kategorični|kategorične|kategoričnih", številska="številska|številski|številske|številskih", besedilna="besedilna|besedilni|besedilne|besedilnih", časovna="časovna|časovni|časovne|časovnih")[attrs[0]])} + def `format_summary_details`: + name: false + untitled: nepoimenovano + '{len(data):n} {pl(len(data), "instance")}, ': '{len(data):n} {plsi(len(data), "primer")}, ' + {n_features} {pl(n_features, "variable")}: {n_features} {plsi(n_features, "spremenljivka")} + 'Features: {features}{features_missing}': Spremenljivke: {features} {features_missing} + 'Target: {targets}': Ciljna spremenljivka: {targets} + 'Metas: {metas}': Meta: {metas} + '{name}: ': true + 'Table with ': 'Tabela: ' + {basic}\n{features}\n{targets}: true + \n{metas}: true + '{escape(name)}: {basic}': true + Table with {basic}: Tabela: {basic} +
      : false + def `missing_values`: + ' ({value*100:.1f}% missing values)': ({value*100:.1f}% manjkajočih vrednosti) + ' (no missing values)': (brez manjkajočih vrednosti) + def `format_multiple_summaries`: + input: vhodu + def `new_line`: + \n: false +
      : false + No data on {type_io}.: Ni podatkov na {type_io} + {name}:
      {details}: false +
      : false + def `_name_of`: + name: false + def `_nobr`: + {s}: false + def `summarize_table`: + length: false + ?: false + missing: false + domain: false + data available, but not prepared yet: false + def `summarize_matrix`: + {w}×{h}: false + {w}×{h} distance matrix: {w}×{h} matrika razdalj + def `summarize_results`: + {nmethods}×{ninstances}: false + "{nmethods} {pl(nmethods, 'method')} ": "{nmethods} {plsi(nmethods, 'metoda')} " + on {ninstances} test {pl(ninstances, 'instance')}: na {ninstances} {plsi(ninstances, 'testnem primeru|testnih primerih|testnih primerih')} + def `summarize_attributes`: + empty list: prazen seznam + ', ': false + ' and {n - 2} others': ' in še {n - 2} {plsi(n - 2, "spremenljivka|spremenljivki|druge spremenljivke|drugih spremenljivk")}' + def `summarize_preprocessor`: +
      : false + {_name_of(preprocessor)} (empty): (brez predprocesorjev) + 🄿: false + ⛄: false + 🄼: false + 🄻: false + 🅂: false +widgets/utils/stdpaths.py: + {__name__} module is deprecated.: false +widgets/utils/stickygraphicsview.py: + StickyGraphicsView: false + class `_OverlayWidget`: + def `__init__`: + sticky-view-shadow: false + def `changeEvent`: + sticky-view-shadow: false + class `StickyGraphicsView`: + def `setupViewport`: + sticky-header-view: false + sticky-footer-view: false + sticky-header-overlay-container: false + sticky-footer-overlay-container: false + def `main`: + Zoom in: false + Zoom out: false + Reset: false + __main__: false +widgets/utils/tableview.py: + def `table_view_compact`: + X: false + class `TableView`: + def `__init__`: + horizontalScrollMode: false + verticalScrollMode: false + def `table_selection_to_mime_data`: + excel: false + utf-8: false + excel-tab: false + text/csv: false + text/tab-separated-values: false + text/plain: false + def `lines_to_csv_string`: + excel: false +widgets/utils/textimport.py: + ColumnType: false + RowSpec: false + CSVOptionsWidget: false + CSVImportWidget: false + _A: false + _B: false + class `Dialect`: + def `__init__`: + \r\n: false + def `__repr__`: + ', ': false + {!r}: false + Dialect(: false + ): false + class `ColumnType`: + Skip: Preskoči + Auto: true + Numeric: Numerično + Categorical: Kategorično + Text: Besedilo + Time: Čas + class `LineEdit`: + def `sizeHint`: + X: false + class `CSVOptionsWidget`: + Tab: Tabulator + \t: false + Comma: Vejica + ,: false + Semicolon: Podpičje + ;: false + Space: Presledek + ' ': false + def `__init__`: + ,: true + '|': true + \": true + selectedEncoding: false + utf-8: false + encoding-combo-box: false + Select file text encoding: Kodiranje datoteke + delimiter-combo-box: false + Select cell delimiter character.: Ločilo med celicami tabele + Other: Drugo + .: true + custom-delimiter-edit: false + quote-edit-combo-box: false + "'": true + Encoding: 'Kodiranje: ' + Cell delimiter: 'Ločilo med celicami: ' + Quote character: 'Narekovaj (za označevanje celic): ' + def `setSelectedEncoding`: + separator: false + def `encoding`: + latin-1: latin-2 + def `__show_encodings_widget`: + -encoding-selection-tool-window: false + Customize Encodings List: Izberi oblike kodiranja + def `__set_visible_codecs`: + ascii: false + Customize Encodings List...: Izberi oblike kodiranja... + class `CSVImportWidget`: + def `__init__`: + X: true + grouping-separator-combo-box: false + Thousands group separator: Ločilo med tisočicami + None: Brez + No separator: Ni ločila + .: false + ,: false + Space: Presledek + ' ': false + "'": false + (\.|,| |')?: false + decimal-separator-combo-box: false + Decimal separator: Ločilo za decimalni del + (\.|,): false + Grouping:: 'Skupine: ' + Decimal:: 'Decimalni del: ' + Number separators:: 'Ločila med števkami: ' + column-type-edit-combo-box: false + Auto: (prepoznaj) + 'The type will be determined automatically based ': 'Tip bo prepoznan samodejno ' + on column contents.: glede na vsebino + Numeric: Številka + Categorical: Kategorije + Text: Besedilo + Datetime: Datum/Ura + separator: false + Ignore: Izpusti + The column will not be loaded: Stolpca ni mogoče naložiti + 'Hint: right click on the row or column header for additional options.': Namig: desni klik na glavo vrstice ali stolpca pokaže dodatne možnosti. + Column type: Vrsta podatka + -error-overlay: false + -error-text-label: false + def `numbersFormat`: + group: false + decimal: false + def `__decimal_sep_activated`: + .: false + ,: false + def `__group_sep_activated`: + .: false + ,: false + def `__resetPreview`: + surrogateescape: false + def `__run_row_menu`: + Skip: Izpusti + Header: Glava + def `__run_type_columns_menu`: + separator: false + def `__setColumnType`: + group: false + decimal: false + def `is_surrogate_escaped`: + \udc80: false + \udcff: false + class `PreviewItemDelegate`: + def `initStyleOption`: + ...: false + def `icon_for_column_type`: + N: false + red: false + C: false + green: false + S: false + black: false + T: false + deepskyblue: false + class `ColumnValidateItemDelegate`: + def `validate`: + NA: false + Na: false + na: false + n/a: false + N/A: false + ?: false + .: false + def `number_parser`: + .: false + def `format_exception_csv`: + 'CSV parsing error: ': 'Napaka pri branju: ' + '\ + ,A,B,C,D +1,a,1,1, +2,b,2,2, +3,c,3,3, +4,d,4,4,,\ +': false + def `main`: + rb: false + __main__: false +widgets/utils/userinput.py: + T: false + def `numbers_from_list`: + (^|[^.])\.\.\.($|[^.]): false + def `_get_points`: + invalid value ({msg[msg.rindex(':') + 1:]}): nepravilna vrednost ({msg[msg.rindex(':') + 1:]}) + def `_numbers_from_no_dots`: + ...: false + ' ... ': false + ', ': false + ' ': false + value must be between {minimum} and {maximum}: vrednost mora biti med {minimum} in {maximum} + value must be at least {minimum}: vrednost mora biti vsaj {minimum} + value must be at most {maximum}: vrednost mora biti največ {maximum} + def `_numbers_from_dots`: + ...: false + ' ... ': false + ,: false + ' ': false + multiple '...'.: več '...' + values before '...' must be smaller than values after.: vrednosti pred '...' morajo biti manjše od vrednosti za '...' + at least two values are required before or after '...'.: pred ali za '...' morata biti vsaj dve vrednosti + points must be in uniform order.: razlike med vrednostmi morajo biti enake + points must be in increasing order.: vrednosti morajo biti naraščajoče + minimum value is missing.: manjka začetna vrednost + maximum value is missing.: manjka končna vrednost + minimum value is below the minimum {minimum}.: začetna vrednost je pod minimumom ({minimum}) + maximum value is above the maximum {maximum}.: končna vrednost je nad maksimumom ({maximum}) + the sequence before '...' does not end with the sequence after it.: zaporedje pred '...' se ne konča z zaporedjem za '...' +widgets/utils/webview.py: + WebviewWidget: false +widgets/utils/widgetpreview.py: + WidgetPreview: false +widgets/utils/localization/__init__.py: + pl: false +widgets/utils/plot/owpalette.py: + create_palette: false + OWPalette: false +widgets/utils/plot/owplotgui.py: + variables_selection: false + OrientedWidget: false + OWToolbar: false + StateButtonContainer: false + OWAction: false + OWButton: false + OWPlotGUI: false + class `VariableSelectionModel`: + def `mimeData`: + 'see properties: item_index': false + item_index: false + def `dropMimeData`: + item_index: false + class `VariablesDelegate`: + def `paint`: + ' Add ': ' Dodaj ' + ' Remove ': ' Odstrani ' + class `OWAction`: + def `__init__`: + ../../icons: false + .png: false + class `OWPlotGUI`: + def `__init__`: + (Same color): (Enaka barva) + (Same shape): (Enaka oblika) + (Same size): (Enaka velikost) + (No labels): (Brez oznak) + Zoom: Povečava + state: false + Dlg_zoom: false + Reset zoom: Ponastavi povečavo + Dlg_zoom_reset: false + Pan: Pomik + Dlg_pan_hand: false + Select: Izberi + Dlg_arrow: false + Add to selection: Dodaj k izboru + selection_behavior: false + Dlg_select_add: false + Remove from selection: Odstrani iz izbora + Dlg_select_remove: false + Toggle selection: Preklopi izbor + Dlg_select_toggle: false + Replace selection: Zamenjaj izbor + Send selection: Pošlji izbor + send_selection: false + Dlg_send: false + Clear selection: Počisti izbor + clear_selection: false + Dlg_clear: false + ShufflePoints: Premešaj točke + shuffle_points: false + Dlg_sort: false + Animate plot: Animiraj sliko + animate_plot: false + update_animations: false + Animate points: Animiraj točke + animate_points: false + Antialias plot: Zmehčaj sliko + antialias_plot: false + update_antialiasing: false + Antialias points: Zmehčaj točke + antialias_points: false + Antialias lines: Zmehčaj črte + antialias_lines: false + Disable effects for large datasets: Izključi grafične učinke za velike podatke + auto_adjust_performance: false + update_performance: false + def `antialiasing_check_box`: + use_antialiasing: false + Use antialiasing: Uporabi mehčanje + update_antialiasing: false + def `jitter_size_slider`: + 'Jittering: ': 'Tresenje: ' + jitter_size: false + jitter_sizes: false + def `jitter_numeric_check_box`: + jitter_continuous: false + Jitter numeric values: Pretresi številske vrednosti + update_jittering: false + def `show_legend_check_box`: + show_legend: false + Show legend: Pokaži legendo + update_legend_visibility: false + def `tooltip_shows_all_check_box`: + tooltip_shows_all: false + Show all data on mouse hover: Ob prehodu z miško pokaži vse podatke + def `class_density_check_box`: + class_density: false + Show color regions: Pokaži barvna področja + def `aggregate_points_check_box`: + aggregate_dense_regions: false + Aggregate points in dense regions: Združi točke v gostih regijah + 'Dense regions with many points are aggregated into ': 'Goste regije z veliko točkami so združene v ' + circles or pie charts,\n: kroge ali tortne diagrame,\n + unless data is jittered or labels, selection or subset is shown.: razen če so točke tresene ali pa so prikazane oznake, izbori ali podmnožice. + def `regression_line_check_box`: + show_reg_line: false + Show regression line: Pokaži regresijsko premico + def `label_only_selected_check_box`: + label_only_selected: false + Label only selection and subset: Označi samo izbor in podmnožico + def `filled_symbols_check_box`: + show_filled_symbols: false + Show filled symbols: Pokaži polne simbole + update_filled_symbols: false + def `grid_lines_check_box`: + show_grid: false + Show gridlines: Pokaži mrežo + update_grid_visibility: false + def `animations_check_box`: + use_animations: false + Use animations: Animiraj + update_animations: false + def `point_size_slider`: + 'Symbol size: ': 'Velikost točk: ' + point_width: false + sizes_changed: false + def `alpha_value_slider`: + 'Opacity: ': 'Neprosojnost: ' + alpha_value: false + colors_changed: false + def `color_value_combo`: + 'Color: ': 'Barva: ' + attr_color: false + colors_changed: false + def `shape_value_combo`: + 'Shape: ': 'Oblika: ' + attr_shape: false + shapes_changed: false + def `size_value_combo`: + 'Size: ': 'Velikost: ' + attr_size: false + sizes_changed: false + def `label_value_combo`: + 'Label: ': 'Oznaka: ' + attr_label: false + labels_changed: false + def `point_properties_box`: + Attributes: Izgled + def `plot_properties_box`: + aggregate_dense_regions: false + def `zoom_select_toolbar`: + Zoom / Select: Povečava / Izbor + def `theme_combo_box`: + theme_name: false + Theme: Tema + Default: Privzeta + Light: Svetla + Dark: Temna + def `box_zoom_select`: + Zoom/Select: Povečava/Izbor +widgets/utils/plot/owplotgui_obsolete.py: + class `AddVariablesDialog`: + def `__init__`: + Hidden Axes: false + Add: false + Cancel: false + class `VariablesSelection`: + def `__call__`: + sizePolicy: false + selectionMode: false + dragEnabled: false + defaultDropAction: false + dragDropOverwriteMode: false + dragDropMode: false + Displayed Axes: false + Delete: false + +: false + Add new class label: false + MINUS SIGN: false + Remove selected class label: false + class `OWAction`: + def `__init__`: + ../../icons: false + .png: false + class `OWPlotGUI`: + def `__init__`: + (Same color): false + (Same shape): false + (Same size): false + (No labels): false + Overlap: false + Zoom: false + state: false + Dlg_zoom: false + Reset zoom: false + Dlg_zoom_reset: false + Pan: false + Dlg_pan_hand: false + Select: false + Dlg_arrow: false + Add to selection: false + selection_behavior: false + Dlg_select_add: false + Remove from selection: false + Dlg_select_remove: false + Toggle selection: false + Dlg_select_toggle: false + Replace selection: false + Send selection: false + send_selection: false + Dlg_send: false + Clear selection: false + clear_selection: false + Dlg_clear: false + ShufflePoints: false + shuffle_points: false + Dlg_sort: false + Animate plot: false + animate_plot: false + update_animations: false + Animate points: false + animate_points: false + Antialias plot: false + antialias_plot: false + update_antialiasing: false + Antialias points: false + antialias_points: false + Antialias lines: false + antialias_lines: false + Disable effects for large datasets: false + auto_adjust_performance: false + update_performance: false + def `antialiasing_check_box`: + use_antialiasing: false + Use antialiasing: false + update_antialiasing: false + def `jitter_size_slider`: + jitter_sizes: false + jitter_size: false + 'Jittering: ': false + None: false + %.1f %%: false + %d %%: false + def `jitter_numeric_check_box`: + jitter_continuous: false + Jitter numeric values: false + def `show_legend_check_box`: + show_legend: false + Show legend: false + update_legend: false + def `tooltip_shows_all_check_box`: + tooltip_shows_all: false + Show all data on mouse hover: false + cb_tooltip_shows_all: false + def `class_density_check_box`: + class_density: false + Show class density: false + def `regression_line_check_box`: + show_reg_line: false + Show regression line: false + def `label_only_selected_check_box`: + label_only_selected: false + Label only selected points: false + def `filled_symbols_check_box`: + show_filled_symbols: false + Show filled symbols: false + update_filled_symbols: false + def `grid_lines_check_box`: + show_grid: false + Show gridlines: false + update_grid: false + def `animations_check_box`: + use_animations: false + Use animations: false + update_animations: false + def `point_size_slider`: + point_width: false + 'Symbol size: ': false + update_point_size: false + def `alpha_value_slider`: + alpha_value: false + 'Opacity: ': false + update_alpha_value: false + def `color_value_combo`: + attr_color: false + 'Color: ': false + update_colors: false + def `shape_value_combo`: + attr_shape: false + 'Shape: ': false + update_shapes: false + def `size_value_combo`: + attr_size: false + 'Size: ': false + update_sizes: false + def `label_value_combo`: + attr_label: false + 'Label: ': false + update_labels: false + def `point_properties_box`: + Points: false + def `plot_properties_box`: + Plot Properties: false + def `zoom_select_toolbar`: + Zoom / Select: false + def `effects_box`: + Visual effects: false + def `theme_combo_box`: + theme_name: false + Theme: false + Default: false + Light: false + Dark: false +widgets/utils/save/owsavebase.py: + ~{os.sep}: false + darwin: false + win32: false + class `OWSaveBase`: + class `Information`: + Empty input; nothing was saved.: Ni podatkov. + class `Warning`: + Auto save disabled.\n: Samodejno shranjevanje je onemogočeno.\n + 'Due to security reasons auto save is only restored for paths ': 'Zaradi varnostnih razlogov se samodejno shranjevanje obnovi samo za poti, ' + 'that are in the same directory as the workflow file or in a ': 'ki so v istem imeniku kot delotok ali v ' + subtree of that directory.: poddrevesu tega imenika. + class `Error`: + File name is not set.: Ime datoteke ni določeno. + File format is unsupported.\n{}: Format datoteke ni podprt.\n{} + {}: false + def `__init__`: + auto_save: false + Autosave when receiving new data: Samodejno shrani ob prejemu novih podatkov + Save as {self.stored_name}: Shrani kot {self.stored_name} + Save: Shrani + Save as ...: Shrani kot ... + def `last_dir`: + basedir: false + ..: false + def `_abs_path_from_setting`: + basedir: false + def `workflowEnvChanged`: + basedir: false + def `save_file_as`: + Save as {self.stored_name}: Shrani kot {self.stored_name} + def `_replace_extension`: + .: false + def `_extension_from_filter`: + .*\(\*?(\..*)\)$: false + def `migrate_settings`: + last_dir: false + stored_path: false + filename: false + stored_name: false + def `get_save_filename`: + Save File: Shrani datoteko + ;;: false +widgets/utils/tests/concurrent_example.py: + def `run`: + Calculating...: false + class `OWConcurrentWidget`: + Projection: false + concurrent, projection, example: false + def `_add_controls`: + param: false + Parameter:: false + Param A: false + Param B: false + Start: false + def `_toggle_run`: + Resume: false + def `_run`: + Stop: false + def `on_done`: + Start: false + __main__: false + iris: false +widgets/visualize/__init__.py: + Visualize: Vizualizacija + orange.widgets.visualize: false + Data visualization: Vizualizacija podatkov + '#FFB7B1': false + icons/Category-Visualize.svg: false +widgets/visualize/owbarplot.py: + class `ParameterSetter`: + Gridlines: Mreža + Show: Pokaži + Bottom axis: Spodnja os + Group axis: Os skupin + Vertical ticks: Navpične oznake + Hide empty categories in the legend: Skrij prazne kategorije v legendi + def `update_setters`: + def `update_bottom_axis`: + bottom: false + def `axis_items`: + item: false + class `BarPlotGraph`: + def `__init__`: + bottom: false + left: false + def `update_axes`: + left: false + bottom: false + def `reset_view`: + height: false + def `select_by_rectangle`: + x: false + height: false + def `__get_index_at`: + height: false + def `__select_bars`: + height: false + class `OWBarPlot`: + Bar Plot: Palice + Visualizes comparisons among categorical variables.: Vizualna primerjava kategoričnih spremenljivk. + icons/BarPlot.svg: false + bar plot, chart: bar plot, chart, stolpčni diagram, grafikon + class `Inputs`: + Data: Podatki + Data Subset: Podmnožica podatkov + class `Outputs`: + Selected Data: Izbrani podatki + graph.plotItem: false + class `Error`: + Plotting requires a numeric feature.: Podatki ne vsebujejo številskih spremenljivk. + class `Information`: + Data has too many instances. Only first {}: Podatki vsebujejo preveč primerov. Kažem samo prvih {}. + ' are shown.': "" + Enumeration: Zaporedne številke + def `_add_controls`: + selected_var: false + Values:: Vrednosti: + None: (Brez) + group_var: false + Group by:: Skupine: + annot_var: false + Annotations:: Oznake: + (Same color): (Enaka barva) + color_var: false + Color:: Barva: + show_legend: false + Show legend: Pokaži legendo + auto_commit: false + def `grouped_indices`: + mergesort: false + def `init_attr_values`: + selected_var: false + group_var: false + annot_var: false + color_var: false + def `get_tooltip`: +
      : false + {} = {}: false + {}

      {}: false + __main__: false + iris: false +widgets/visualize/owboxplot.py: + ContDataRange: false + low: false + high: false + group_value: false + DiscDataRange: false + value: false + class `BoxData`: + def `__init__`: + midpoint: false + class `OWBoxPlot`: + Box Plot: Škatla z brki + Visualize the distribution of feature values in a box plot.: Prikaži porazdelitev vrednosti spremenljivke v škatli z brki. + icons/BoxPlot.svg: false + box plot, whisker: box plot, whisker + class `Inputs`: + Data: Podatki + class `Outputs`: + Selected Data: Izbrani podatki + class `Warning`: + Data contains no categorical or numeric variables: Podatki ne vsebujejo kategoričnih ali številskih spremenljivk. + median: false + mean: false + box_scene: false + def `__init__`: + Variable: Spremenljivka + order_by_importance: false + Order by relevance to subgroups: Uredi glede na različnost po skupinah + Order by 𝜒² or ANOVA over the subgroups: Uredi glede na vrednost 𝜒² ali ANOVA med skupinami + None: Brez + Subgroups: Skupine + order_grouping_by_importance: false + Order by relevance to variable: Uredi glede na pomembnost spremenljivke + Order by 𝜒² or ANOVA over the variable values: Uredi glede na vrednost 𝜒² ali ANOVA za vrednosti spremenljivke + Display: Prikaz + show_annotations: false + Annotate: Oznake + compare: false + No comparison: Brez primerjanja + Compare medians: Primerjaj srednje vrednosti + Compare means: Primerjaj povprečja + stretched: false + Stretch bars: Raztegni črte + show_labels: false + Show box labels: Pokaži oznake črt + sort_freqs: false + Sort by subgroup frequencies: Uredi po pogostosti skupin + def `reset_attrs`: + hidden: false + def `reset_groups`: + hidden: false + def `compute_box_data`: + missing '{self.group_var.name}': (neznana vrednost) + def `_display_changed_disc`: + {int(sum(cont))}: false + def `_compute_tests_cont`: + 'At least one group has just one instance, ': 'Vsaj ena skupina ima zgolj en primer, ' + cannot compute significance: zato ne morem izračunati pomembnosti. + "Student's t: {t:.3f} (p={p:.3f}, N={n})": Studentov t-test: {t:.3f} (p={p:.3f}, N={n}) + 'ANOVA: {F:.3f} (p={p:.3f}, N={n})': true + def `_compute_tests_disc`: + 'χ²: {chi:.2f} (p={p:.3f}, dof={dof})': true + def `mean_label`: + ' \u00b1 ': true + %.*f: true + ': ': true + def `draw_axis`: + ?: true + def `draw_axis_disc`: + the callee must ensure this!: false + def `strudel`: + missing '{attr.name}': (neznana vrednost) + add_lpad: false + add_rpad: false + '{value}: {100 * freq / total:.2f}%': true + '{value}: ({int(freq)})': true + def `_show_posthoc`: + median: false + mean: false + def `send_report`: + "Box plot for attribute '{self.attribute.name}' ": "Škatla z brki za spremenljivko '{self.attribute.name}' " + grouped by '{self.group_var.name}': urejeno po '{self.group_var.name}' + __main__: false + heart_disease.tab: false +widgets/visualize/owdistributions.py: + class `ScatterPlotItem`: + def `paint`: + pxMode: false + antialias: false + class `ParameterSetter`: + Hide empty categories in the legend: V legendo ne vključi praznih kategorij + def `axis_items`: + item: false + class `AshCurve`: + def `pdf`: + same: false + class `ElidedAxisNoUnits`: + def `labelString`: + ;: true + '{k}: {v}': true + {self.labelText}: false + class `OWDistributions`: + Distributions: Porazdelitve + Display value distributions of a data feature in a graph.: Pokaži porazdelitev vrednosti spremenljivke. + icons/Distribution.svg: false + distributions, histogram: distributions, histogram + class `Inputs`: + Data: Podatki + Set the input dataset: Določi vhodne podatke + class `Outputs`: + Selected Data: Izbrani podatki + Histogram Data: Podatki o porazdelitvi + class `Error`: + Variable '{}' does not have any defined values: Spremenljivke '{}' nima definiranih vrednosti. + No data instances with '{}' and '{}' defined: Nobena vrstica nima znanih vrednosti za '{}' in '{}'. + class `Warning`: + Data instances with missing values are ignored: Podatki z neznanimi vrednostmi so izločeni. + plot: false + None: (brez) + Normal: Normalna + loc: false + scale: false + μ: true + σ: true + Beta: true + a: false + b: false + α: true + β: true + -loc: false + -scale: false + Gamma: Gama + θ: false + Rayleigh: true + Pareto: true + Exponential: Eksponentna + λ: true + Kernel density: Jedra + def `__init__`: + var: false + Variable: Spremenljivka + sort_by_freq: false + Sort categories by frequency: Uredi kategorije po velikosti + Distribution: Porazdelitev + fitted_distribution: false + Fitted distribution: Prilagodi porazdelitev + number_of_bins: false + Bin width: Širina koša + kde_smoothing: false + Smoothing: Glajenje + hide_bars: false + Hide bars: Skrij stolpce + Columns: Stolpci + cvar: false + Split by: Razdeli po + (None): (Ne deli) + stacked_columns: false + Stack columns: Naloži stolpce + show_probs: false + Show probabilities: Pokaži verjetnosti + cumulative_distr: false + Show cumulative distribution: Pokaži kumulativno porazdelitev + show_legend: false + Show legend: Pokaži legendo + def `_setup_plots`: + def `add_new_plot`: + right: false + bottom: false + left: false + def `_on_show_probabilities_changed`: + Fitted probability: Prilagojena verjetnost + Chosen distribution is used to compute Bayesian probabilities: Izbrana porazdelitev je uporabljena za izračun Bayesovkih verjenosti + Fitted distribution: Prilagojena porazdelitev + def `_set_axis_names`: + bottom: false + left: false + Probability of '{self.cvar.name}' at given '{self.var.name}': Verjetnost '{self.cvar.name}' pri podani '{self.var.name}' + Frequency: Pogostost + def `_disc_plot`: + bottom: false +

      : true + '{escape(desc)}: {int(freq)} ': true + '({100 * freq / len(self.valid_data):.2f} %) ': true + def `_disc_split_plot`: + bottom: false + def `_cont_plot`: +

      : true + '{escape(desc)}: ': true + {freq} ({100 * freq / total:.2f} %)

      : true + def `_set_cont_ticks`: + bottom: false + def `_fit_approximation`: + def `join_pars`: + ', ': true + {sname}={strv(val)}: true + def `str_params`: + -: true + ' ({par})': true + def `_set_curve_brushes`: + pen: false + def `_split_tooltip`: + 'white-space:pre; text-align: right;': true + "style='{cs} padding-left: 1em'": true + style='{cs}': false + "": true + : true + : true + : true + : true + : true + : true + : true + : true + : true + : true +
      {escape(valname)}:{int(tot_group)}: true + {100 * tot_group / total:.2f} %
      (in group)(overall)
      {value}:{int(freq)}{100 * freq / div_group:.2f} %{100 * freq / total:.2f} %
      : true + def `_display_legend`: + s: false + ' ({desc})': true + def `str_int`: + {var.name} < {sx1}: true + {var.name} = {sx0}: true + {var.name} ≥ {sx0}: true + {sx0} ≤ {var.name} < {sx1}: true + def `show_selection`: +

      : true + '{escape(valname)}: ': true + {inside} ({100 * inside / total:.2f} %): true + def `migrate_context`: + selection: false + selected_bars: false + def `apply`: + Bin: Koš + def `_get_histogram_table`: + Bin: Koš + Count: Število + def `_get_cont_baritem_indices`: + ignore: false + def `send_report`: + Cummulative distribution of '{self.var.name}': Kumulativna porazdelitev '{self.var.name}' + Distribution of '{self.var.name}': Porazdelitev '{self.var.name}' + " with columns split by '{self.cvar.name}'": " s stolpci, razdeljenimi po '{self.cvar.name}'" + __main__: false + heart_disease.tab: false +widgets/visualize/owfreeviz.py: + def `run_freeviz`: + Calculating...: Računam... + class `InitType`: + def `items`: + Circular: Krožna + Random: Naključna + class `OWFreeViz`: + FreeViz: Prostoskop + Displays FreeViz projection: Pokaže projekcijo s Prostoskopom. + icons/Freeviz.svg: false + freeviz, viz: freeviz, viz + class `Error`: + Data must have a target variable.: Podatki morajo imeti ciljno spremenljivko. + Data must have a single target variable.: Podatki morajo imeti eno ciljno spremenljivko. + Target variable must have at least two unique values.: Ciljna spremenljivka mora imeti vsaj dve različni vrednosti. + Number of features exceeds the number of instances.: Število spremenljivk presega število primerov. + Data is too large.: Podatkov je preveč. + All data columns are constant.: Vsi stolpci so konstantni. + At least two features are required.: Potrebni sta vsaj dve spremenljivki. + class `Warning`: + Categorical features with more than: Kategorične spremenljivke z več kot dvema + ' two values are not shown.': ' vrednostima niso prikazane.' + def `_add_controls`: + Hide radius:: Skriti krog: + hide_radius: false + def `__add_controls_start_box`: + Optimize: Izris + initialization: false + Initialization:: Začetna pozicija: + balance: false + Gravity: Gravitacija + gravity_index: false + Start: Začni + def `_toggle_run`: + Resume: Nadaljuj + def `_run`: + Stop: Stoj + def `on_done`: + Start: Začni + def `on_exception`: + Start: Začni + def `enable_controls`: + Start: Začni + def `migrate_settings`: + radius: false + graph: false + hide_radius: false + def `migrate_context`: + attr_color: false + graph: false + attr_size: false + attr_shape: false + attr_label: false + __main__: false + zoo: false +widgets/visualize/owheatmap.py: + def `split_domain`: + N/A: NN + None: Brez + No clustering: Brez gručenja + Clustering: Gručenje + Apply hierarchical clustering: Uporabi hierarhično gručenje + Clustering (opt. ordering): Gručenje z urejanjem + 'Apply hierarchical clustering with optimal leaf ': Uporabi hierarhično gručenje z urejanjem listov. + ordering.: "" + Top: Zgoraj + Bottom: Spodaj + Top and Bottom: Zgoraj in spodaj + class `OWHeatMap`: + Heat Map: Topla greda + Plot a data matrix heatmap.: Pokaži podatke v topli gredi. + icons/Heatmap-symbolic.svg: false + heat map: heat map + class `Inputs`: + Data: Podatki + class `Outputs`: + Selected Data: Izbrani podatki + scene: false + class `Information`: + Data has been sampled: Podatki so vzorčeni. + Categorical features are ignored.: Kategoričnih spremenljivk ne kažem. + {}: false + Showing this data may require a lot of memory: Prikaz teh podatkov lahko zahteva veliko pomnilnika. + class `Error`: + No numeric features: Ni številskih spremenljivk. + Not enough features for column clustering: Ni dovolj spremenljivk za gručenje stolpcev. + Not enough instances for clustering: Ni dovolj podatkov za gručenje vrstic. + Not enough instances for k-means merging: Ni dovolj podatkov za združevanje. + Not enough memory to show this data: Premalo pomnilnika za prikaz podatkov. + class `Warning`: + Empty clusters were removed: Prazne gruče so odstranjene. + 'For data with a meaningful mid-point, ': 'Za podatke, ki imajo nevtralno, sredinsko vrednost ' + choose one of diverging palettes.: izberite divergentno barvno paleto. + diverging_palette: false + def `__init__`: + Color: Barva + Merge: Združevanje + merge_kmeans: false + Merge by k-means: Združi v skupine (k-means) + merge_kmeans_k: false + Clusters:: Skupin: + Clustering: Gručenje + row_clustering: false + col_clustering: false + Rows:: Vrstice: + Columns:: Stolpci: + Split By: Razdelitev + (None): (Brez) + Split the heatmap vertically by a categorical column: Navpična delitev glede na kategorično spremenljivko + split_by_var: false + Split the heatmap horizontally by column annotation: Vodoravna delitev glede na oznake stolpcev + split_columns_var: false + Annotation && Legends: Oznake in legenda + legend: false + Show legend: Pokaži legendo + averages: false + Stripes with averages: Trak s poprečji + Row Annotations: Oznake vrstic + annotation_var: false + annotation_color_var: false + Text: Besedilo + Column annotations: Oznake stolpcev + column_annotation_color_var: false + column_label_pos: false + Position: Položaj + keep_aspect: false + Keep aspect ratio: Ohrani razmerje + Resize: Povečava + auto_commit: false + Increase Font: Povečaj pisavo + ctrl+>: true + Decrease Font: Zmanjšaj pisavo + ctrl+<: true + setShortcutVisibleInContextMenu: false + def `set_dataset`: + hidden: false + def `_make_parts`: + N/A: NN + def `cluster_rows`: + Parts: false + def `cluster_columns`: + Parts: false + def `construct_heatmaps`: + Parts: false + def `construct_heatmaps_scene`: + Parts: false + _T: false + def `__update_clustering_enable_state`: + Parts: false + 'Row cluster ordering was disabled due to the ': Urejanje gruč vrstic + estimated runtime cost: ' je ustavljeno, ker bi zahtevalo preveč časa.' + 'Row clustering was was disabled due to the ': Gručenje vrstic + 'Column cluster ordering was disabled due to ': Urejanje gruč stolpcev + 'Column clustering was disabled due to the ': Gručenje stolpcev + def `update_annotations`: + ', ': false + ' ({} more)': ' (in še {})' + def `_on_view_context_menu`: + Keep aspect ratio: false + def `send_report`: + Columns:: Stolpci + Clustering: Gručenje + No sorting: Brez urejanja + Rows:: Vrstice + Split:: Delitev + Row annotation: Oznake vrstic + def `migrate_settings`: + row_clustering: false + col_clustering: false + row_clustering_method: false + col_clustering_method: false + def `join_elided`: + ...: true + def `colorize`: + N/A: NN + def `aggregate`: + ', ': false + ' ({} more)': ' (in še {})' + def `agg_join_str`: + ' ({} more)': ' (in še {})' + ', ': false + _T: false + __main__: false + brown-selected.tab: false +widgets/visualize/owlinearprojection.py: + class `OWLinProjGraph`: + def `update_anchors`: + {label}: false + ...: true + Placement: false + class `OWLinearProjection`: + Linear Projection: Linearna projekcija + 'A multi-axis projection of data onto ': 'Projekcija večdimenzionalnih podatkov ' + a two-dimensional plane.: v dve dimenziji. + icons/LinearProjection-symbolic.svg: false + linear projection: linear projection + Circular Placement: Krožna postavitev + Linear Discriminant Analysis: Linearna diskriminantna analiza + Principal Component Analysis: Analiza osnovnih komponent + class `Error`: + Plotting requires numeric features: Podatki ne vsebujejo številskih spremenljivk. + class `Information`: + LDA placement is disabled due to unsuitable target.\n{}: Razporejanje z LDA je izključeno, ker ni primerne ciljne spremenljivke.\n{} + def `_add_controls`: + Features: Spremenljivke + Hide radius:: Skriti krog + hide_radius: false + def `_add_controls_variables`: + Suggest Features: Predlagaj spremenljivke + def `_add_controls_placement`: + placement: false + def `_add_buttons`: + auto_commit: false + def `store_vizrank_n_attrs`: + n_attrs: false + def `_check_options`: + Current data has no target variable: Podatki nimajo ciljne spremenljivke. + {class_var.name} is not categorical: {class_var.name} ni kategorična spremenljivka. + Data has no defined values for {class_var.name}: Podatki nimajo definiranih vrednosti za {class_var.name}. + ' and ': ' in ' + "'{class_var.values[int(i)]}'": false + "Data contains just {['one', 'two'][nclasses - 1]} distinct ": "Podatki vsebujejo le {plsi(nclasses - 1, 'eno vrednost|dve vrednosti|')} " + "{pl(nclasses, 'value')} ({vals}) for '{class_var.name}'; ": "spremenljivke '{class_var.name}'; " + at least three are required.: LDA potrebuje vsaj tri. + def `init_vizrank`: + There is no data.: Ni podatkov. + Color variable has to be selected: Potrebno je izbrati spremenljivko za barvo. + 'Suggest Features does not work for Linear ': 'Predlaganje spremenljivk ne deluje ' + 'Discriminant Analysis Projection when ': 'za diskriminantno analizo pri barvanju ' + continuous color variable is selected.: s številsko spremenljivko. + Not enough available continuous variables: Ni dovolj številskih spremenljivk. + Not enough valid data instances: Ni dovolj podatkov. + def `init_projection`: + eigen: false + def `_get_send_report_caption`: + Projection: Projekcija + Color: Barva + Label: Oznake + Shape: Oblika + Size: Velikost + Jittering: Tresenje + {} %: false + def `migrate_settings`: + point_width: false + point_size: false + jitter_size: false + jitter_value: false + alpha_value: false + class_density: false + graph: false + radius: false + hide_radius: false + selection_indices: false + selection: false + placement: false + def `migrate_context`: + color_index: false + attr_color: false + shape_index: false + attr_shape: false + size_index: false + attr_size: false + graph: false + attr_label: false + selected_vars: false + __main__: false + iris: false +widgets/visualize/owlineplot.py: + class `ParameterSetter`: + Mean: Srednja vrednost + Lines: Črte + Lines (missing value): Črte (manjkajoče vrednosti) + Selected lines: Izbrane črte + Selected lines (missing value): Izbrane črte (manjkajoče vrednosti) + Range: Razpon + Selected range: Razpon izbora + def `update_setters`: + Dash line: Prekinjena črta + def `axis_items`: + item: false + class `LinePlotGraph`: + def `__init__`: + bottom: false + left: false + def `update_legend`: + s: false + def `reset`: + bottom: false + class `ProfileGroup`: + def `update_profiles_color`: + pen: false + def `update_sel_profiles_color`: + pen: false + class `OWLinePlot`: + Line Plot: Črtni diagram + Visualization of data profiles (e.g., time series).: Vizualizacija profilov podatkov, na primer časovnih vrst. + icons/LinePlot.svg: false + line plot: line plot + class `Inputs`: + Data: Podatki + Data Subset: Podmnožica podatkov + class `Outputs`: + Selected Data: Izbrani podatki + graph.plotItem: false + class `Error`: + Need at least one numeric feature.: Potrebna je vsaj ena številska spremenljivka. + class `Warning`: + No display option is selected.: Izberite vsaj en element za prikaz. + class `Information`: + Data has too many features. Only first {}: Podatki imajo preveč spremenljivk. Prikazanih je prvih {}. + ' are shown.': "" + def `_add_controls`: + Display: Prikaz + show_profiles: false + Lines: Črte + Plot lines: Pokaži črte. + show_range: false + Range: Razpon + Plot range between 10th and 90th percentile: Pokaži razpon med 10. in 90. percentilom. + show_mean: false + Mean: Srednja vrednost + Plot mean curve: Črta s poprečno vrednostjo. + show_error: false + Error bars: Standardna deviacija + Show standard deviation: Pokaži standardno deviacijo. + None: (Brez skupin) + group_var: false + Group by: Skupine + auto_commit: false + def `__show_profiles_changed`: + profiles: false + def `__show_range_changed`: + range: false + def `__show_mean_changed`: + mean: false + def `__show_error_changed`: + error: false + def `setup_plot`: + bottom: false + def `__get_visibility_flags`: + show_profiles: false + show_range: false + show_mean: false + show_error: false + def `_update_visibility`: + set_visible_{}: false + def `send_report`: + Group by: Skupine glede na + __main__: false + brown-selected: false +widgets/visualize/owmosaic.py: + class `MosaicVizRank`: + def `__init__`: + 'Score Mosaics with ': Oceni Mozaike + a single variable: z eno spremenljivko + two variables: z dvema spremenljivkama + three variables: s tremi spremenljivkami + four variables: s štirimi spremenljivkami + at most two variables: z največ dvema spremenljivkama + at most three variables: z največ tremi spremenljivkami + at most four variables: z največ štirimi spremenljivkami + def `on_attrs_changed`: + Restart with new settings: Poženi z novimi nastavitvami + def `score_attributes`: + name: false + def `compute_score`: + -: false + def `row_for_state`: + ', ': false + def `emit_run_state_changed`: + attr_range_index: false + class `OWMosaicDisplay`: + Mosaic Display: Mozaik + Display data in a mosaic plot.: Pokaži mozaik za podatke. + icons/MosaicDisplay.svg: false + mosaic display: mosaic display + class `Inputs`: + Data: Podatki + Data Subset: Podmnožica podatkov + class `Outputs`: + Selected Data: Izbrani podatki + canvas: false + class `Warning`: + Data subset is incompatible with Data: Podmnožica podatkov ni združljiva z vhodnimi podatki. + No valid data: Ni uporabnih podatkov. + Selection of numeric features on SQL is not supported: Izbor številskih podatkov iz SQL ni podprt. + def `__init__`: + (None): (Prazno) + variable{}: false + Find Informative Mosaics: Poišči informativne mozaike + Interior Coloring: Barva notranjosti + (Pearson residuals): (Pearsonovi residuali) + variable_color: false + use_boxes: false + Compare with total: Primerjaj s celoto + def `store_vizrank_attr_range`: + attr_range_index: false + def `init_vizrank`: + Not enough data: Ni dovolj podatkov + def `update_graph`: + def `get_counts`: + -: true + def `draw_data`: + -: true + '{}    {}: {}
      ': false + def `add_rect`: +


      : false + 'Expected instances: %.1f
      ': Pričakovanih primerov: %.1f
      + 'Actual instances: %d
      ': Dejanskih primerov: %d
      + 'Standardized (Pearson) residual: %.1f': Standardiziran (Pearsonov) residual: %.1f + -: true +
      : false + '%s: %d / %.1f%% (Expected %.1f / %.1f%%)': %s: %d / %.1f%% (Pričakovano %.1f / %.1f%%) + '{}
      Instances: {}

      {}': {}
      Primerov: {}

      {} + def `create_legend`: + <-8: false + -8:-4: false + -4:-2: false + -2:2: false + 2:4: false + 4:8: false + >8: false + Residuals:: Residuali: + Feature {} has no values: Spremenljivka {} nima vrednosti + def `migrate_context`: + (None): false + def `get_conditional_distribution`: + COUNT(*): false + ?: false + None: false + -: false + __main__: false + zoo: false +widgets/visualize/ownomogram.py: + class `SortBy`: + def `items`: + Original order: Originalni + Alphabetically: Abecedni + Absolute importance: Absolutna pomembnost + Positive influence: Pozitivni vpliv + Negative influence: Negativni vpliv + class `DotItem`: + 'ul {margin-top: 1px; margin-bottom: 1px;}': false + ' + {}
      {} + ': false + class `ProbabilitiesDotItem`: + def `get_tooltip_text`: + 'Total: {}
      Probability: {:.0%}': Vsota: {}
      Verjetnost: {:.0%} + class `DiscreteMovableDotItem`: + def `get_tooltip_text`: + 'Points: {}': Točke: {} + '{}: {:.0%}
      ': false + def `_get_tooltip_labels_with_percentages`: + <: false + <: false + class `GraphicsColorAnimator`: + def `__init__`: + brushColor: false + class `ContinuousItemMixin`: + def `get_tooltip_text`: + 'Points: {}': Točke: {} + 'Value: {}': Vrednost: {} + class `ProbabilitiesRulerItem`: + def `__init__`: + Total: Vsota + ' ': false + class `OWNomogram`: + Nomogram: true + ' Nomograms for Visualization of Naive Bayesian': 'Nomogram za prikaz ' + ' and Logistic Regression Classifiers.': naivnega Bayesovega modela in logistične regresije. + icons/Nomogram.svg: false + nomogram: nomogram + class `Inputs`: + Classifier: Model + Data: Podatki + class `Outputs`: + Features: Značilke + scene: false + class `Error`: + 'Nomogram accepts only Naive Bayes and ': 'Nomogram sprejema zgolj naivni Bayesov ' + Logistic Regression classifiers.: klasifikator in logistično regresijo. + def `__init__`: + target_class_index: false + 'Target class: ': 'Ciljni razred: ' + normalize_probabilities: false + Normalize probabilities: Normiraj verjetnosti + For multiclass data 1 vs. all probabilities do not: Za večrazredne podatke 'eden proti vsem' se posamične napovedane verjetnosti + ' sum to 1 and therefore could be normalized.': ' ne seštejejo v 1.' + 'margin-bottom: 12px': false + scale: false + 'Scale: ': 'Lestvica: ' + Point scale: Točkovna lestvica + Log odds ratios: Razmerje logaritmov obetov + Displayed features: Prikazane spremenljivke + sort_index: false + Do not sort features, display them in original order: Pokaži spremenljivke v izvirnem vrstnem redu + Sort features alphabetically by name: Uredi spremenljivke abecedno po imenu + Sort features by absolute importance: Uredi spremenljivke po absolutni pomembnosti + Sort features by positive influence on the class: Uredi spremenljivke po pozitivnem vplivu na razred + Sort features by negative influence on the class: Uredi spremenljivke po negativnem vplivu na razred + 'Order: ': 'Vrstni red: ' + display_index: false + All features: Vse značilke + Best ranked:: Prvih: + n_attributes: false + 'Show: ': 'Pokaži: ' + cont_feature_dim_index: false + Numeric features:: Številske spremenljivke: + 1D projection: 1D projekcija + 2D curve: 2D krivulja + def `_class_combo_changed`: + ignore: false + def `update_scene`: + Points: Točke + Probabilities (%): Verjetnosti (%) + def `create_footer_nomogram`: + {}='{}': false + def `reconstruct_domain`: + variable: false + def `reset_settings`: + ignore: false + combo box 'target_class_index' is empty: false + __main__: false + heart_disease: false +widgets/visualize/owpythagorastree.py: + class `OWPythagorasTree`: + Pythagorean Tree: Pitagorovo drevo + Pythagorean Tree visualization for tree like-structures.: Prikaz drevesne strukture s Pitagorovim drevesom. + icons/PythagoreanTree-symbolic.svg: false + pythagorean tree, fractal: pythagorean tree, fractal, fraktal + class `Inputs`: + Tree: Drevo + class `Outputs`: + Selected Data: Izbrani podatki + scene: false + corner: false + offset: false + def `__init__`: + Normal: Sorazmerna + Square root: Kvadratni koren + Logarithmic: Logaritmična + Tree Info: Podatki o drevesu + Display Settings: Nastavitve prikaza + depth_limit: false + Depth: Globina + target_class_index: false + Target class: Ciljni razred: + size_calc_idx: false + Size: Velikost: + size_log_scale: false + Log scale factor: Logaritmični faktor + Plot Properties: Lastnosti prikaza + tooltips_enabled: false + Enable tooltips: Kaži namige pod miško + show_legend: false + Show legend: Pokaži legendo + Redraw: Ponovno izriši + def `set_tree`: + meta_target_class_index: false + meta_size_calc_idx: false + meta_depth_limit: false + def `_update_info_box`: + 'Nodes: {}\nDepth: {}': Število vozlišč: {}, globina: {} + def `_update_log_scale_slider`: + Logarithmic: Logaritmična + def `_clear_info_box`: + No tree on input: Na vhodu ni drevesa. + def `_update_target_class_combo`: + Target class: Ciljni razred: + None: (Vsi razredi) + Node color: Barva: + def `_classification_update_legend_colors`: + other: false + '#ffffff': false + __main__: false + iris: false +widgets/visualize/owpythagoreanforest.py: + ' + +': false + class `PythagoreanForestModel`: + def `data`: + tree: false + scene: false + def `update_tree_views`: + tree: false + class `PythagorasTreeDelegate`: + def `paint`: + '#ebebeb': false + class `OWPythagoreanForest`: + Pythagorean Forest: Pitagorov gozd + Pythagorean forest for visualising random forests.: Prikaz naključnega gozda s Pitagorovimi drevesi. + icons/PythagoreanForest-symbolic.svg: false + pythagorean forest, fractal: pythagorean forest, fractal, fraktal, drevesa + class `Inputs`: + Random Forest: Naključni gozd + Random forest: Naključni gozd + class `Outputs`: + Tree: Drevo + Normal: Sorazmerna + Square root: Kvadratni koren + Logarithmic: Logaritmična + def `migrate_settings`: + selected_tree_index: false + zoom: false + def `__init__`: + Forest: Gozd + Display: Prikaz + depth_limit: false + Depth: Globina: + target_class_index: false + Target class: Ciljni razred: + size_calc_idx: false + Size: Velikost: + zoom: false + Zoom: Povečava + def `_update_info_box`: + 'Trees: {}': Drevesa: {} + def `_update_target_class_combo`: + Target class: Ciljni razred: + None: (Vsi razredi) + Node color: Barva: + def `_clear_info_box`: + No forest on input.: Na vhodu ni gozda. + def `send_report`: + def `item_html`: + utf-8: false + ': false +
      : false +
      : false +
      : false +

      . . .

      : false + __main__: false + iris: false +widgets/visualize/owradviz.py: + class `RadvizVizRank`: + def `__init__`: + 'Maximum number of variables: ': 'Največje število spremenljivk: ' + def `compute_score`: + ignore: false + class `OWRadvizGraph`: + def `update_anchors`: + {label}: false + ' ': false + \n: false + ...: false + class `OWRadviz`: + Radviz: Radviz + Display Radviz projection: Projekcija z Radvizom. + icons/Radviz-symbolic.svg: false + radviz, viz: radviz, viz + class `Warning`: + Categorical variables with more than two values are not shown.: Kategorične spremenljivke z več kot dvema vrednostima niso prikazane. + Maximum number of selected variables reached.: Doseženo je največje možno število izbranih spremenljivk. + def `_add_controls`: + Features: Spremenljivke + Suggest features: Predlagaj spremenljivke + def `_add_buttons`: + auto_commit: false + def `store_vizrank_n_attrs`: + n_attrs: false + def `init_vizrank`: + No data: Ni podatkov. + Not enough variables: Ni dovolj spremenljivk. + Color is not set.: Barva ni nastavljena. + No rows with defined color variable: Ni vrstic z definirano vrednostjo {self.attr_color.name}. + Not enough rows without missing data: Ni dovolj vrstic brez manjkajočih podatkov. + Constant data: Konstantni podatki. + def `_send_components_metas`: + angle: false + def `migrate_context`: + attr_color: false + graph: false + attr_size: false + attr_shape: false + attr_label: false + selected_vars: false + __main__: false + brown-selected: false +widgets/visualize/owruleviewer.py: + class `OWRuleViewer`: + CN2 Rule Viewer: Pregledovalnik pravil CN2 + Review rules induced from data.: Pregled pravil, sestavljenih iz podatkov. + icons/CN2RuleViewer.svg: false + cn2 rule viewer: cn2 rule viewer + class `Inputs`: + Data: Podatki + Classifier: Klasifikator + class `Outputs`: + Selected Data: Pokriti primeri + def `__init__`: + IF conditions: Pogoj + THEN class: Napoved + Distribution: Porazdelitev + Probabilities [%]: Verjetnosti [%] + Quality: Kvaliteta + Length: Dolžina + compact_view: false + Compact view: Stisnjen pregled + Restore original order: Izvirni vrstni red + def `set_classifier`: + rule_list: false + def `copy_to_clipboard`: + \n: false + def `send_report`: + Induced rules: Pravila + class `CustomRuleViewerTableModel`: + ==: false + =: false + !=: false + ≠: false + <=: false + ≤: false + >=: false + ≥: false + def `data`: + def `_display_role`: + ' AND ': ' IN ' + ' AND\n': ' IN \n' + TRUE: Sicer + →: false + =: false + .1f: false + ' : ': false + {:.{}{}}: false + f: false + e: false + def `_tooltip_role`: + ' AND ': ' IN ' + ' AND\n': ' IN\n' + \n: false + ': ': false + {:.1f}: false + %: false + __main__: false + iris: false +widgets/visualize/owscatterplot.py: + class `ParameterSetter`: + def `axis_items`: + item: false + def `reg_line_label_items`: + label: false + class `OWScatterPlotGraph`: + def `_regression_line`: + r = {rvalue:.2f}: true + def `_add_line`: + label: false + def `update_reg_line_label_colors`: + label: false + def `_update_curve`: + '#505050': true + def `update_error_bars`: + '#505050': true + class `OWScatterPlot`: + Scatter Plot: Razsevni diagram + 'Interactive scatter plot visualization with ': Interaktivni prikaz podatkov z razsevnim diagramom. + intelligent data visualization enhancements.: "" + icons/ScatterPlot.svg: false + scatter plot: scatter plot, grafikon + class `Inputs`: + Features: Spremenljivki + class `Outputs`: + Features: Spremenljivki + class `Warning`: + "Plot cannot be displayed because '{}' or '{}' ": "Diagrama ni mogoče pokazati, ker '{}' ali '{}' " + is missing for all data points.: nima nobene znane vrednosti. + class `Information`: + Large SQL table; showing a sample.: Velika tabela SQL; kažem vzorec. + Points with missing '{}' or '{}' are not displayed: Točke z manjkajočo vrednostjo '{}' ali '{}' niso prikazane. + def `_add_controls`: + graph.orthonormal_regression: false + Treat variables as independent: Obravnavaj spremenljivki kot neodvisni + If checked, fit line to group (minimize distance from points);\n: Če je opcija izbrana, je črta postavljena čim bližje točkam; + otherwise fit y as a function of x (minimize vertical distances): sicer pa minimizira samo razdaljo v smeri y. + graph.show_ellipse: false + Show confidence ellipse: Prikaz elipse zaupanja + Hotelling's T² confidence ellipse (α=95%): Elipsa zaupanja po Hotellingu (α=95%) + def `_add_controls_axis`: + Axes: Osi + m: false + attr_x: false + Axis x:: Os x: + attr_y: false + Axis y:: Os y: + Find Informative Projections: Poišči informativne projekcije + def `_add_controls_sampling`: + auto_sample: false + Sample: Vzorec + Sampling: Vzorčenje + def `init_vizrank`: + No data on input: Ni podatkov. + Data is sparse: Podatki so v redki tabeli. + Not enough features for ranking: Ni dovolj spremenljivk za rangiranje. + Color variable is not selected: Barva ni izbrana. + Color variable has no values: Spremenljivka {self.attr_color} nima znanih vrednosti. + def `_point_tooltip`: +
      : false + {} = {}: false + {}

      {}: false + def `set_subset_data`: + Data subset does not support large Sql tables: V velikih tabelah iz SQL ni možno izbirati primerov. + def `get_axes`: + bottom: false + left: false + def `get_widget_name_extension`: + {} vs {}: {} in {} + def `_get_send_report_caption`: + Color: Barva + Label: Oznake + Shape: Oblika + Size: Velikost + Jittering: Tresenje + def `migrate_settings`: + selection: false + selection_group: false + auto_send_selection: false + auto_commit: false + graph: false + jitter_continuous: false + def `migrate_context`: + attr_color: false + graph: false + attr_size: false + attr_shape: false + attr_label: false + attr_x: false + attr_y: false + def `__get_bar_icons`: + Orange.widgets.visualize: false + icons/interval-horizontal.svg: false + icons/interval-vertical.svg: false + __main__: false + iris: false +widgets/visualize/owscatterplotgraph.py: + class `LegendItem`: + def `addItem`: + left: false + class `ScatterPlotItem`: + def `setCoordinates`: + pen: false + brush: false + size: false + symbol: false + data: false + def `paint`: + visible: false + def `_get_aggregated_points`: + x: false + y: false + brush: false + def `pointsAt`: + visible: false + def `_define_symbols`: + ?: false + +: false + t: false + x: false + class `AxisItem`: + def `tickStrings`: + %Y: true + %Y %b: true + %Y %b %d: true + %Hh: true + %d %Hh: true + %H:%M: true + %H:%M:%S: true + %S.%f: true + class `ScatterBaseParameterSetter`: + Categorical legend: Kategorična legenda + Numerical legend: Številska legenda + class `OWScatterPlotBase`: + o x t + d star ?: false + def `__init__`: + left: false + bottom: false + def `_create_drag_tooltip`: + '{}: Append to group': {}: Dodaj k skupini + Cmd: true + darwin: false + Ctrl: true + 'Shift: Add group': Shift: Dodaj skupino + 'Alt: Remove': Alt: Odstrani +
      : false + ', ': false +
      : false + {}: false + def `get_sizes`: + ignore: false + def `update_sizes`: + impute_sizes: false + size: false + def `update_density`: + pen: false + def `get_shapes`: + impute_shapes: false + def `_update_color_legend`: + o: false + def `__adjust_pos`: + pxMode: false + def `get_dragged_points`: + set_coordinates: false + def `finish_dragging`: + finish_dragging: false +widgets/visualize/owscoringsheetviewer.py: + class `ScoringSheetTable`: + def `__init__`: + Attribute Name: Ime spremenljivke + Points: Točke + Selected: Izbrano + class `RiskSlider`: + def `setup_labels`: + Total:: Skupaj:: + Probabilities (%):: Verjetnosti (%):: + def `update_label_frequency`: + 100.0%: false + def `paintEvent`: + %: false + def `handle_hover_event`: + '{self.target_class}\n ': false + "
      ": false + Points: {int(points)}
      : Točke: {int(points)}
      + Probability: {probability:.1f}%: Verjetnost: {probability:.1f}% + class `OWScoringSheetViewer`: + Scoring Sheet Viewer: Pregledovalnik točkovalnika + Visualize the scoring sheet model.: Prikaz točkovalnega modela. + icons/ScoringSheetViewer-symbolic.svg: false + orangecontrib.prototypes.widgets.owscoringsheetviewer.OWScoringSheetViewer: false + scoring sheet viewer: true + class `Inputs`: + Classifier: Klasifikator + Data: Podatki + class `Outputs`: + Features: Spremenljivke + class `Error`: + Scoring Sheet Viewer only accepts a Scoring Sheet model.: Pregledovalnik točkovalnika sprejema le točkovalni model. + class `Information`: + The input data contains multiple instances. Only the first instance will be used.: Vhodni podatki vsebujejo več primerov. Uporabljen bo le prvi primer. + def `_setup_gui`: + horizontal: false + target_class_index: false + Target class:: Ciljni razred: + def `_populate_interface`: + {class_var_name} = {class_var_value}: false + def `_extract_data_from_model`: + closest_observation: false + __main__: false + heart_disease: false +widgets/visualize/owsieve.py: + class `ChiSqStats`: + def `__init__`: + ignore: false + class `OWSieveDiagram`: + Sieve Diagram: Sievov diagram + 'Visualize the observed and expected frequencies ': 'Prikaz pričakovanih in opaženih pogostosti ' + for a combination of values.: za kombinacije spremenljivk. + icons/SieveDiagram-symbolic.svg: false + sieve diagram: sieve diagram, grafikon + class `Inputs`: + Data: Podatki + Features: Spremenljivki + class `Outputs`: + Selected Data: Izbrani podatki + canvas: false + class `Warning`: + Data does not meet the Cochran's rule\n{}: Podatki ne ustrezajo Cochranovemu pravilu\n{} + def `__init__`: + attr_x: false + \u2717: true + attr_y: false + Score Combinations: Oceni kombinacije + def `migrate_context`: + attrX: false + attr_x: false + attrY: false + attr_y: false + def `init_vizrank`: + No data: Ni podatkov. + Not enough data: Ni dovolj podatkov. + Data is sparse: Podatki so v redki tabeli. + def `resolve_shown_attributes`: + Features from the input signal are not present in the data: Spremenljivk na vhodnem signalu ni v vhodni tabeli. + def `update_graph`: + def `text`: + max_width: false + def `fmt`: + {:.2f}: false + def `make_tooltip`: + def `_oper`: + ' = ': false + ' ': false + <≥: false + ' in ': ' znotraj ' + '{attr}{eq}{val_name}: {obs}/{n} ({p:.0f} %)': false + 'combination of values:
      +    expected {exp} ({p_exp:.0f} %)
      +    observed {obs} ({p_obs:.0f} %)': 'kombinacija vrednosti:
      +    pričakovanih {exp} ({p_exp:.0f} %)
      +    opaženih {obs} ({p_obs:.0f} %)' + {xt}
      {yt}
      {ct}: false + Features {} and {} have no values: Spremenljivki {} in {} nimata znanih vrednosti. + Feature {} has no values: Spremenljivka {} nima znanih vrednosti. + χ²={:.2f}, p={:.3f}: true + 'N = ': true + def `_check_cochran`: + no cells in contingency table: kontingenčna tabela nima celic + some expected frequencies are below 1: nekatere pričakovane pogostosti so manjše od 1 + more than 20% of expected frequencies are below 5: več kot 20 % pričakovanih pogostosti je manjših od 5 + def `get_widget_name_extension`: + {} vs {}: {} in {} + __main__: false + zoo: false +widgets/visualize/owsilhouetteplot.py: + class `NoGroupVariable`: + Input does not have any suitable labels: Vhodni podatki nimajo primerne spremenljivke za oznake + class `OWSilhouettePlot`: + Silhouette Plot: Silhuete + 'Visually assess cluster quality and ': Pokaže kvaliteto gručenja oz. skupin in pripadnost skupinam. + the degree of cluster membership.: "" + icons/SilhouettePlot-symbolic.svg: false + silhouette plot: silhouette plot + class `Inputs`: + Data: Podatki + class `Outputs`: + Selected Data: Izbrani podatki + orangecontrib.prototypes.widgets.owsilhouetteplot.OWSilhouettePlot: false + Orange.widgets.unsupervised.owsilhouetteplot.OWSilhouettePlot: false + Euclidean: Evklidska + Manhattan: Manhattanska + Cosine: Kosinusna + scene: false + class `Error`: + Need at least two non-empty clusters: Potrebni sta vsaj dve neprazni skupini + All clusters are singletons: Vse skupine imajo le en sam element. + Not enough memory: Premalo pomnilnika + "Distances could not be computed: '{}'": Razdalj ni mogoče izračunati: '{}' + {}: false + Distance matrix is not symmetric.: Matrika razdalj ni simetrična. + class `Warning`: + {} instance{s} omitted (missing cluster assignment): {} primer(ov) je izpuščenih (skupina ni znana) + {} instance{s} omitted (undefined distances): {} primer(ov) je izpuščenih (nedefinirana razdalja) + Ignoring categorical features: Ignoriram kategorične spremenljivke + def `__init__`: + Distance: Razdalja + distance_idx: false + Grouping: Skupine + (None): (Brez) + cluster_var: false + group_by_cluster: false + Show in groups: Pokaži v skupinah + Bars: Črte + Bar width:: Širina črt: + bar_size: false + Annotations:: Oznake: + annotation_var: false + (increase the width to show): (povečajte širino, da bodo vidne) + auto_commit: false + def `_set_distances`: + Distance matrix is not symmetric.: Matrika razdalj ni simetrična. + Input matrix does not have associated data: Matrika razdalj ne vsebuje podatkov. + def `_ensure_matrix`: + invalid state: false + def `_update`: + precomputed: false + s: false + def `commit`: + strictly increasing: false + Silhouette ({}): Silhueta ({}) + def `send_report`: + 'Silhouette plot ': 'Silhueta ' + '({self.Distances[self.distance_idx][0]} distance), ': '({self.Distances[self.distance_idx][0]} razdalja), ' + clustered by '{self.cluster_var.name}': skupine glede na '{self.cluster_var.name}' + , annotated with '{self.annotation_var.name}': , označene z '{self.annotation_var.name}' + def `migrate_context`: + cluster_var_idx: false + cluster_var: false + annotation_var_idx: false + annotation_var: false + def `show_tool_tip`: + QTipLabel: false + {etext}: false + class `SilhouettePlot`: + def `setScores`: + scores and labels must be 1 dimensional: false + scores and labels must have the same shape: false + rownames must have the same size as scores: false + All indices in `labels` must be in `range(len(values))`: false + def `__setup`: + top: false + ' ({np.mean(group.scores):.3f})': false + bottom: false + class `Line`: + def `__init__`: + sizePolicy: false + class `BarPlotItem`: + def `__init__`: + '#3FCFCF': false + __main__: false + brown-selected: false +widgets/visualize/owtreeviewer.py: + class `TreeNode`: + def `rect`: + _rect: false + def `boundingRect`: + attr: false + class `OWTreeGraph`: + Tree Viewer: Drevogled + icons/TreeViewer.svg: false + tree viewer: tree viewer + class `Inputs`: + Tree: Drevo + Classification Tree: false + Regression Tree: false + class `Outputs`: + Selected Data: Izbrani podatki + selected-data: false + annotated-data: false + Orange.widgets.classify.owclassificationtreegraph.OWClassificationTreeGraph: false + Orange.widgets.classify.owregressiontreegraph.OWRegressionTreeGraph: false + Default: Privzeto + Number of instances: Število primerov + Mean value: Srednja vrednost + Variance: Varianca + def `__init__`: + 'Target class: ': 'Ciljni razred: ' + None: Brez + node_labels: false + Variable that identifies the instances in nodes.: Spremenljivka, ki identificira primere v vozliščih. + Node labels:: Oznake vozlišč: + show_intermediate: false + Show details in non-leaves: Podrobnosti v notranjih vozliščih + def `_update_node_info_attr_name`: +
      : false + def `_ctree_clean`: + No tree.: Ni drevesa. + def `_ctree_setup`: + 'Target class: ': 'Ciljni razred: ' + None: Brez + 'Color by: ': 'Obarvaj po: ' + {nodes} {pl(nodes, "node")}, {leaves} {pl(leaves, "leaf|leaves")}: {nodes} {plsi(nodes, "vozlišče|vozlišči|vozlišča|vozlišč")}, {leaves} {plsi(leaves, "list")} + def `node_tooltip`: +    : false +

      : false +
      : false + {indent}– {to_html(str(rule))}: false +

      Selection

      {rule}

      :

      Kriterij

      {rule}

      + {nbp}Distribution of '{name}'

      :

      Porazdelitev

      {rule}

      + : false + : false + ": false + : false + : false + : false + : false +
      ": false + {escape(value)}{indent}{prop:g}{prop / total * 100:.1f} %
      : false + {nbp}{class_var.name} = {mean:.3g} ± {var:.3g}
      : false + ({self.tree_adapter.num_samples(node.node_inst)} instances)

      : ({self.tree_adapter.num_samples(node.node_inst)} {plsi(self.tree_adapter.num_samples(node.node_inst), "primer")}) + '{nbp}Next split: {split}

      ': {nbp}Naslednja delitev: {split}

      +
      : false + def `send_report`: + Tree size: Velikost drevesa + Edge widths: Širina vej + Fixed: Enaka + Relative to root: Relativna glede na koren + Relative to parent: Relativna glede na starša + Target class: Ciljni razred + Color by: Obarvaj po + def `update_node_info`: +
      : false + ', ': false + , …: true + '

      {text}

      ': false + def `node_content_cls`: + {escape(self.domain.class_vars[0].values[int(modus)])}
      : false + 100%, {total}/{total}: false + {100 * tabs:2.1f}%, {int(total * tabs)}/{total}: false + def `node_content_reg`: + {mean:.1f} ± {var:.1f}
      : false + {insts} instances: {insts} {plsi(insts, "primer")} + __main__: false + titanic: false +widgets/visualize/owtreeviewer2d.py: + class `GraphNode`: + def `__init__`: + edges: false + class `TextTreeNode`: + def `backgroundBrush`: + _background_brush: false + defaultItemBrush: false + QBrush: false + Background brush: false + def `setHtml`: + : false + : false + def `rect`: + _rect: false + def `boundingRect`: + _rect: false + class `TreeGraphicsView`: + resized: false + class `OWTreeViewer2D`: + scene: false + def `__init__`: + Tree: Drevo + No tree.: Ni drevesa. + Display: Prikaz + 'Zoom: ': 'Povečava: ' + zoom: false + 'Width: ': 'Širina: ' + max_node_width: false + 'Depth: ': 'Globina: ' + max_tree_depth: false + Unlimited: Neomejena + {x} levels: {x} {plsi(x, "nivo|nivoja|nivoji|nivojev")} + 'Edge width: ': 'Širina povezav: ' + line_width_method: false + Fixed: Enaka + Relative to root: Relativna glede na koren + Relative to parent: Relativna glede na starša + 'QToolTip { padding: 3px; + border: 1px solid #C0C0C0; + }': false + def `send_report`: + Tree: Drevo + .svg: false + def `node_tooltip`: + tree node: vozlišče drevesa +widgets/visualize/owvenndiagram.py: + _InputData: false + key: false + name: false + table: false + _ItemSet: false + title: false + items: false + Instance identity: Identiteta vrstic + Instance equality: Enakost vrstic + class `OWVennDiagram`: + Venn Diagram: Vennov diagram + 'A graphical visualization of the overlap of data instances ': 'Grafični prikaz skupnih podatkov v ' + from a collection of input datasets.: različnih tabelah. + icons/VennDiagram.svg: false + venn diagram: venn diagram, grafikon + class `Inputs`: + Data: Podatki + class `Outputs`: + Selected Data: Izbrani podatki + class `Error`: + Data sets do not contain the same instances.: Vhodni podatki ne vsebujejo istih primeov. + Venn diagram accepts at most five datasets.: Vennov diagram lahko sprejme največ pet tabel. + class `Warning`: + 'Some variables have been renamed ': 'Nekatere spremenljivke so preimenovane v izogib ' + to avoid duplicates.\n{}: ponavljanju imen.\n{} + scene: false + attributes: false + metas: false + class_vars: false + X: false + Y: false + x: false + y: false + def `__init__`: + rowwise: false + Columns (features): Stolpci (spremenljivke) + Rows (instances), matched by: Vrstice (primeri), ujemanje glede na + selected_feature: false + 'Instances are identical if originally coming from the ': Vrstici sta "enaki", če izvirata iz iste vrstice neke tabele. + same row of the same table.\n: "" + 'Instances can be check for equality only if described by ': Enakost vrstic je možno primerjati le, če sta opisani z istimi spremenljivkami. + the same variables.: "" + output_duplicates: false + Output duplicates: Ponovljeni primeri na izhodu. + autocommit: false + def `_createDiagram`: + '{} (all: {})': {} (vsi: {}) + {}: false + {0}: false +

      |{}| = {}

      : false + : false + ', ': false +
      ({len(area_items) - 32} items not shown): '
      in še {z_besedo(len(area_items) - 32, 1, "f")} {plsi(len(area_items) - 32, "druga spremenljivka|drugi spremenljivki|druge spremenljivke|drugih spremenljivk")}' +
      : false + def `merge_data`: + ', ': false + attributes: false + metas: false + class_vars: false + def `extract_columnwise`: + {var_name.name} ({idx}): false + attributes: false + Selected: false + ', ': false + def `create_from_columns`: + attributes: false + def `extract_rowwise`: + {} ({}): false + ', ': false + def `get_indices`: + metas: false + def `extract_rowwise_duplicates`: + name: false + attributes: false + metas: false + class_vars: false + def `migrate_settings`: + selected_feature: false + def `disjoint_set_label`: + INTERSECTION: false + c: false + def `label_for_index`: + A: false + : false + : false + class `VennDiagram`: +

      {0}

      {1}
      : false + def `_on_editingStarted`: +
      : false + def `append_column`: + X: false + Y: false + M: false + def `main`: + brown-selected: false + test_rows: false + M: false + Test: false + A: false + __main__: false +widgets/visualize/owviolinplot.py: + class `ParameterSetter`: + Bottom axis: Spodnja os + Vertical tick text: Navpično besedilo + def `axis_items`: + item: false + def `bottom_axis`: + bottom: false + class `ViolinItem`: + RugPlot: false + support, density: false + class `ViolinPlot`: + def `__init__`: + bottom: false + left: false + def `order_items`: + bottom: false + left: false + def `_set_axes`: + left: false + bottom: false + def `_clear_axes`: + left: false + bottom: false + class `OWViolinPlot`: + Violin Plot: Violine + Visualize the distribution of feature: Porazdelitev vrednosti v + ' values in a violin plot.': ' vizualizaciji z violinami.' + icons/ViolinPlot.svg: false + violin plot, kernel, density: violin plot, kernel, density, jedro, gostota, porazdelitev + class `Inputs`: + Data: Podatki + class `Outputs`: + Selected Data: Izbrani podatki + class `Error`: + Plotting requires a numeric feature.: Risati je mogoče le številske spremenljivke. + Plotting requires at least two instances.: Risanje zahteva vsaj dva primera. + gaussian: false + epanechnikov: false + linear: false + Normal: Normalno + Epanechnikov: Epanechikovo + Linear: Linearno + Area: Površina + Count: Število primerov + Width: Širina + graph.plotItem: false + def `_add_controls`: + None: (Brez skupin) + Variable: Spremenljivka + order_by_importance: false + Order by relevance to subgroups: Uredi glede na različnost po skupinah + Order by 𝜒² or ANOVA over the subgroups: Uredi glede na vrednost 𝜒² ali ANOVA med skupinami + Subgroups: Skupine + order_grouping_by_importance: false + Order by relevance to variable: Uredi glede na pomembnost spremenljivke + Order by 𝜒² or ANOVA over the variable values: Uredi glede na vrednost 𝜒² ali ANOVA med vrednostmi spremenljivke + Display: Prikaz + show_box_plot: false + Box plot: Škatla z brki + show_strip_plot: false + Density dots: Preproga točk + show_rug_plot: false + Density lines: Proge + order_violins: false + Order subgroups: Uredi skupine + show_grid: false + Show grid: Pokaži mrežo + orientation_index: false + Horizontal: Vodoravna + Vertical: Navpična + 'Orientation: ': 'Smer: ' + Density Estimation: Ocenjevanje gostote + kernel_index: false + Kernel:: Jedro: + scale_index: false + Scale:: Velikost: + def `init_list_view`: + hidden: false + __main__: false + heart_disease: false +widgets/visualize/pythagorastreeviewer.py: + Square: false + center: false + length: false + angle: false + Point: false + x: false + y: false + class `PythagorasTreeViewer`: + def `__init__`: + interactive: false + target_class_index: false + weight_adjustment: false + class `SquareGraphicsItem`: + def `__init__`: + brush: false + '#297A1F': false + zvalue: false + class `TreeNode`: + def `_rules_str`: +
      : false +
      %s: false + class `DiscreteTreeNode`: + def `tooltip`: +
      : false +

      : false + {}/{} samples ({:2.3f}%): {}/{} primerov ({:2.3f}%) +


      : false + 'Split by ': Delitev glede na +

      : false +

      : false + class `ContinuousTreeNode`: + None: (Enaka barva) + Mean: Povprečje + Standard deviation: Standardni odklon + def `tooltip`: + '

      Mean: {:2.3f}':

      Povprečje: {:2.3f} + '
      Standard deviation: {:2.3f}':
      Standardni odklon: {:2.3f} +
      {} samples:
      {} primerov +


      : false + 'Split by ': Delitev glede na +

      : false +

      : false +widgets/visualize/utils/__init__.py: + class `VizRankDialog`: + class `Information`: + There is nothing to rank.: Ni česa rangirati. + def `__init__`: + Orange.widgets.visualize.utils.vizrank.VizRankDialog: false + Filter ...: false + Start: Začni + def `initialize`: + Start: Začni + def `on_done`: + Finished: Končano + def `toggle`: + Pause: Počakaj + Continue: Nadaljuj + def `run_vizrank`: + Getting combinations...: Sestavljam kombinacije... + Getting scores...: Ocenjujem... + class `VizRankDialogAttrPair`: + def `__init__`: + xy_changed_manually: false + def `row_for_state`: + name: false + ', ': false + class `CanvasText`: + def `elide`: + ...: true + class `ViewWithPress`: + def `__init__`: + handler: false +widgets/visualize/utils/component.py: + class `AnchorParameterSetter`: + Anchor: Sidro +widgets/visualize/utils/customizableplot.py: + def `available_font_families`: + .: false + class `Updater`: + Font family: Pisava + Font size: Velikost pisave + Italic: Poševno + Width: Širina + Opacity: Neprosojnost + Style: Slog + Antialias: Mehčanje + Solid line: Polna črta + Dash line: Prekinjena črta + Dot line: Pikčasta črta + Dash dot line: Črta - pika + Dash dot dot line: Črta - pika - pika + def `update_axes_titles_font`: + foreground: false + normal: false + italic: false + font-size: false + {font.pointSize()}pt: false + font-family: false + {font.family()}: false + color: false + font-style: false + {fstyle}: false + def `update_axes_ticks_font`: + tickFont: false + def `update_legend_font`: + size: false + def `update_lines`: + pen: false + def `update_inf_lines`: + label: false + class `CommonParameterSetter`: + Fonts: Pisave + Annotations: Oznake + Figure: Slika + Font family: Oblika pisave + Axis title: Ime osi + Axis ticks: Črte na osi + Legend: Legenda + Label: Oznake + Line label: Oznaka črte + x-axis title: false + y-axis title: false + Title: Naslov + Lines: Črte + def `__init__`: + bottom: false + left: false +widgets/visualize/utils/error_bars_dialog.py: + class `ErrorBarsDialog`: + def `__init__`: + (None): (Brez) + Difference from plotted value: Odmik od vrednosti + Absolute position on the plot: Absolutni položaj na grafu + Upper:: Zgornje: + Lower:: Spodnje: + __main__: false + Open: false + iris: false +widgets/visualize/utils/heatmap.py: + class `ColorMap`: + def `replace`: + ColorMap: false + class `CategoricalColorMap`: + def `replace`: + CategoricalColorMap: false + colortable: false + names: false + class `GradientColorMap`: + def `adjust_levels`: + low > high ({low} > {high}): false + def `apply`: + ignore: false + unsafe: false + def `replace`: + GradientColorMap: false + colortable: false + thresholds: false + center: false + span: false + def `normalized_indices`: + RowItem: false + ColumnItem: false + class `HeatmapGridWidget`: + class `Parts`: + RowItem: false + ColumnItem: false + def `setHeatmaps`: + Parts: false + row-labels-right: false + column-labels-top: false + column-labels-bottom: false + annotation-legend-container: false + row-annotation-legend-container: false + col-annotation-legend-container: false + def `heatmapAtPos`: + GraphicsHeatmapWidget: false + class `GraphicsHeatmapWidget`: + def `heatmapCellToolTip`: + '{}, {}: {:g}': false + class `_GradientLegendAxisItem`: + def `boundingRect`: + top: false + bottom: false + tickFont: false + 0.0000000: false + class `GradientLegendWidget`: + def `__init__`: + sizePolicy: false + top: false + def `__update`: + {:.6g}: false + class `CategoricalColorLegend`: + def `__init__`: + sizePolicy: false + def `_setup`: + X: false + def `_updateFont`: + X: false +widgets/visualize/utils/lac.py: + def `create_sql_contingency`: + %s IS NOT NULL: false + COUNT(%s): false + float: false + def `lac`: + Initializing: false + Done: false + def `get_bin_centers`: + []()<>=≥: false + -: false +widgets/visualize/utils/owlegend.py: + class `Anchorable`: + topLeft: false + topRight: false + bottomLeft: false + bottomRight: false + def `__init__`: + bottomRight: false + class `ContinuousLegendItem`: + def `_format_values`: + {:.3f}: false + class `OWDiscreteLegend`: + def `set_domain`: + '[OWDiscreteLegend] The class var provided ': false + was not discrete.: false + class `OWContinuousLegend`: + def `__init__`: + range: false + def `set_domain`: + '[OWContinuousLegend] The class var provided ': false + was not continuous.: false +widgets/visualize/utils/plotutils.py: + class `TextItem`: + setAnchor: false + class `InteractiveViewBox`: + def `mouseDragEvent`: + def `select`: + unsuspend_jittering: false + suspend_jittering: false + get_dragged_points: false + mouseMode: false + class `PaletteItemSample`: + def `__init__`: + {{:.{}f}}: false + {} - {}: false + class `StyledAxisItem`: + def `__clear_labelStyle_color`: + color: false + class `AxisItem`: + def `__init__`: + rotateTicks: false + def `setRotateTicks`: + rotateTicks: false + def `drawPicture`: + bottom: false + top: false + rotateTicks: false + tickFont: false + tickTextOffset: false + class `PlotWidget`: + def `__init__`: + axisItems: false + left: false + bottom: false + class `PlotItem`: + def `__init__`: + axisItems: false + left: false + bottom: false +widgets/visualize/utils/scene.py: + class `UpdateItemsOnSelectGraphicsScene`: + def `__handle_selection`: + selection_changed: false +widgets/visualize/utils/tree.py: + {__name__} module was moved. Use {tree.__name__} instead: false +widgets/visualize/utils/vizrank.py: + class `VizRankDialog`: + Score Plots: Oceni prikaze + Start: Začni + Pause: Počakaj + Continue: Nadaljuj + Finished: Končano + def `__init__`: + Filter ...: true + Start: Začni + def `set_run_state`: + {self.captionTitle} (paused at {self._progress}%): {self.captionTitle} (čaka pri {self._progress}%) + def `VizRankMixin`: + {vizrank_class.__name__}Mixin: false + class `VizRankDialogAttrs`: + def `__init__`: + Orange.data.Table: false + Orange.data.Variable: false + def `attr_order`: + Orange.data.Variable: false + def `row_for_state`: + ', ': true + def `auto_select`: + Orange.data.Variable: false + class `VizRankDialogNAttrs`: + def `__init__`: + Orange.data.Table: false + Orange.data.Variable: false + 'Number of variables: ': 'Število spremenljivk: ' + def `on_n_attrs_changed`: + Restart with {new_attrs} variables: Ponovno poženi {plsi_sz(new_attrs)} {z_besedo(new_attrs, 6, "f")} {plsi(new_attrs, "spremenljivko|spremenljivkama|spremenljivkami")} + def `emit_run_state_changed`: + n_attrs: false +widgets/visualize/utils/widget.py: + class `OWProjectionWidgetBase`: + class `Information`: + Points with undefined '{}' are shown in smaller size: Točke z neznano vrednostjo '{}' so manjše. + Points with undefined '{}' are shown as crossed circles: Točke z neznano vrednostjo '{}' so prečrtane. + def `init_attr_values`: + attr_color: false + attr_shape: false + attr_size: false + attr_label: false + def `get_column`: + Other: Drugo + def `_point_tooltip`: + def `show_part`: + {} = {}: false + ... and {over} {pl(over, 'other')}: ... in še {over} + {name}:
      : false +
      : false + {pl(len(dom.class_vars), 'Class|Classes')}: {plsi(len(dom.class_vars), 'Razred|Razreda|Razredi')} + {pl(len(dom.metas), 'Meta')}: Meta {plsi(len(dom.metas), 'spremenljivka|spremenljivki|spremenljivki')} + {pl(len(dom.attributes), 'Feature')}: {plsi(len(dom.attributes), 'Atribut|Atributa|Atributi')} +
      : false + def `get_tooltip`: +
      : false + {len(point_ids)} instances
      {text}
      ...: {len(point_ids)} {plsi(len(point_ids), "primer")}
      {text}
      ... + class `OWDataProjectionWidget`: + class `Inputs`: + Data: Podatki + Data Subset: Podmnožica podatkov + class `Outputs`: + Selected Data: Izbrani podatki + class `Warning`: + Too many labels to show (zoom in or label only selected): Preveč oznak (povečaj ali izberi 'Označi samo izbor in podmnožico') + 'Subset data contains some instances that do not appear in ': 'Podmnožica podatkov vsebuje nekatere primere, ki niso med ' + input data: vhodnimi podatki + No subset data instances appear in input data: Vhodni podatki ne vsebujejo nobenega podatke iz podane podmnožice. + Increase opacity if subset is difficult to see: Če je podmnožico težko videti, zmanjšaj prosojnost. + graph.plot_widget.plotItem: false + proj-x: false + proj-y: false + def `_add_buttons`: + auto_commit: false + def `_get_selected_data`: + Group: Skupina + def `_get_send_report_caption`: + Color: Barva + Label: Oznaka + Shape: Oblika + Size: Velikost + Jittering: Pretresanje + {} %: false + class `OWAnchorProjectionWidget`: + class `Outputs`: + Components: Komponente + class `Error`: + Sparse data is not supported: Redki podatki niso podprti + No projection due to no valid data: Ni veljavnih podatkov, ni projekcije. + At least two data instances are required: Potrebna sta vsaj dva primera. + An error occurred while projecting data.\n{}: Napaka med projeciranjem podatkov.\n{} + def `send_components`: + component: false + components: komponente + __main__: false + class `OWProjectionWidgetWithName`: + projection: false + iris: false diff --git a/i18n/si/static/canvas/workflows/si/110-file-and-data-table-widget.ows b/i18n/si/static/canvas/workflows/si/110-file-and-data-table-widget.ows new file mode 100644 index 00000000000..5a20af423d8 --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/110-file-and-data-table-widget.ows @@ -0,0 +1,51 @@ + + + + + + + + + + + Gradnik Datoteka. Z dvojnim klikom ga odpri in izberi datoteko s podatki. + Izhod gradnika Datoteka. + Vhod gradnika Tabela. + Komunikacijski kanal. Ta prenese podatke iz gradnika Datoteka v gradnik Tabela. + Gradnik Tabela. Dvakrat klikni ikono, da si ogledaš podatke v preglednici. + Izhod gradnika Tabela pošlje naprej vse podatke (vrstice), ki so izbrani v gradniku. + Ta izhod ni uporabljen, zato je črta črtkana. Dodaš lahko še eno Tabelo s klikom na njeno ikono v orodjarni na levi strani. Nato poveži izhod gradnika Tabela z vhodom gradnika Tabela (1) in preveri, ali se izbrani podatki iz prvotne tabele res pošljejo v drugo tabelo za nadaljnjo obdelavo. To se najbolje opazi, če bosta oba gradnika odprta, to je, če sta njuni okni prikazani. + + + + + + + + + + + gASVuAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDLgHZ0MsAAQAAAAACEwAAATwAAAOqAAACNQAAAhMAAAFSAAADqgAAAjUAAAAAAACUjAtz +aGVldF9uYW1lc5R9lIwGc291cmNllEsAjAN1cmyUaBCMDWRvbWFpbl9lZGl0b3KUfZSMC19fdmVy +c2lvbl9flEsBjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdD +b250ZXh0lJOUKYGUfZQojAR0aW1llEdB1qcWDLpbX4wGdmFsdWVzlH2UKIwJdmFyaWFibGVzlF2U +aBt9lGgoXZQoXZQojAxzZXBhbCBsZW5ndGiUjBRPcmFuZ2UuZGF0YS52YXJpYWJsZZSMEkNvbnRp +bnVvdXNWYXJpYWJsZZSTlEsAaBCIZV2UKIwLc2VwYWwgd2lkdGiUaDBLAGgQiGVdlCiMDHBldGFs +IGxlbmd0aJRoMEsAaBCIZV2UKIwLcGV0YWwgd2lkdGiUaDBLAGgQiGVdlCiMBGlyaXOUaC6MEERp +c2NyZXRlVmFyaWFibGWUk5RLAYwsSXJpcy1zZXRvc2EsIElyaXMtdmVyc2ljb2xvciwgSXJpcy12 +aXJnaW5pY2GUiWVlc2gdSwF1jAphdHRyaWJ1dGVzlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBh +bCB3aWR0aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlHSUjAVtZXRh +c5QpjApjbGFzc192YXJzlIwEaXJpc5RdlCiMC0lyaXMtc2V0b3NhlIwPSXJpcy12ZXJzaWNvbG9y +lIwOSXJpcy12aXJnaW5pY2GUZYaUhZSMEm1vZGlmaWVkX3ZhcmlhYmxlc5RdlHViYXUu + + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, 'stored_sort': [], '__version__': 1} + + + + + diff --git a/i18n/si/static/canvas/workflows/si/120-scatterplot-data-table.ows b/i18n/si/static/canvas/workflows/si/120-scatterplot-data-table.ows new file mode 100644 index 00000000000..f3ace4e24f7 --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/120-scatterplot-data-table.ows @@ -0,0 +1,62 @@ + + + + + + + + + + + + + Ta gradnik Datoteka je nastavljen za branje nabora podatkov Iris. Z dvojnim klikom na ikono lahko spremeniš vhodno podatkovno datoteko in opazuješ, kako ta delotok deluje za nekatere druge primere podatkov (na primer housing). + Za vizualizacijo podatkov dvakrat klikni ikono Razsevni diagram. Nato izberi podmnožico podatkov tako, da izbereš točke na razsevnem diagramu. + Gradnik Tabela prikazuje podmnožico podatkov, izbrano v gradniku Razsevni diagram. + + + + Poskusi povezati kakšen drug gradnik z izhodom gradnika Razsevni diagram. Recimo gradnik Škatla z brki (orodjarna, podokno Vizualizacija). Škatla z brki bo prikazala porazdelitve podmnožice podatkov, izbrane v Razsevnem diagramu. + + + + + gASVvAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWg +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwEdGltZZRHQdanFiOv2ZCMBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoKF2UKF2UKIwMc2VwYWwgbGVuZ3RolIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJD +b250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGgwSwBoEIhlXZQojAxw +ZXRhbCBsZW5ndGiUaDBLAGgQiGVdlCiMC3BldGFsIHdpZHRolGgwSwBoEIhlXZQojARpcmlzlGgu +jBBEaXNjcmV0ZVZhcmlhYmxllJOUSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIEly +aXMtdmlyZ2luaWNhlIllZXNoHUsBdYwKYXR0cmlidXRlc5QojAxzZXBhbCBsZW5ndGiUSwKGlIwL +c2VwYWwgd2lkdGiUSwKGlIwMcGV0YWwgbGVuZ3RolEsChpSMC3BldGFsIHdpZHRolEsChpR0lIwF +bWV0YXOUKYwKY2xhc3NfdmFyc5SMBGlyaXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMtdmVyc2lj +b2xvcpSMDklyaXMtdmlyZ2luaWNhlGWGlIWUjBJtb2RpZmllZF92YXJpYWJsZXOUXZR1YmF1Lg== + + gASVEwMAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAOtAAABrAAABmwAAAR7 +AAADrQAAAcIAAAZsAAAEewAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiYwRaml0dGVyX2NvbnRpbnVvdXOUiYwLaml0dGVyX3NpemWUSwqME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOU +KYGUfZQojAphdHRyaWJ1dGVzlH2UKIwLc2VwYWwgd2lkdGiUSwKMBGlyaXOUSwGMC3BldGFsIHdp +ZHRolEsCjAxzZXBhbCBsZW5ndGiUSwKMDHBldGFsIGxlbmd0aJRLAnWMBHRpbWWUR0HWpxYjvO8b +jAVtZXRhc5R9lIwGdmFsdWVzlH2UKIwKYXR0cl9jb2xvcpSMBGlyaXOUS2WGlIwKYXR0cl9sYWJl +bJROSv7///+GlIwKYXR0cl9zaGFwZZROSv7///+GlIwJYXR0cl9zaXpllE5K/v///4aUjAZhdHRy +X3iUjAxzZXBhbCBsZW5ndGiUS2aGlIwGYXR0cl95lIwLc2VwYWwgd2lkdGiUS2aGlGgKfZRoFksF +dYwOb3JkZXJlZF9kb21haW6UXZQoaCNLAoaUaCBLAoaUaCRLAoaUaCJLAoaUaCFLAYaUZXViYXUu + + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, 'stored_sort': [], '__version__': 1} + + + + + diff --git a/i18n/si/static/canvas/workflows/si/130-scatterplot-visualize-subset.ows b/i18n/si/static/canvas/workflows/si/130-scatterplot-visualize-subset.ows new file mode 100644 index 00000000000..07127a8b15f --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/130-scatterplot-visualize-subset.ows @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + (1) Odpri Tabelo in izberi primerke podatkov ali podmnožico primerkov (uporabi tipko shift). + (2) Odpri Razsevni diagram in opazuj izbrano podmnožico podatkov v Tabeli. + Z dvojnim klikom na ta kanal preveri, ali se podatki iz Tabele dejansko posredujejo kot podmnožica podatkov. + + + + + + + gASVvAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWg +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwEdGltZZRHQdanFiuwZGKMBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoKF2UKF2UKIwMc2VwYWwgbGVuZ3RolIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJD +b250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGgwSwBoEIhlXZQojAxw +ZXRhbCBsZW5ndGiUaDBLAGgQiGVdlCiMC3BldGFsIHdpZHRolGgwSwBoEIhlXZQojARpcmlzlGgu +jBBEaXNjcmV0ZVZhcmlhYmxllJOUSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIEly +aXMtdmlyZ2luaWNhlIllZXNoHUsBdYwKYXR0cmlidXRlc5QojAxzZXBhbCBsZW5ndGiUSwKGlIwL +c2VwYWwgd2lkdGiUSwKGlIwMcGV0YWwgbGVuZ3RolEsChpSMC3BldGFsIHdpZHRolEsChpR0lIwF +bWV0YXOUKYwKY2xhc3NfdmFyc5SMBGlyaXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMtdmVyc2lj +b2xvcpSMDklyaXMtdmlyZ2luaWNhlGWGlIWUjBJtb2RpZmllZF92YXJpYWJsZXOUXZR1YmF1Lg== + + gASVEwMAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAOtAAABrAAABmwAAAR7 +AAADrQAAAcIAAAZsAAAEewAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiYwRaml0dGVyX2NvbnRpbnVvdXOUiYwLaml0dGVyX3NpemWUSwqME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOU +KYGUfZQojA5vcmRlcmVkX2RvbWFpbpRdlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBhbCB3aWR0 +aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlIwEaXJpc5RLAYaUZYwF +bWV0YXOUfZSMCmF0dHJpYnV0ZXOUfZQoaCZLAmggSwJoKEsBaCRLAmgiSwJ1jAZ2YWx1ZXOUfZQo +jAphdHRyX2NvbG9ylIwEaXJpc5RLZYaUjAphdHRyX2xhYmVslE5K/v///4aUjAphdHRyX3NoYXBl +lE5K/v///4aUjAlhdHRyX3NpemWUTkr+////hpSMBmF0dHJfeJSMDHNlcGFsIGxlbmd0aJRLZoaU +jAZhdHRyX3mUjAtzZXBhbCB3aWR0aJRLZoaUaAp9lGgWSwV1jAR0aW1llEdB1qcWK8W1+nViYXUu + + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, 'stored_sort': [], '__version__': 1} + + + + + diff --git a/i18n/si/static/canvas/workflows/si/250-tree-scatterplot.ows b/i18n/si/static/canvas/workflows/si/250-tree-scatterplot.ows new file mode 100644 index 00000000000..c2f9ba92ed3 --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/250-tree-scatterplot.ows @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + Naloži podatke o Irisu ("iris.tab") iz predhodno naloženih dokumentacijskih zbirk podatkov. + + + Vsaka sprememba pri izbiri drevesnega vozlišča spremeni upodobitev v razsevnem diagramu. + + Dvakrat klikni na ta gradnik in izberi poljubno vozlišče v drevesu. + + Podatki, izbrani v drevogledu, se prenesejo v vse nadaljnje gradnike v delotoku. + Ta delotok deluje najbolje, če so hkrati odprti Drevogled, Razsevni diagram in Škatla z brki. + + + + gASVOgYAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZQojB5v +cmFuZ2V3aWRnZXQudXRpbHMuZmlsZWRpYWxvZ3OUjApSZWNlbnRQYXRolJOUKYGUfZQojAdhYnNw +YXRolIwtL1VzZXJzL2phbmV6L29yYW5nZTMvT3JhbmdlL2RhdGFzZXRzL2lyaXMudGFilIwGcHJl +Zml4lIwPc2FtcGxlLWRhdGFzZXRzlIwHcmVscGF0aJSMCGlyaXMudGFilIwFdGl0bGWUjACUjAVz +aGVldJRoEIwLZmlsZV9mb3JtYXSUTnViaAYpgZR9lChoCYwwL1VzZXJzL2phbmV6L29yYW5nZTMv +T3JhbmdlL2RhdGFzZXRzL3RpdGFuaWMudGFilGgLaAxoDYwLdGl0YW5pYy50YWKUaA9oEGgRaBBo +Ek51YmgGKYGUfZQoaAmMMC9Vc2Vycy9qYW5lei9vcmFuZ2UzL09yYW5nZS9kYXRhc2V0cy9ob3Vz +aW5nLnRhYpRoC2gMaA2MC2hvdXNpbmcudGFilGgPaBBoEWgQaBJOdWJoBimBlH2UKGgJjDYvVXNl +cnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaGVhcnRfZGlzZWFzZS50YWKUaAtoDGgN +jBFoZWFydF9kaXNlYXNlLnRhYpRoD2gQaBFoEGgSTnViZYwLcmVjZW50X3VybHOUXZSME3NhdmVk +V2lkZ2V0R2VvbWV0cnmUQzIB2dDLAAIAAAAAAhIAAAC1AAAEaQAAAvAAAAISAAAAywAABGkAAALw +AAAAAAAAAAAGkJSMC3NoZWV0X25hbWVzlH2UjAZzb3VyY2WUSwCMA3VybJRoEIwNZG9tYWluX2Vk +aXRvcpR9lIwLX192ZXJzaW9uX1+USwGMEGNvbnRleHRfc2V0dGluZ3OUXZQojBVvcmFuZ2V3aWRn +ZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOUKYGUfZQojAZ2YWx1ZXOUfZQojAl2YXJpYWJsZXOUXZRo +J32UaDNdlChdlCiMDHNlcGFsIGxlbmd0aJSMFE9yYW5nZS5kYXRhLnZhcmlhYmxllIwSQ29udGlu +dW91c1ZhcmlhYmxllJOUSwBoEIhlXZQojAtzZXBhbCB3aWR0aJRoO0sAaBCIZV2UKIwMcGV0YWwg +bGVuZ3RolGg7SwBoEIhlXZQojAtwZXRhbCB3aWR0aJRoO0sAaBCIZV2UKIwEaXJpc5RoOYwQRGlz +Y3JldGVWYXJpYWJsZZSTlEsBjCxJcmlzLXNldG9zYSwgSXJpcy12ZXJzaWNvbG9yLCBJcmlzLXZp +cmdpbmljYZSJZWVzaClLAXWMCmF0dHJpYnV0ZXOUKIwMc2VwYWwgbGVuZ3RolEsChpSMC3NlcGFs +IHdpZHRolEsChpSMDHBldGFsIGxlbmd0aJRLAoaUjAtwZXRhbCB3aWR0aJRLAoaUdJSMBW1ldGFz +lCmMCmNsYXNzX3ZhcnOUjARpcmlzlF2UKIwLSXJpcy1zZXRvc2GUjA9JcmlzLXZlcnNpY29sb3KU +jA5JcmlzLXZpcmdpbmljYZRlhpSFlIwSbW9kaWZpZWRfdmFyaWFibGVzlF2UdWJoLimBlH2UKGhR +KWgxfZQojAl4bHNfc2hlZXSUaBBK/////4aUjA1kb21haW5fZWRpdG9ylH2UjAl2YXJpYWJsZXOU +XZQoXZQojAxzZXBhbCBsZW5ndGiUaDtLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGg7SwBoEIhlXZQo +jAxwZXRhbCBsZW5ndGiUaDtLAGgQiGVdlCiMC3BldGFsIHdpZHRolGg7SwBoEIhlXZQojARpcmlz +lGhFSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIElyaXMtdmlyZ2luaWNhlIllZXNo +Y12UaClLAXVoRyhoZksChpRoaEsChpRoaksChpRobEsChpR0lIwOb3JkZXJlZF9kb21haW6UXZQo +aGZLAoaUaGhLAoaUaGpLAoaUaGxLAoaUaG5LAYaUZYwEdGltZZRHQdYqsOiB0RhoWl2UaFJobksB +hpSFlHViZXUu + + gASVrwEAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjBFsaW5lX3dpZHRoX21ldGhvZJRL +AowObWF4X25vZGVfd2lkdGiUS5aMDm1heF90cmVlX2RlcHRolEsAjBFyZWdyZXNzaW9uX2NvbG9y +c5RLAIwTc2F2ZWRXaWRnZXRHZW9tZXRyeZRDQgHZ0MsAAwAAAAACFAAAASsAAAY8AAADPgAAAhUA +AAFKAAAGOwAAAz0AAAAAAAAAAAeAAAACFQAAAUoAAAY7AAADPZSMEXNob3dfaW50ZXJtZWRpYXRl +lImMBHpvb22USwOMC19fdmVyc2lvbl9flEsBjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3 +aWRnZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOUKYGUfZQojAZ2YWx1ZXOUfZQojBJ0YXJnZXRfY2xh +c3NfaW5kZXiUSwBoCksBdYwEdGltZZRHQdYqsOoF80WMB2NsYXNzZXOUXZQojAtJcmlzLXNldG9z +YZSMD0lyaXMtdmVyc2ljb2xvcpSMDklyaXMtdmlyZ2luaWNhlGV1YmF1Lg== + + gASV4wIAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lE6MCXNlbGVjdGlvbpROjBF0b29sdGlwX3No +b3dzX2FsbJSIjA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CM +DWNsYXNzX2RlbnNpdHmUiYwRaml0dGVyX2NvbnRpbnVvdXOUiYwLaml0dGVyX3NpemWUSwqME2xh +YmVsX29ubHlfc2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0 +aJRLCowJc2hvd19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVy +c2lvbl9flEsFjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdD +b250ZXh0lJOUKYGUfZQojAZ2YWx1ZXOUfZQojAphdHRyX2NvbG9ylIwEaXJpc5RLZYaUjAphdHRy +X2xhYmVslE5K/v///4aUjAphdHRyX3NoYXBllE5K/v///4aUjAlhdHRyX3NpemWUTkr+////hpSM +BmF0dHJfeJSMDHNlcGFsIGxlbmd0aJRLZoaUjAZhdHRyX3mUjAtzZXBhbCB3aWR0aJRLZoaUaAl9 +lGgVSwV1jA5vcmRlcmVkX2RvbWFpbpRdlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBhbCB3aWR0 +aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlIwEaXJpc5RLAYaUZYwK +YXR0cmlidXRlc5R9lChoMUsCaDNLAmg1SwJoN0sCaDlLAXWMBW1ldGFzlH2UjAR0aW1llEdB1iqw +5zw20nViYXUu + + gASVGAIAAAAAAAB9lCiMB2NvbXBhcmWUSwKMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjBNvcmRlcl9i +eV9pbXBvcnRhbmNllImMHG9yZGVyX2dyb3VwaW5nX2J5X2ltcG9ydGFuY2WUiYwTc2F2ZWRXaWRn +ZXRHZW9tZXRyeZROjBBzaG93X2Fubm90YXRpb25zlIiMC3Nob3dfbGFiZWxzlIiMDXNpZ190aHJl +c2hvbGSURz+pmZmZmZmajApzb3J0X2ZyZXFzlImMCHN0YXR0ZXN0lEsAjAlzdHJldGNoZWSUiIwL +X192ZXJzaW9uX1+USwGMEGNvbnRleHRfc2V0dGluZ3OUXZSMFW9yYW5nZXdpZGdldC5zZXR0aW5n +c5SMB0NvbnRleHSUk5QpgZR9lCiMBnZhbHVlc5R9lCiMCWdyb3VwX3ZhcpSMBGlyaXOUS2WGlIwJ +YXR0cmlidXRllIwMc2VwYWwgbGVuZ3RolEtmhpSMCmNvbmRpdGlvbnOUXZRoDEsBdYwOb3JkZXJl +ZF9kb21haW6UXZQoaBpLAoaUjAtzZXBhbCB3aWR0aJRLAoaUjAxwZXRhbCBsZW5ndGiUSwKGlIwL +cGV0YWwgd2lkdGiUSwKGlGgXSwGGlGWMCmF0dHJpYnV0ZXOUfZQoaBpLAmghSwJoI0sCaCVLAmgX +SwF1jAVtZXRhc5R9lIwEdGltZZRHQdYqsOoVKdx1YmF1Lg== + + {'auto_apply': True, 'binary_trees': True, 'controlAreaVisible': True, 'learner_name': 'Drevo', 'limit_depth': True, 'limit_majority': True, 'limit_min_internal': True, 'limit_min_leaf': True, 'max_depth': 100, 'min_internal': 5, 'min_leaf': 2, 'savedWidgetGeometry': b'\x01\xd9\xd0\xcb\x00\x03\x00\x00\x00\x00\x02\x90\x00\x00\x015\x00\x00\x04U\x00\x00\x02g\x00\x00\x02\x90\x00\x00\x015\x00\x00\x04U\x00\x00\x02g\x00\x00\x00\x00\x00\x00\x00\x00\x07\x80\x00\x00\x02\x90\x00\x00\x015\x00\x00\x04U\x00\x00\x02g', 'sufficient_majority': 95, '__version__': 1} + + + + + diff --git a/i18n/si/static/canvas/workflows/si/305-pca.ows b/i18n/si/static/canvas/workflows/si/305-pca.ows new file mode 100644 index 00000000000..7a1272b760e --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/305-pca.ows @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + Odpri, da si ogledaš diagram razpršenosti in interaktivno izbereš število komponent. + Izberi dve najboljši osnovni komponenti in preveri, ali so razredi iz vhodnega nabora podatkov dobro ločeni. + Gradnik Datoteka naloži nabor podatkov brown-selected iz molekularne biologije z 79 spremenljivkami, 186 primerki in 3 razredi. + + + + + + + gASVxwsAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZQojB5v +cmFuZ2V3aWRnZXQudXRpbHMuZmlsZWRpYWxvZ3OUjApSZWNlbnRQYXRolJOUKYGUfZQojAdhYnNw +YXRolIw3L1VzZXJzL2phbmV6L29yYW5nZTMvT3JhbmdlL2RhdGFzZXRzL2Jyb3duLXNlbGVjdGVk +LnRhYpSMBnByZWZpeJSMD3NhbXBsZS1kYXRhc2V0c5SMB3JlbHBhdGiUjBJicm93bi1zZWxlY3Rl +ZC50YWKUjAV0aXRsZZSMAJSMBXNoZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJoBimBlH2UKGgJjC0v +VXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUaAtoDGgNjAhpcmlz +LnRhYpRoD2gQaBFoEGgSTnViZYwLcmVjZW50X3VybHOUXZSME3NhdmVkV2lkZ2V0R2VvbWV0cnmU +Qy4B2dDLAAEAAAAAA/8AAAJcAAAF8AAABEMAAAP/AAACcgAABfAAAARDAAAAAAAAlIwLc2hlZXRf +bmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtfX3ZlcnNpb25f +X5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4 +dJSTlCmBlH2UKIwGdmFsdWVzlH2UKIwJdmFyaWFibGVzlF2UaB99lGgrXZQoXZQojAdhbHBoYSAw +lIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJDb250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiM +B2FscGhhIDeUaDNLAGgQiGVdlCiMCGFscGhhIDE0lGgzSwBoEIhlXZQojAhhbHBoYSAyMZRoM0sA +aBCIZV2UKIwIYWxwaGEgMjiUaDNLAGgQiGVdlCiMCGFscGhhIDM1lGgzSwBoEIhlXZQojAhhbHBo +YSA0MpRoM0sAaBCIZV2UKIwIYWxwaGEgNDmUaDNLAGgQiGVdlCiMCGFscGhhIDU2lGgzSwBoEIhl +XZQojAhhbHBoYSA2M5RoM0sAaBCIZV2UKIwIYWxwaGEgNzCUaDNLAGgQiGVdlCiMCGFscGhhIDc3 +lGgzSwBoEIhlXZQojAhhbHBoYSA4NJRoM0sAaBCIZV2UKIwIYWxwaGEgOTGUaDNLAGgQiGVdlCiM +CGFscGhhIDk4lGgzSwBoEIhlXZQojAlhbHBoYSAxMDWUaDNLAGgQiGVdlCiMCWFscGhhIDExMpRo +M0sAaBCIZV2UKIwJYWxwaGEgMTE5lGgzSwBoEIhlXZQojAVFbHUgMJRoM0sAaBCIZV2UKIwGRWx1 +IDMwlGgzSwBoEIhlXZQojAZFbHUgNjCUaDNLAGgQiGVdlCiMBkVsdSA5MJRoM0sAaBCIZV2UKIwH +RWx1IDEyMJRoM0sAaBCIZV2UKIwHRWx1IDE1MJRoM0sAaBCIZV2UKIwHRWx1IDE4MJRoM0sAaBCI +ZV2UKIwHRWx1IDIxMJRoM0sAaBCIZV2UKIwHRWx1IDI0MJRoM0sAaBCIZV2UKIwHRWx1IDI3MJRo +M0sAaBCIZV2UKIwHRWx1IDMwMJRoM0sAaBCIZV2UKIwHRWx1IDMzMJRoM0sAaBCIZV2UKIwHRWx1 +IDM2MJRoM0sAaBCIZV2UKIwHRWx1IDM5MJRoM0sAaBCIZV2UKIwIY2RjMTUgMTCUaDNLAGgQiGVd +lCiMCGNkYzE1IDMwlGgzSwBoEIhlXZQojAhjZGMxNSA1MJRoM0sAaBCIZV2UKIwIY2RjMTUgNzCU +aDNLAGgQiGVdlCiMCGNkYzE1IDkwlGgzSwBoEIhlXZQojAljZGMxNSAxMTCUaDNLAGgQiGVdlCiM +CWNkYzE1IDEzMJRoM0sAaBCIZV2UKIwJY2RjMTUgMTUwlGgzSwBoEIhlXZQojAljZGMxNSAxNzCU +aDNLAGgQiGVdlCiMCWNkYzE1IDE5MJRoM0sAaBCIZV2UKIwJY2RjMTUgMjEwlGgzSwBoEIhlXZQo +jAljZGMxNSAyMzCUaDNLAGgQiGVdlCiMCWNkYzE1IDI1MJRoM0sAaBCIZV2UKIwJY2RjMTUgMjcw +lGgzSwBoEIhlXZQojAljZGMxNSAyOTCUaDNLAGgQiGVdlCiMBXNwbyAwlGgzSwBoEIhlXZQojAVz +cG8gMpRoM0sAaBCIZV2UKIwFc3BvIDWUaDNLAGgQiGVdlCiMBXNwbyA3lGgzSwBoEIhlXZQojAVz +cG8gOZRoM0sAaBCIZV2UKIwGc3BvIDExlGgzSwBoEIhlXZQojAZzcG81IDKUaDNLAGgQiGVdlCiM +BnNwbzUgN5RoM0sAaBCIZV2UKIwHc3BvNSAxMZRoM0sAaBCIZV2UKIwKc3BvLSBlYXJseZRoM0sA +aBCIZV2UKIwIc3BvLSBtaWSUaDNLAGgQiGVdlCiMBmhlYXQgMJRoM0sAaBCIZV2UKIwHaGVhdCAx +MJRoM0sAaBCIZV2UKIwHaGVhdCAyMJRoM0sAaBCIZV2UKIwHaGVhdCA0MJRoM0sAaBCIZV2UKIwH +aGVhdCA4MJRoM0sAaBCIZV2UKIwIaGVhdCAxNjCUaDNLAGgQiGVdlCiMBmR0dCAxNZRoM0sAaBCI +ZV2UKIwGZHR0IDMwlGgzSwBoEIhlXZQojAZkdHQgNjCUaDNLAGgQiGVdlCiMB2R0dCAxMjCUaDNL +AGgQiGVdlCiMBmNvbGQgMJRoM0sAaBCIZV2UKIwHY29sZCAyMJRoM0sAaBCIZV2UKIwHY29sZCA0 +MJRoM0sAaBCIZV2UKIwIY29sZCAxNjCUaDNLAGgQiGVdlCiMBmRpYXUgYZRoM0sAaBCIZV2UKIwG +ZGlhdSBilGgzSwBoEIhlXZQojAZkaWF1IGOUaDNLAGgQiGVdlCiMBmRpYXUgZJRoM0sAaBCIZV2U +KIwGZGlhdSBllGgzSwBoEIhlXZQojAZkaWF1IGaUaDNLAGgQiGVdlCiMBmRpYXUgZ5RoM0sAaBCI +ZV2UKIwIZnVuY3Rpb26UaDGMEERpc2NyZXRlVmFyaWFibGWUk5RLAYwTUHJvdGVhcywgUmVzcCwg +Umlib5SJZV2UKIwEZ2VuZZRoMYwOU3RyaW5nVmFyaWFibGWUk5RLAmgQiWVlc2ghSwF1jAphdHRy +aWJ1dGVzlChoMEsChpRoNUsChpRoN0sChpRoOUsChpRoO0sChpRoPUsChpRoP0sChpRoQUsChpRo +Q0sChpRoRUsChpRoR0sChpRoSUsChpRoS0sChpRoTUsChpRoT0sChpRoUUsChpRoU0sChpRoVUsC +hpRoV0sChpRoWUsChpRoW0sChpRoXUsChpRoX0sChpRoYUsChpRoY0sChpRoZUsChpRoZ0sChpRo +aUsChpRoa0sChpRobUsChpRob0sChpRocUsChpRoc0sChpRodUsChpRod0sChpRoeUsChpRoe0sC +hpRofUsChpRof0sChpRogUsChpRog0sChpRohUsChpRoh0sChpRoiUsChpRoi0sChpRojUsChpRo +j0sChpRokUsChpRok0sChpRolUsChpRol0sChpRomUsChpRom0sChpRonUsChpRon0sChpRooUsC +hpRoo0sChpRopUsChpRop0sChpRoqUsChpRoq0sChpRorUsChpRor0sChpRosUsChpRos0sChpRo +tUsChpRot0sChpRouUsChpRou0sChpRovUsChpRov0sChpRowUsChpRow0sChpRoxUsChpRox0sC +hpRoyUsChpRoy0sChpRozUsChpRoz0sChpR0lIwFbWV0YXOUaNZLA4aUhZSMCmNsYXNzX3ZhcnOU +aNFdlCiMB1Byb3RlYXOUjARSZXNwlIwEUmlib5RlhpSFlIwSbW9kaWZpZWRfdmFyaWFibGVzlF2U +dWJhdS4= + + {'auto_commit': True, 'axis_labels': 10, 'controlAreaVisible': True, 'maxp': 20, 'ncomponents': 2, 'normalize': True, 'savedWidgetGeometry': None, 'variance_covered': 35, '__version__': 1} + gASV5QQAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAASoAAAA4wAAB90AAAOy +AAAEqAAAAPkAAAfdAAADsgAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiIwRaml0dGVyX2NvbnRpbnVvdXOUiIwLaml0dGVyX3NpemWUSwGME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UKIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJST +lCmBlH2UKIwGdmFsdWVzlH2UKIwKYXR0cl9jb2xvcpSMCGZ1bmN0aW9ulEtlhpSMCmF0dHJfbGFi +ZWyUTkr+////hpSMCmF0dHJfc2hhcGWUTkr+////hpSMCWF0dHJfc2l6ZZROSv7///+GlIwGYXR0 +cl94lIwDUEMxlEtmhpSMBmF0dHJfeZSMA1BDMpRLZoaUaAp9lGgWSwV1jAphdHRyaWJ1dGVzlH2U +KGgqSwJoLUsCaCFLAXWMBW1ldGFzlH2UjARnZW5llEsDc3ViaBspgZR9lChoMn2UjAR0aW1llEdB +1eCcEvjll2gwfZQojAhmdW5jdGlvbpRLAYwDUEMxlEsCjANQQzKUSwJ1jA5vcmRlcmVkX2RvbWFp +bpRdlChoO0sChpRoPEsChpRoOksBhpRlaB59lCiMC2F1dG9fc2FtcGxllIhK/v///4aUjAZhdHRy +X3iUjANQQzGUSwKGlIwRdG9vbGJhcl9zZWxlY3Rpb26USwBK/v///4aUjBNhdXRvX3NlbmRfc2Vs +ZWN0aW9ulIhK/v///4aUjAVncmFwaJR9lCiMC2FscGhhX3ZhbHVllEuASv7///+GlIwKYXR0cl9j +b2xvcpRoOksBhpSMEXRvb2x0aXBfc2hvd3NfYWxslIlK/v///4aUjAphdHRyX3NoYXBllIwAlEr+ +////hpSMC3Nob3dfbGVnZW5klIhK/v///4aUjAtqaXR0ZXJfc2l6ZZRLAUr+////hpSME2xhYmVs +X29ubHlfc2VsZWN0ZWSUiUr+////hpSMCXNob3dfZ3JpZJSJSv7///+GlIwKYXR0cl9sYWJlbJRo +VUr+////hpSMCWF0dHJfc2l6ZZRoVUr+////hpSMEWppdHRlcl9jb250aW51b3VzlIhK/v///4aU +jAtwb2ludF93aWR0aJRLCkr+////hpSMDWNsYXNzX2RlbnNpdHmUiEr+////hpR1jBNzYXZlZFdp +ZGdldEdlb21ldHJ5lGgFSv7///+GlIwGYXR0cl95lIwDUEMylEsChpRoIGhRaCdoYmglaFZoI2hg +aBZLBXV1YmV1Lg== + + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, 'stored_sort': [], '__version__': 1} + + + + + diff --git a/i18n/si/static/canvas/workflows/si/310-clustering.ows b/i18n/si/static/canvas/workflows/si/310-clustering.ows new file mode 100644 index 00000000000..61c2be318ab --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/310-clustering.ows @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + Datoteka prebere podatke. Poskusi to shemo z "brown-selected" podatki (iz zbirk podatkov, ki so priložene programu Orange). + Vizualiziraj podatkovne razdalje v toplotni gredi. + Izberi poljubni del dendrograma v Hierarhičnem gručenju, nato opazuj izbrane podatke v tabeli ali v katerem drugem gradniku za analizo. Odpri gradnik Hierarhično gručenje in gradnik Tabela (1), da to shemo spremeniš v interaktivno analizo podatkov. + Vsaka sprememba izbire v hierarhičnem gručenju se bo prenesla v gradnika Tabela (1) in Škatla z brki. + Hierarhično gručenje podatkov. + Izračuna razdalje med vzorci podatkov. + + + + + + + + + gASVlwsAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjExDOi9Vc2Vycy9BbmphL0RvY3VtZW50cy9QdW1pY2Uvb3JhbmdlMy1zaS9PcmFuZ2UvZGF0 +YXNldHMvYnJvd24tc2VsZWN0ZWQudGFilIwGcHJlZml4lIwPc2FtcGxlLWRhdGFzZXRzlIwHcmVs +cGF0aJSMEmJyb3duLXNlbGVjdGVkLnRhYpSMBXRpdGxllIwAlIwFc2hlZXSUaBCMC2ZpbGVfZm9y +bWF0lE51YmGMC3JlY2VudF91cmxzlF2UjBNzYXZlZFdpZGdldEdlb21ldHJ5lENCAdnQywADAAAA +AAISAAABMwAABIIAAANzAAACEwAAAVIAAASBAAADcgAAAAAAAAAAB4AAAAITAAABUgAABIEAAANy +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwGdmFsdWVzlH2UKIwJdmFyaWFibGVzlF2UaBt9lGgnXZQoXZQo +jAdhbHBoYSAwlIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJDb250aW51b3VzVmFyaWFibGWUk5RL +AGgQiGVdlCiMB2FscGhhIDeUaC9LAGgQiGVdlCiMCGFscGhhIDE0lGgvSwBoEIhlXZQojAhhbHBo +YSAyMZRoL0sAaBCIZV2UKIwIYWxwaGEgMjiUaC9LAGgQiGVdlCiMCGFscGhhIDM1lGgvSwBoEIhl +XZQojAhhbHBoYSA0MpRoL0sAaBCIZV2UKIwIYWxwaGEgNDmUaC9LAGgQiGVdlCiMCGFscGhhIDU2 +lGgvSwBoEIhlXZQojAhhbHBoYSA2M5RoL0sAaBCIZV2UKIwIYWxwaGEgNzCUaC9LAGgQiGVdlCiM +CGFscGhhIDc3lGgvSwBoEIhlXZQojAhhbHBoYSA4NJRoL0sAaBCIZV2UKIwIYWxwaGEgOTGUaC9L +AGgQiGVdlCiMCGFscGhhIDk4lGgvSwBoEIhlXZQojAlhbHBoYSAxMDWUaC9LAGgQiGVdlCiMCWFs +cGhhIDExMpRoL0sAaBCIZV2UKIwJYWxwaGEgMTE5lGgvSwBoEIhlXZQojAVFbHUgMJRoL0sAaBCI +ZV2UKIwGRWx1IDMwlGgvSwBoEIhlXZQojAZFbHUgNjCUaC9LAGgQiGVdlCiMBkVsdSA5MJRoL0sA +aBCIZV2UKIwHRWx1IDEyMJRoL0sAaBCIZV2UKIwHRWx1IDE1MJRoL0sAaBCIZV2UKIwHRWx1IDE4 +MJRoL0sAaBCIZV2UKIwHRWx1IDIxMJRoL0sAaBCIZV2UKIwHRWx1IDI0MJRoL0sAaBCIZV2UKIwH +RWx1IDI3MJRoL0sAaBCIZV2UKIwHRWx1IDMwMJRoL0sAaBCIZV2UKIwHRWx1IDMzMJRoL0sAaBCI +ZV2UKIwHRWx1IDM2MJRoL0sAaBCIZV2UKIwHRWx1IDM5MJRoL0sAaBCIZV2UKIwIY2RjMTUgMTCU +aC9LAGgQiGVdlCiMCGNkYzE1IDMwlGgvSwBoEIhlXZQojAhjZGMxNSA1MJRoL0sAaBCIZV2UKIwI +Y2RjMTUgNzCUaC9LAGgQiGVdlCiMCGNkYzE1IDkwlGgvSwBoEIhlXZQojAljZGMxNSAxMTCUaC9L +AGgQiGVdlCiMCWNkYzE1IDEzMJRoL0sAaBCIZV2UKIwJY2RjMTUgMTUwlGgvSwBoEIhlXZQojAlj +ZGMxNSAxNzCUaC9LAGgQiGVdlCiMCWNkYzE1IDE5MJRoL0sAaBCIZV2UKIwJY2RjMTUgMjEwlGgv +SwBoEIhlXZQojAljZGMxNSAyMzCUaC9LAGgQiGVdlCiMCWNkYzE1IDI1MJRoL0sAaBCIZV2UKIwJ +Y2RjMTUgMjcwlGgvSwBoEIhlXZQojAljZGMxNSAyOTCUaC9LAGgQiGVdlCiMBXNwbyAwlGgvSwBo +EIhlXZQojAVzcG8gMpRoL0sAaBCIZV2UKIwFc3BvIDWUaC9LAGgQiGVdlCiMBXNwbyA3lGgvSwBo +EIhlXZQojAVzcG8gOZRoL0sAaBCIZV2UKIwGc3BvIDExlGgvSwBoEIhlXZQojAZzcG81IDKUaC9L +AGgQiGVdlCiMBnNwbzUgN5RoL0sAaBCIZV2UKIwHc3BvNSAxMZRoL0sAaBCIZV2UKIwKc3BvLSBl +YXJseZRoL0sAaBCIZV2UKIwIc3BvLSBtaWSUaC9LAGgQiGVdlCiMBmhlYXQgMJRoL0sAaBCIZV2U +KIwHaGVhdCAxMJRoL0sAaBCIZV2UKIwHaGVhdCAyMJRoL0sAaBCIZV2UKIwHaGVhdCA0MJRoL0sA +aBCIZV2UKIwHaGVhdCA4MJRoL0sAaBCIZV2UKIwIaGVhdCAxNjCUaC9LAGgQiGVdlCiMBmR0dCAx +NZRoL0sAaBCIZV2UKIwGZHR0IDMwlGgvSwBoEIhlXZQojAZkdHQgNjCUaC9LAGgQiGVdlCiMB2R0 +dCAxMjCUaC9LAGgQiGVdlCiMBmNvbGQgMJRoL0sAaBCIZV2UKIwHY29sZCAyMJRoL0sAaBCIZV2U +KIwHY29sZCA0MJRoL0sAaBCIZV2UKIwIY29sZCAxNjCUaC9LAGgQiGVdlCiMBmRpYXUgYZRoL0sA +aBCIZV2UKIwGZGlhdSBilGgvSwBoEIhlXZQojAZkaWF1IGOUaC9LAGgQiGVdlCiMBmRpYXUgZJRo +L0sAaBCIZV2UKIwGZGlhdSBllGgvSwBoEIhlXZQojAZkaWF1IGaUaC9LAGgQiGVdlCiMBmRpYXUg +Z5RoL0sAaBCIZV2UKIwIZnVuY3Rpb26UaC2MEERpc2NyZXRlVmFyaWFibGWUk5RLAYwTUHJvdGVh +cywgUmVzcCwgUmlib5SJZV2UKIwEZ2VuZZRoLYwOU3RyaW5nVmFyaWFibGWUk5RLAmgQiWVlc2gd +SwF1jAphdHRyaWJ1dGVzlChoLEsChpRoMUsChpRoM0sChpRoNUsChpRoN0sChpRoOUsChpRoO0sC +hpRoPUsChpRoP0sChpRoQUsChpRoQ0sChpRoRUsChpRoR0sChpRoSUsChpRoS0sChpRoTUsChpRo +T0sChpRoUUsChpRoU0sChpRoVUsChpRoV0sChpRoWUsChpRoW0sChpRoXUsChpRoX0sChpRoYUsC +hpRoY0sChpRoZUsChpRoZ0sChpRoaUsChpRoa0sChpRobUsChpRob0sChpRocUsChpRoc0sChpRo +dUsChpRod0sChpRoeUsChpRoe0sChpRofUsChpRof0sChpRogUsChpRog0sChpRohUsChpRoh0sC +hpRoiUsChpRoi0sChpRojUsChpRoj0sChpRokUsChpRok0sChpRolUsChpRol0sChpRomUsChpRo +m0sChpRonUsChpRon0sChpRooUsChpRoo0sChpRopUsChpRop0sChpRoqUsChpRoq0sChpRorUsC +hpRor0sChpRosUsChpRos0sChpRotUsChpRot0sChpRouUsChpRou0sChpRovUsChpRov0sChpRo +wUsChpRow0sChpRoxUsChpRox0sChpRoyUsChpRoy0sChpR0lIwFbWV0YXOUaNJLA4aUhZSMCmNs +YXNzX3ZhcnOUaM1dlCiMB1Byb3RlYXOUjARSZXNwlIwEUmlib5RlhpSFlIwSbW9kaWZpZWRfdmFy +aWFibGVzlF2UdWJhdS4= + + {'autocommit': False, 'axis': 0, 'controlAreaVisible': True, 'metric_id': 0, 'savedWidgetGeometry': None, '__version__': 4} + gASV/AcAAAAAAAB9lCiMCmF1dG9jb21taXSUiIwLY29sb3JfZ2FtbWGURwAAAAAAAAAAjApjb2xv +cl9oaWdolEc/8AAAAAAAAIwJY29sb3JfbG93lEcAAAAAAAAAAIwSY29udHJvbEFyZWFWaXNpYmxl +lIiMDHBhbGV0dGVfbmFtZZSMFWxpbmVhcl9iZ3l3XzIwXzk4X2M2NpSMEXBlbmRpbmdfc2VsZWN0 +aW9ulF2UjBNzYXZlZFdpZGdldEdlb21ldHJ5lE6MB3NvcnRpbmeUSwCMC19fdmVyc2lvbl9flEsB +jBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250ZXh0lJOU +KYGUfZQojAphdHRyaWJ1dGVzlCiMB2FscGhhIDCUSwKGlIwHYWxwaGEgN5RLAoaUjAhhbHBoYSAx +NJRLAoaUjAhhbHBoYSAyMZRLAoaUjAhhbHBoYSAyOJRLAoaUjAhhbHBoYSAzNZRLAoaUjAhhbHBo +YSA0MpRLAoaUjAhhbHBoYSA0OZRLAoaUjAhhbHBoYSA1NpRLAoaUjAhhbHBoYSA2M5RLAoaUjAhh +bHBoYSA3MJRLAoaUjAhhbHBoYSA3N5RLAoaUjAhhbHBoYSA4NJRLAoaUjAhhbHBoYSA5MZRLAoaU +jAhhbHBoYSA5OJRLAoaUjAlhbHBoYSAxMDWUSwKGlIwJYWxwaGEgMTEylEsChpSMCWFscGhhIDEx +OZRLAoaUjAVFbHUgMJRLAoaUjAZFbHUgMzCUSwKGlIwGRWx1IDYwlEsChpSMBkVsdSA5MJRLAoaU +jAdFbHUgMTIwlEsChpSMB0VsdSAxNTCUSwKGlIwHRWx1IDE4MJRLAoaUjAdFbHUgMjEwlEsChpSM +B0VsdSAyNDCUSwKGlIwHRWx1IDI3MJRLAoaUjAdFbHUgMzAwlEsChpSMB0VsdSAzMzCUSwKGlIwH +RWx1IDM2MJRLAoaUjAdFbHUgMzkwlEsChpSMCGNkYzE1IDEwlEsChpSMCGNkYzE1IDMwlEsChpSM +CGNkYzE1IDUwlEsChpSMCGNkYzE1IDcwlEsChpSMCGNkYzE1IDkwlEsChpSMCWNkYzE1IDExMJRL +AoaUjAljZGMxNSAxMzCUSwKGlIwJY2RjMTUgMTUwlEsChpSMCWNkYzE1IDE3MJRLAoaUjAljZGMx +NSAxOTCUSwKGlIwJY2RjMTUgMjEwlEsChpSMCWNkYzE1IDIzMJRLAoaUjAljZGMxNSAyNTCUSwKG +lIwJY2RjMTUgMjcwlEsChpSMCWNkYzE1IDI5MJRLAoaUjAVzcG8gMJRLAoaUjAVzcG8gMpRLAoaU +jAVzcG8gNZRLAoaUjAVzcG8gN5RLAoaUjAVzcG8gOZRLAoaUjAZzcG8gMTGUSwKGlIwGc3BvNSAy +lEsChpSMBnNwbzUgN5RLAoaUjAdzcG81IDExlEsChpSMCnNwby0gZWFybHmUSwKGlIwIc3BvLSBt +aWSUSwKGlIwGaGVhdCAwlEsChpSMB2hlYXQgMTCUSwKGlIwHaGVhdCAyMJRLAoaUjAdoZWF0IDQw +lEsChpSMB2hlYXQgODCUSwKGlIwIaGVhdCAxNjCUSwKGlIwGZHR0IDE1lEsChpSMBmR0dCAzMJRL +AoaUjAZkdHQgNjCUSwKGlIwHZHR0IDEyMJRLAoaUjAZjb2xkIDCUSwKGlIwHY29sZCAyMJRLAoaU +jAdjb2xkIDQwlEsChpSMCGNvbGQgMTYwlEsChpSMBmRpYXUgYZRLAoaUjAZkaWF1IGKUSwKGlIwG +ZGlhdSBjlEsChpSMBmRpYXUgZJRLAoaUjAZkaWF1IGWUSwKGlIwGZGlhdSBmlEsChpSMBmRpYXUg +Z5RLAoaUdJSMCmNsYXNzX3ZhcnOUjAhmdW5jdGlvbpRLAYaUhZSMBW1ldGFzlIwEZ2VuZZRLA4aU +hZSMBnZhbHVlc5R9lCiMDmFubm90YXRpb25faWR4lEsASv7///+GlGgMSwF1jAR0aW1llEdB1eCV +dgEnhYwOb3JkZXJlZF9kb21haW6UXZQoaBVLAoaUaBdLAoaUaBlLAoaUaBtLAoaUaB1LAoaUaB9L +AoaUaCFLAoaUaCNLAoaUaCVLAoaUaCdLAoaUaClLAoaUaCtLAoaUaC1LAoaUaC9LAoaUaDFLAoaU +aDNLAoaUaDVLAoaUaDdLAoaUaDlLAoaUaDtLAoaUaD1LAoaUaD9LAoaUaEFLAoaUaENLAoaUaEVL +AoaUaEdLAoaUaElLAoaUaEtLAoaUaE1LAoaUaE9LAoaUaFFLAoaUaFNLAoaUaFVLAoaUaFdLAoaU +aFlLAoaUaFtLAoaUaF1LAoaUaF9LAoaUaGFLAoaUaGNLAoaUaGVLAoaUaGdLAoaUaGlLAoaUaGtL +AoaUaG1LAoaUaG9LAoaUaHFLAoaUaHNLAoaUaHVLAoaUaHdLAoaUaHlLAoaUaHtLAoaUaH1LAoaU +aH9LAoaUaIFLAoaUaINLAoaUaIVLAoaUaIdLAoaUaIlLAoaUaItLAoaUaI1LAoaUaI9LAoaUaJFL +AoaUaJNLAoaUaJVLAoaUaJdLAoaUaJlLAoaUaJtLAoaUaJ1LAoaUaJ9LAoaUaKFLAoaUaKNLAoaU +aKVLAoaUaKdLAoaUaKlLAoaUaKtLAoaUaK1LAoaUaK9LAoaUaLFLAoaUaLVLAYaUaLlLA4aUZXVi +YXUu + + gASVwQUAAAAAAAB9lCiMF2Fubm90YXRpb25faWZfZW51bWVyYXRllIwNxaB0ZXZpbMSNZW5qZZSM +E2Fubm90YXRpb25faWZfbmFtZXOUjAROYW1llIwKYXV0b2NvbW1pdJSIjBJjb250cm9sQXJlYVZp +c2libGWUiIwJY3V0X3JhdGlvlEdAUsAAAAAAAIwRbGFiZWxfb25seV9zdWJzZXSUiYwHbGlua2Fn +ZZRLAYwJbWF4X2RlcHRolEsKjAdwcnVuaW5nlEsAjBNzYXZlZFdpZGdldEdlb21ldHJ5lE6MEHNl +bGVjdGlvbl9tZXRob2SUSwCMBXRvcF9ulEsDjAt6b29tX2ZhY3RvcpRLAIwLX192ZXJzaW9uX1+U +SwKMFF9fc2Vzc2lvbl9zdGF0ZV9kYXRhlH2UjAd2ZXJzaW9ulEsASwBLAIeUc4wQY29udGV4dF9z +ZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJSTlCmBlH2UKIwGdmFs +dWVzlH2UKIwKYW5ub3RhdGlvbpSMBGdlbmWUS2eGlIwIY29sb3JfYnmUjAhmdW5jdGlvbpRLZYaU +aBBLAnWMCmF0dHJpYnV0ZXOUfZQojAdhbHBoYSAwlEsCjAdhbHBoYSA3lEsCjAhhbHBoYSAxNJRL +AowIYWxwaGEgMjGUSwKMCGFscGhhIDI4lEsCjAhhbHBoYSAzNZRLAowIYWxwaGEgNDKUSwKMCGFs +cGhhIDQ5lEsCjAhhbHBoYSA1NpRLAowIYWxwaGEgNjOUSwKMCGFscGhhIDcwlEsCjAhhbHBoYSA3 +N5RLAowIYWxwaGEgODSUSwKMCGFscGhhIDkxlEsCjAhhbHBoYSA5OJRLAowJYWxwaGEgMTA1lEsC +jAlhbHBoYSAxMTKUSwKMCWFscGhhIDExOZRLAowFRWx1IDCUSwKMBkVsdSAzMJRLAowGRWx1IDYw +lEsCjAZFbHUgOTCUSwKMB0VsdSAxMjCUSwKMB0VsdSAxNTCUSwKMB0VsdSAxODCUSwKMB0VsdSAy +MTCUSwKMB0VsdSAyNDCUSwKMB0VsdSAyNzCUSwKMB0VsdSAzMDCUSwKMB0VsdSAzMzCUSwKMB0Vs +dSAzNjCUSwKMB0VsdSAzOTCUSwKMCGNkYzE1IDEwlEsCjAhjZGMxNSAzMJRLAowIY2RjMTUgNTCU +SwKMCGNkYzE1IDcwlEsCjAhjZGMxNSA5MJRLAowJY2RjMTUgMTEwlEsCjAljZGMxNSAxMzCUSwKM +CWNkYzE1IDE1MJRLAowJY2RjMTUgMTcwlEsCjAljZGMxNSAxOTCUSwKMCWNkYzE1IDIxMJRLAowJ +Y2RjMTUgMjMwlEsCjAljZGMxNSAyNTCUSwKMCWNkYzE1IDI3MJRLAowJY2RjMTUgMjkwlEsCjAVz +cG8gMJRLAowFc3BvIDKUSwKMBXNwbyA1lEsCjAVzcG8gN5RLAowFc3BvIDmUSwKMBnNwbyAxMZRL +AowGc3BvNSAylEsCjAZzcG81IDeUSwKMB3NwbzUgMTGUSwKMCnNwby0gZWFybHmUSwKMCHNwby0g +bWlklEsCjAZoZWF0IDCUSwKMB2hlYXQgMTCUSwKMB2hlYXQgMjCUSwKMB2hlYXQgNDCUSwKMB2hl +YXQgODCUSwKMCGhlYXQgMTYwlEsCjAZkdHQgMTWUSwKMBmR0dCAzMJRLAowGZHR0IDYwlEsCjAdk +dHQgMTIwlEsCjAZjb2xkIDCUSwKMB2NvbGQgMjCUSwKMB2NvbGQgNDCUSwKMCGNvbGQgMTYwlEsC +jAZkaWF1IGGUSwKMBmRpYXUgYpRLAowGZGlhdSBjlEsCjAZkaWF1IGSUSwKMBmRpYXUgZZRLAowG +ZGlhdSBmlEsCjAZkaWF1IGeUSwKMCGZ1bmN0aW9ulEsBdYwFbWV0YXOUfZSMBGdlbmWUSwNzdWJh +dS4= + + {'compare': 1, 'controlAreaVisible': True, 'order_by_importance': False, 'order_grouping_by_importance': False, 'savedWidgetGeometry': None, 'show_annotations': True, 'show_labels': True, 'sig_threshold': 0.05, 'sort_freqs': False, 'stattest': 0, 'stretched': True, '__version__': 1, 'context_settings': []} + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': b'\x01\xd9\xd0\xcb\x00\x03\x00\x00\x00\x00\x01Z\x00\x00\x00\xdc\x00\x00\x04y\x00\x00\x02\xeb\x00\x00\x01Z\x00\x00\x00\xf8\x00\x00\x04y\x00\x00\x02\xeb\x00\x00\x00\x00\x00\x00\x00\x00\x05\xe8\x00\x00\x01Z\x00\x00\x00\xf8\x00\x00\x04y\x00\x00\x02\xeb', 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, 'stored_sort': [], '__version__': 1} + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, 'stored_sort': [], '__version__': 1} + + + + + diff --git a/i18n/si/static/canvas/workflows/si/410-feature-ranking.ows b/i18n/si/static/canvas/workflows/si/410-feature-ranking.ows new file mode 100644 index 00000000000..ec9011da643 --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/410-feature-ranking.ows @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + Manjkajoče vrednosti smo nadomestili, da smo lahko vizualizirali vse podatkovne točke. + Razsevni diagram z najbolj informativnimi spremenljivkami. Ali dobro ločujejo razrede? Odpri gradnik in preveri. + Prikaže ocene spremenljivk. Ta gradnik smo uporabili za izbiro dveh najbolj informativnih spremenljivk. + + + + + + + gASVxwsAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZQojB5v +cmFuZ2V3aWRnZXQudXRpbHMuZmlsZWRpYWxvZ3OUjApSZWNlbnRQYXRolJOUKYGUfZQojAdhYnNw +YXRolIw3L1VzZXJzL2phbmV6L29yYW5nZTMvT3JhbmdlL2RhdGFzZXRzL2Jyb3duLXNlbGVjdGVk +LnRhYpSMBnByZWZpeJSMD3NhbXBsZS1kYXRhc2V0c5SMB3JlbHBhdGiUjBJicm93bi1zZWxlY3Rl +ZC50YWKUjAV0aXRsZZSMAJSMBXNoZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJoBimBlH2UKGgJjC0v +VXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUaAtoDGgNjAhpcmlz +LnRhYpRoD2gQaBFoEGgSTnViZYwLcmVjZW50X3VybHOUXZSME3NhdmVkV2lkZ2V0R2VvbWV0cnmU +Qy4B2dDLAAEAAAAAA/8AAAJcAAAF8AAABEMAAAP/AAACcgAABfAAAARDAAAAAAAAlIwLc2hlZXRf +bmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtfX3ZlcnNpb25f +X5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4 +dJSTlCmBlH2UKIwGdmFsdWVzlH2UKIwJdmFyaWFibGVzlF2UaB99lGgrXZQoXZQojAdhbHBoYSAw +lIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJDb250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiM +B2FscGhhIDeUaDNLAGgQiGVdlCiMCGFscGhhIDE0lGgzSwBoEIhlXZQojAhhbHBoYSAyMZRoM0sA +aBCIZV2UKIwIYWxwaGEgMjiUaDNLAGgQiGVdlCiMCGFscGhhIDM1lGgzSwBoEIhlXZQojAhhbHBo +YSA0MpRoM0sAaBCIZV2UKIwIYWxwaGEgNDmUaDNLAGgQiGVdlCiMCGFscGhhIDU2lGgzSwBoEIhl +XZQojAhhbHBoYSA2M5RoM0sAaBCIZV2UKIwIYWxwaGEgNzCUaDNLAGgQiGVdlCiMCGFscGhhIDc3 +lGgzSwBoEIhlXZQojAhhbHBoYSA4NJRoM0sAaBCIZV2UKIwIYWxwaGEgOTGUaDNLAGgQiGVdlCiM +CGFscGhhIDk4lGgzSwBoEIhlXZQojAlhbHBoYSAxMDWUaDNLAGgQiGVdlCiMCWFscGhhIDExMpRo +M0sAaBCIZV2UKIwJYWxwaGEgMTE5lGgzSwBoEIhlXZQojAVFbHUgMJRoM0sAaBCIZV2UKIwGRWx1 +IDMwlGgzSwBoEIhlXZQojAZFbHUgNjCUaDNLAGgQiGVdlCiMBkVsdSA5MJRoM0sAaBCIZV2UKIwH +RWx1IDEyMJRoM0sAaBCIZV2UKIwHRWx1IDE1MJRoM0sAaBCIZV2UKIwHRWx1IDE4MJRoM0sAaBCI +ZV2UKIwHRWx1IDIxMJRoM0sAaBCIZV2UKIwHRWx1IDI0MJRoM0sAaBCIZV2UKIwHRWx1IDI3MJRo +M0sAaBCIZV2UKIwHRWx1IDMwMJRoM0sAaBCIZV2UKIwHRWx1IDMzMJRoM0sAaBCIZV2UKIwHRWx1 +IDM2MJRoM0sAaBCIZV2UKIwHRWx1IDM5MJRoM0sAaBCIZV2UKIwIY2RjMTUgMTCUaDNLAGgQiGVd +lCiMCGNkYzE1IDMwlGgzSwBoEIhlXZQojAhjZGMxNSA1MJRoM0sAaBCIZV2UKIwIY2RjMTUgNzCU +aDNLAGgQiGVdlCiMCGNkYzE1IDkwlGgzSwBoEIhlXZQojAljZGMxNSAxMTCUaDNLAGgQiGVdlCiM +CWNkYzE1IDEzMJRoM0sAaBCIZV2UKIwJY2RjMTUgMTUwlGgzSwBoEIhlXZQojAljZGMxNSAxNzCU +aDNLAGgQiGVdlCiMCWNkYzE1IDE5MJRoM0sAaBCIZV2UKIwJY2RjMTUgMjEwlGgzSwBoEIhlXZQo +jAljZGMxNSAyMzCUaDNLAGgQiGVdlCiMCWNkYzE1IDI1MJRoM0sAaBCIZV2UKIwJY2RjMTUgMjcw +lGgzSwBoEIhlXZQojAljZGMxNSAyOTCUaDNLAGgQiGVdlCiMBXNwbyAwlGgzSwBoEIhlXZQojAVz +cG8gMpRoM0sAaBCIZV2UKIwFc3BvIDWUaDNLAGgQiGVdlCiMBXNwbyA3lGgzSwBoEIhlXZQojAVz +cG8gOZRoM0sAaBCIZV2UKIwGc3BvIDExlGgzSwBoEIhlXZQojAZzcG81IDKUaDNLAGgQiGVdlCiM +BnNwbzUgN5RoM0sAaBCIZV2UKIwHc3BvNSAxMZRoM0sAaBCIZV2UKIwKc3BvLSBlYXJseZRoM0sA +aBCIZV2UKIwIc3BvLSBtaWSUaDNLAGgQiGVdlCiMBmhlYXQgMJRoM0sAaBCIZV2UKIwHaGVhdCAx +MJRoM0sAaBCIZV2UKIwHaGVhdCAyMJRoM0sAaBCIZV2UKIwHaGVhdCA0MJRoM0sAaBCIZV2UKIwH +aGVhdCA4MJRoM0sAaBCIZV2UKIwIaGVhdCAxNjCUaDNLAGgQiGVdlCiMBmR0dCAxNZRoM0sAaBCI +ZV2UKIwGZHR0IDMwlGgzSwBoEIhlXZQojAZkdHQgNjCUaDNLAGgQiGVdlCiMB2R0dCAxMjCUaDNL +AGgQiGVdlCiMBmNvbGQgMJRoM0sAaBCIZV2UKIwHY29sZCAyMJRoM0sAaBCIZV2UKIwHY29sZCA0 +MJRoM0sAaBCIZV2UKIwIY29sZCAxNjCUaDNLAGgQiGVdlCiMBmRpYXUgYZRoM0sAaBCIZV2UKIwG +ZGlhdSBilGgzSwBoEIhlXZQojAZkaWF1IGOUaDNLAGgQiGVdlCiMBmRpYXUgZJRoM0sAaBCIZV2U +KIwGZGlhdSBllGgzSwBoEIhlXZQojAZkaWF1IGaUaDNLAGgQiGVdlCiMBmRpYXUgZ5RoM0sAaBCI +ZV2UKIwIZnVuY3Rpb26UaDGMEERpc2NyZXRlVmFyaWFibGWUk5RLAYwTUHJvdGVhcywgUmVzcCwg +Umlib5SJZV2UKIwEZ2VuZZRoMYwOU3RyaW5nVmFyaWFibGWUk5RLAmgQiWVlc2ghSwF1jAphdHRy +aWJ1dGVzlChoMEsChpRoNUsChpRoN0sChpRoOUsChpRoO0sChpRoPUsChpRoP0sChpRoQUsChpRo +Q0sChpRoRUsChpRoR0sChpRoSUsChpRoS0sChpRoTUsChpRoT0sChpRoUUsChpRoU0sChpRoVUsC +hpRoV0sChpRoWUsChpRoW0sChpRoXUsChpRoX0sChpRoYUsChpRoY0sChpRoZUsChpRoZ0sChpRo +aUsChpRoa0sChpRobUsChpRob0sChpRocUsChpRoc0sChpRodUsChpRod0sChpRoeUsChpRoe0sC +hpRofUsChpRof0sChpRogUsChpRog0sChpRohUsChpRoh0sChpRoiUsChpRoi0sChpRojUsChpRo +j0sChpRokUsChpRok0sChpRolUsChpRol0sChpRomUsChpRom0sChpRonUsChpRon0sChpRooUsC +hpRoo0sChpRopUsChpRop0sChpRoqUsChpRoq0sChpRorUsChpRor0sChpRosUsChpRos0sChpRo +tUsChpRot0sChpRouUsChpRou0sChpRovUsChpRov0sChpRowUsChpRow0sChpRoxUsChpRox0sC +hpRoyUsChpRoy0sChpRozUsChpRoz0sChpR0lIwFbWV0YXOUaNZLA4aUhZSMCmNsYXNzX3ZhcnOU +aNFdlCiMB1Byb3RlYXOUjARSZXNwlIwEUmlib5RlhpSFlIwSbW9kaWZpZWRfdmFyaWFibGVzlF2U +dWJhdS4= + + gASVvAUAAAAAAAB9lCiMCmF1dG9fYXBwbHmUiIwSY29udHJvbEFyZWFWaXNpYmxllIiME3NhdmVk +V2lkZ2V0R2VvbWV0cnmUQ0IB2dDLAAMAAAAAArQAAAEBAAAHPAAABFsAAAK1AAABIAAABzsAAARa +AAAAAAAAAAAHgAAAArUAAAEgAAAHOwAABFqUjBBzZWxlY3RlZF9tZXRob2RzlI+UKIwNR2luaSBE +ZWNyZWFzZZSMFkluZm9ybWF0aW9uIEdhaW4gUmF0aW+UjAhSUmVsaWVmRpSMFVVuaXZhcmlhdGUg +UmVncmVzc2lvbpSQjAdzb3J0aW5nlEsASwGGlIwLX192ZXJzaW9uX1+USwSMEGNvbnRleHRfc2V0 +dGluZ3OUXZSMFW9yYW5nZXdpZGdldC5zZXR0aW5nc5SMB0NvbnRleHSUk5QpgZR9lCiMBnZhbHVl +c5R9lCiMCW5TZWxlY3RlZJRLAkr+////hpSMDnNlbGVjdGVkX2F0dHJzlF2UKIwHYWxwaGEgMJRL +ZoaUjAdhbHBoYSA3lEtmhpRlSv3///+GlIwPc2VsZWN0aW9uTWV0aG9klEsDSv7///+GlGgNSwR1 +jAphdHRyaWJ1dGVzlH2UKIwHYWxwaGEgMJRLAowHYWxwaGEgN5RLAowIYWxwaGEgMTSUSwKMCGFs +cGhhIDIxlEsCjAhhbHBoYSAyOJRLAowIYWxwaGEgMzWUSwKMCGFscGhhIDQylEsCjAhhbHBoYSA0 +OZRLAowIYWxwaGEgNTaUSwKMCGFscGhhIDYzlEsCjAhhbHBoYSA3MJRLAowIYWxwaGEgNzeUSwKM +CGFscGhhIDg0lEsCjAhhbHBoYSA5MZRLAowIYWxwaGEgOTiUSwKMCWFscGhhIDEwNZRLAowJYWxw +aGEgMTEylEsCjAlhbHBoYSAxMTmUSwKMBUVsdSAwlEsCjAZFbHUgMzCUSwKMBkVsdSA2MJRLAowG +RWx1IDkwlEsCjAdFbHUgMTIwlEsCjAdFbHUgMTUwlEsCjAdFbHUgMTgwlEsCjAdFbHUgMjEwlEsC +jAdFbHUgMjQwlEsCjAdFbHUgMjcwlEsCjAdFbHUgMzAwlEsCjAdFbHUgMzMwlEsCjAdFbHUgMzYw +lEsCjAdFbHUgMzkwlEsCjAhjZGMxNSAxMJRLAowIY2RjMTUgMzCUSwKMCGNkYzE1IDUwlEsCjAhj +ZGMxNSA3MJRLAowIY2RjMTUgOTCUSwKMCWNkYzE1IDExMJRLAowJY2RjMTUgMTMwlEsCjAljZGMx +NSAxNTCUSwKMCWNkYzE1IDE3MJRLAowJY2RjMTUgMTkwlEsCjAljZGMxNSAyMTCUSwKMCWNkYzE1 +IDIzMJRLAowJY2RjMTUgMjUwlEsCjAljZGMxNSAyNzCUSwKMCWNkYzE1IDI5MJRLAowFc3BvIDCU +SwKMBXNwbyAylEsCjAVzcG8gNZRLAowFc3BvIDeUSwKMBXNwbyA5lEsCjAZzcG8gMTGUSwKMBnNw +bzUgMpRLAowGc3BvNSA3lEsCjAdzcG81IDExlEsCjApzcG8tIGVhcmx5lEsCjAhzcG8tIG1pZJRL +AowGaGVhdCAwlEsCjAdoZWF0IDEwlEsCjAdoZWF0IDIwlEsCjAdoZWF0IDQwlEsCjAdoZWF0IDgw +lEsCjAhoZWF0IDE2MJRLAowGZHR0IDE1lEsCjAZkdHQgMzCUSwKMBmR0dCA2MJRLAowHZHR0IDEy +MJRLAowGY29sZCAwlEsCjAdjb2xkIDIwlEsCjAdjb2xkIDQwlEsCjAhjb2xkIDE2MJRLAowGZGlh +dSBhlEsCjAZkaWF1IGKUSwKMBmRpYXUgY5RLAowGZGlhdSBklEsCjAZkaWF1IGWUSwKMBmRpYXUg +ZpRLAowGZGlhdSBnlEsCjAhmdW5jdGlvbpRLAXWMBW1ldGFzlH2UjARnZW5llEsDc3ViYXUu + + gASVyQUAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAOtAAABrAAABmwAAASd +AAADrQAAAcIAAAZsAAAEnQAAAAAAAJSMCXNlbGVjdGlvbpROjBF0b29sdGlwX3Nob3dzX2FsbJSI +jA92aXN1YWxfc2V0dGluZ3OUfZSMBWdyYXBolH2UKIwLYWxwaGFfdmFsdWWUS4CMDWNsYXNzX2Rl +bnNpdHmUiYwRaml0dGVyX2NvbnRpbnVvdXOUiYwLaml0dGVyX3NpemWUSwqME2xhYmVsX29ubHlf +c2VsZWN0ZWSUiYwWb3J0aG9ub3JtYWxfcmVncmVzc2lvbpSJjAtwb2ludF93aWR0aJRLCowJc2hv +d19ncmlklImMC3Nob3dfbGVnZW5klIiMDXNob3dfcmVnX2xpbmWUiXWMC19fdmVyc2lvbl9flEsF +jBBjb250ZXh0X3NldHRpbmdzlF2UKIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJST +lCmBlH2UKIwGdmFsdWVzlH2UKIwKYXR0cl9jb2xvcpSMCGZ1bmN0aW9ulEtlhpSMCmF0dHJfbGFi +ZWyUTkr+////hpSMCmF0dHJfc2hhcGWUTkr+////hpSMCWF0dHJfc2l6ZZROSv7///+GlIwGYXR0 +cl94lIwHYWxwaGEgMJRLZoaUjAZhdHRyX3mUjAdhbHBoYSA3lEtmhpRoCn2UaBZLBXWMCmF0dHJp +YnV0ZXOUfZQoaCpLAmgtSwJoIUsBdYwFbWV0YXOUfZSMBGdlbmWUSwNzdWJoGymBlH2UKGgefZQo +jAphdHRyX2NvbG9ylIwIZnVuY3Rpb26US2WGlIwKYXR0cl9sYWJlbJROSv7///+GlIwKYXR0cl9z +aGFwZZROSv7///+GlIwJYXR0cl9zaXpllE5K/v///4aUjAZhdHRyX3iUjAZkaWF1IGaUS2aGlIwG +YXR0cl95lIwKc3BvLSBlYXJseZRLZoaUjAVncmFwaJR9lGgWSwV1aDB9lChoQksCaEVLAmg5SwF1 +aDJ9lIwEZ2VuZZRLA3N1YmgbKYGUfZQoaDJ9lGgefZQojBNhdXRvX3NlbmRfc2VsZWN0aW9ulIhK +/v///4aUjAthdXRvX3NhbXBsZZSISv7///+GlIwFZ3JhcGiUfZQojAphdHRyX2NvbG9ylIwIZnVu +Y3Rpb26USwGGlIwKYXR0cl9zaGFwZZSMAJRK/v///4aUjAtqaXR0ZXJfc2l6ZZRLCkr+////hpSM +C2FscGhhX3ZhbHVllEuASv7///+GlIwNY2xhc3NfZGVuc2l0eZSJSv7///+GlIwLc2hvd19sZWdl +bmSUiEr+////hpSMEXRvb2x0aXBfc2hvd3NfYWxslIlK/v///4aUjAlzaG93X2dyaWSUiUr+//// +hpSMCWF0dHJfc2l6ZZRoWkr+////hpSME2xhYmVsX29ubHlfc2VsZWN0ZWSUiUr+////hpSMEWpp +dHRlcl9jb250aW51b3VzlIlK/v///4aUjAphdHRyX2xhYmVslGhaSv7///+GlIwLcG9pbnRfd2lk +dGiUSwpK/v///4aUdYwTc2F2ZWRXaWRnZXRHZW9tZXRyeZRoBUr+////hpSMBmF0dHJfeJSMBmRp +YXUgZpRLAoaUjBF0b29sYmFyX3NlbGVjdGlvbpRLAEr+////hpSMBmF0dHJfeZSMCnNwby0gZWFy +bHmUSwKGlGg4aFhoP2hpaD1oW2g7aG9oFksFdYwEdGltZZRHQdXgoeNUYjVoMH2UKIwKc3BvLSBl +YXJseZRLAmhXSwGMBmRpYXUgZpRLAnWMDm9yZGVyZWRfZG9tYWlulF2UKGh/SwKGlGh+SwKGlGhX +SwGGlGV1YmV1Lg== + + gASVyQsAAAAAAAB9lCiMFV9kZWZhdWx0X21ldGhvZF9pbmRleJRLAowKYXV0b2NvbW1pdJSJjBJj +b250cm9sQXJlYVZpc2libGWUiIwVZGVmYXVsdF9udW1lcmljX3ZhbHVllEcAAAAAAAAAAIwMZGVm +YXVsdF90aW1llEsAjBNzYXZlZFdpZGdldEdlb21ldHJ5lEMuAdnQywABAAAAAAPNAAABugAABiMA +AAPCAAADzQAAAdAAAAYjAAADwgAAAAAAAJSMC19fdmVyc2lvbl9flEsBjBBjb250ZXh0X3NldHRp +bmdzlF2UKIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJSTlCmBlH2UKIwGdmFsdWVz +lH2UKIwaX3ZhcmlhYmxlX2ltcHV0YXRpb25fc3RhdGWUfZRK/P///4aUaAhLAXWMCmF0dHJpYnV0 +ZXOUfZQojAdhbHBoYSAwlEsCjAdhbHBoYSA3lEsCjAhhbHBoYSAxNJRLAowIYWxwaGEgMjGUSwKM +CGFscGhhIDI4lEsCjAhhbHBoYSAzNZRLAowIYWxwaGEgNDKUSwKMCGFscGhhIDQ5lEsCjAhhbHBo +YSA1NpRLAowIYWxwaGEgNjOUSwKMCGFscGhhIDcwlEsCjAhhbHBoYSA3N5RLAowIYWxwaGEgODSU +SwKMCGFscGhhIDkxlEsCjAhhbHBoYSA5OJRLAowJYWxwaGEgMTA1lEsCjAlhbHBoYSAxMTKUSwKM +CWFscGhhIDExOZRLAowFRWx1IDCUSwKMBkVsdSAzMJRLAowGRWx1IDYwlEsCjAZFbHUgOTCUSwKM +B0VsdSAxMjCUSwKMB0VsdSAxNTCUSwKMB0VsdSAxODCUSwKMB0VsdSAyMTCUSwKMB0VsdSAyNDCU +SwKMB0VsdSAyNzCUSwKMB0VsdSAzMDCUSwKMB0VsdSAzMzCUSwKMB0VsdSAzNjCUSwKMB0VsdSAz +OTCUSwKMCGNkYzE1IDEwlEsCjAhjZGMxNSAzMJRLAowIY2RjMTUgNTCUSwKMCGNkYzE1IDcwlEsC +jAhjZGMxNSA5MJRLAowJY2RjMTUgMTEwlEsCjAljZGMxNSAxMzCUSwKMCWNkYzE1IDE1MJRLAowJ +Y2RjMTUgMTcwlEsCjAljZGMxNSAxOTCUSwKMCWNkYzE1IDIxMJRLAowJY2RjMTUgMjMwlEsCjAlj +ZGMxNSAyNTCUSwKMCWNkYzE1IDI3MJRLAowJY2RjMTUgMjkwlEsCjAVzcG8gMJRLAowFc3BvIDKU +SwKMBXNwbyA1lEsCjAVzcG8gN5RLAowFc3BvIDmUSwKMBnNwbyAxMZRLAowGc3BvNSAylEsCjAZz +cG81IDeUSwKMB3NwbzUgMTGUSwKMCnNwby0gZWFybHmUSwKMCHNwby0gbWlklEsCjAZoZWF0IDCU +SwKMB2hlYXQgMTCUSwKMB2hlYXQgMjCUSwKMB2hlYXQgNDCUSwKMB2hlYXQgODCUSwKMCGhlYXQg +MTYwlEsCjAZkdHQgMTWUSwKMBmR0dCAzMJRLAowGZHR0IDYwlEsCjAdkdHQgMTIwlEsCjAZjb2xk +IDCUSwKMB2NvbGQgMjCUSwKMB2NvbGQgNDCUSwKMCGNvbGQgMTYwlEsCjAZkaWF1IGGUSwKMBmRp +YXUgYpRLAowGZGlhdSBjlEsCjAZkaWF1IGSUSwKMBmRpYXUgZZRLAowGZGlhdSBmlEsCjAZkaWF1 +IGeUSwKMCGZ1bmN0aW9ulEsBdYwFbWV0YXOUfZSMBGdlbmWUSwNzdWJoDSmBlH2UKGhnfZRoEH2U +KIwTc2F2ZWRXaWRnZXRHZW9tZXRyeZRoB0r+////hpSMEHZhcmlhYmxlX21ldGhvZHOUfZRK/v// +/4aUjBVfZGVmYXVsdF9tZXRob2RfaW5kZXiUSwJK/v///4aUjA1kZWZhdWx0X3ZhbHVllEcAAAAA +AAAAAEr+////hpSMCmF1dG9jb21taXSUiUr+////hpRoCEsBdYwEdGltZZRHQdXgod3mM1FoFX2U +KIwHYWxwaGEgMJRLAowGRWx1IDYwlEsCjAZkaWF1IGOUSwKMBnNwbzUgMpRLAowJY2RjMTUgMTcw +lEsCjAljZGMxNSAxNTCUSwKMCWNkYzE1IDIzMJRLAowFRWx1IDCUSwKMBXNwbyA1lEsCjAVzcG8g +N5RLAowIYWxwaGEgNTaUSwKMCGFscGhhIDYzlEsCjAdFbHUgMTUwlEsCjAdjb2xkIDIwlEsCjAdF +bHUgMjQwlEsCjAdoZWF0IDEwlEsCjAdFbHUgMzMwlEsCjAljZGMxNSAxMTCUSwKMB3NwbzUgMTGU +SwKMB0VsdSAzMDCUSwKMB0VsdSAxMjCUSwKMCGFscGhhIDM1lEsCjAdoZWF0IDgwlEsCjAhhbHBo +YSAxNJRLAowHaGVhdCAyMJRLAowGRWx1IDMwlEsCjAhjZGMxNSAzMJRLAowIY2RjMTUgOTCUSwKM +BnNwbzUgN5RLAowGZHR0IDYwlEsCjAZkdHQgMzCUSwKMBmRpYXUgZZRLAowJYWxwaGEgMTEylEsC +jAZkaWF1IGKUSwKMCGFscGhhIDc3lEsCjAhhbHBoYSAyMZRLAowJY2RjMTUgMjkwlEsCjAhhbHBo +YSAyOJRLAowFc3BvIDCUSwKMBkVsdSA5MJRLAowGaGVhdCAwlEsCjAZjb2xkIDCUSwKMB2R0dCAx +MjCUSwKMCWNkYzE1IDI3MJRLAowJY2RjMTUgMjUwlEsCjAlhbHBoYSAxMTmUSwKMBmRpYXUgYZRL +AowIYWxwaGEgNDmUSwKMB0VsdSAzNjCUSwKMB0VsdSAyMTCUSwKMBmRpYXUgZpRLAowHaGVhdCA0 +MJRLAowKc3BvLSBlYXJseZRLAowIY2RjMTUgMTCUSwKMB2FscGhhIDeUSwKMB0VsdSAzOTCUSwKM +CWNkYzE1IDEzMJRLAowIZnVuY3Rpb26USwGMBmRpYXUgZ5RLAowIc3BvLSBtaWSUSwKMCGFscGhh +IDQylEsCjAdFbHUgMjcwlEsCjAdjb2xkIDQwlEsCjAhhbHBoYSA5MZRLAowIaGVhdCAxNjCUSwKM +BXNwbyA5lEsCjAhhbHBoYSA5OJRLAowIYWxwaGEgODSUSwKMCGNkYzE1IDcwlEsCjAZzcG8gMTGU +SwKMCGFscGhhIDcwlEsCjAdFbHUgMTgwlEsCjAhjZGMxNSA1MJRLAowIY29sZCAxNjCUSwKMBmRp +YXUgZJRLAowGZHR0IDE1lEsCjAVzcG8gMpRLAowJY2RjMTUgMjEwlEsCjAljZGMxNSAxOTCUSwKM +CWFscGhhIDEwNZRLAnWMDm9yZGVyZWRfZG9tYWlulF2UKGh7SwKGlGixSwKGlGiSSwKGlGieSwKG +lGigSwKGlGiQSwKGlGi3SwKGlGiqSwKGlGiFSwKGlGiGSwKGlGjBSwKGlGidSwKGlGi+SwKGlGi6 +SwKGlGi9SwKGlGjKSwKGlGibSwKGlGioSwKGlGiCSwKGlGiUSwKGlGh8SwKGlGiiSwKGlGiPSwKG +lGiHSwKGlGjCSwKGlGisSwKGlGiJSwKGlGi4SwKGlGiOSwKGlGiLSwKGlGirSwKGlGiySwKGlGiw +SwKGlGiVSwKGlGjDSwKGlGi/SwKGlGiWSwKGlGiMSwKGlGizSwKGlGiASwKGlGh/SwKGlGjJSwKG +lGjISwKGlGiBSwKGlGinSwKGlGimSwKGlGifSwKGlGihSwKGlGjHSwKGlGiDSwKGlGiESwKGlGi8 +SwKGlGjASwKGlGh+SwKGlGiXSwKGlGiNSwKGlGivSwKGlGi2SwKGlGijSwKGlGiKSwKGlGiTSwKG +lGiuSwKGlGiRSwKGlGi7SwKGlGjGSwKGlGiZSwKGlGiYSwKGlGilSwKGlGikSwKGlGiISwKGlGi5 +SwKGlGjESwKGlGipSwKGlGicSwKGlGh9SwKGlGjFSwKGlGiaSwKGlGitSwKGlGi1SwKGlGi0SwGG +lGV1YmV1Lg== + + + + + + diff --git a/i18n/si/static/canvas/workflows/si/450-cross-validation.ows b/i18n/si/static/canvas/workflows/si/450-cross-validation.ows new file mode 100644 index 00000000000..f58605bbbe2 --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/450-cross-validation.ows @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + Izberi zbirko podatkov z oznako razreda. Recimo "iris.tab" iz zbirke podatkov v dokumentaciji. + Vedno je dobro najprej preveriti podatke. + Izberi celico v Matriki zmot, da pridobiš ustrezne primerke podatkov. Poglej jih v preglednici. + Uporabi Matriko zmot za dodatno analizo rezultatov prečnega preverjanja. + Tu se izvede prečno preverjanje. Z dvojnim klikom si lahko ogledaš rezultate napovedne točnosti. + Pri prečnem preverjanju je lahko hkrati ocenjenih več modelov (učnih algoritmov). + + + + + + + + + + gASVvAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWg +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwEdGltZZRHQdanFjFxv6WMBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoKF2UKF2UKIwMc2VwYWwgbGVuZ3RolIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJD +b250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGgwSwBoEIhlXZQojAxw +ZXRhbCBsZW5ndGiUaDBLAGgQiGVdlCiMC3BldGFsIHdpZHRolGgwSwBoEIhlXZQojARpcmlzlGgu +jBBEaXNjcmV0ZVZhcmlhYmxllJOUSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIEly +aXMtdmlyZ2luaWNhlIllZXNoHUsBdYwKYXR0cmlidXRlc5QojAxzZXBhbCBsZW5ndGiUSwKGlIwL +c2VwYWwgd2lkdGiUSwKGlIwMcGV0YWwgbGVuZ3RolEsChpSMC3BldGFsIHdpZHRolEsChpR0lIwF +bWV0YXOUKYwKY2xhc3NfdmFyc5SMBGlyaXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMtdmVyc2lj +b2xvcpSMDklyaXMtdmlyZ2luaWNhlGWGlIWUjBJtb2RpZmllZF92YXJpYWJsZXOUXZR1YmF1Lg== + + gASVqQMAAAAAAAB9lCiMFGNvbXBhcmlzb25fY3JpdGVyaW9ulEsAjBJjb250cm9sQXJlYVZpc2li +bGWUiIwNY3Zfc3RyYXRpZmllZJSIjAduX2ZvbGRzlEsDjAluX3JlcGVhdHOUSwOMCnJlc2FtcGxp +bmeUSwCMBHJvcGWURz+5mZmZmZmajAtzYW1wbGVfc2l6ZZRLCYwTc2F2ZWRXaWRnZXRHZW9tZXRy +eZRDQgHZ0MsAAwAAAAADcQAAAmgAAAZ+AAAD+wAAA3IAAAKHAAAGfQAAA/oAAAAAAAAAAAeAAAAD +cgAAAocAAAZ9AAAD+pSMEnNodWZmbGVfc3RyYXRpZmllZJSIjAh1c2Vfcm9wZZSJjAtzY29yZV90 +YWJsZZR9lIwQc2hvd19zY29yZV9oaW50c5R9lCiMBk1vZGVsX5SIjAZUcmFpbl+UiYwFVGVzdF+U +iYwCQ0GUiIwXUHJlY2lzaW9uUmVjYWxsRlN1cHBvcnSUiIwLVGFyZ2V0U2NvcmWUiIwJUHJlY2lz +aW9ulIiMBlJlY2FsbJSIjAJGMZSIjANBVUOUiIwHTG9nTG9zc5SJjAtTcGVjaWZpY2l0eZSJjBdN +YXR0aGV3c0NvcnJDb2VmZmljaWVudJSIjANNU0WUiIwEUk1TRZSIjANNQUWUiIwCUjKUiIwGQ1ZS +TVNFlImMD0NsdXN0ZXJpbmdTY29yZZSIjApTaWxob3VldHRllIiMF0FkanVzdGVkTXV0dWFsSW5m +b1Njb3JllIh1c4wLX192ZXJzaW9uX1+USwSMEGNvbnRleHRfc2V0dGluZ3OUXZSMFW9yYW5nZXdp +ZGdldC5zZXR0aW5nc5SMB0NvbnRleHSUk5QpgZR9lCiMBHRpbWWUR0HWpxYxhRFijAZ2YWx1ZXOU +fZQojA9jbGFzc19zZWxlY3Rpb26UjCcoQnJleiwgcG9rYcW+aSBwb3ByZcSNamUgcHJlayByYXpy +ZWRvdimUSv////+GlIwMZm9sZF9mZWF0dXJllE5K/v///4aUjBVmb2xkX2ZlYXR1cmVfc2VsZWN0 +ZWSUiUr+////hpRoDX2UaCZLBHWMCmF0dHJpYnV0ZXOUKIwMc2VwYWwgbGVuZ3RolEsChpSMC3Nl +cGFsIHdpZHRolEsChpSMDHBldGFsIGxlbmd0aJRLAoaUjAtwZXRhbCB3aWR0aJRLAoaUdJSMBW1l +dGFzlCmMCmNsYXNzX3ZhcnOUjARpcmlzlEsBhpSFlHViYXUu + + {'C_index': 61, 'auto_apply': True, 'class_weight': False, 'controlAreaVisible': True, 'learner_name': 'Logistična regresija', 'penalty_type': 1, 'savedWidgetGeometry': None, '__version__': 2} + {'auto_apply': True, 'class_weight': False, 'controlAreaVisible': True, 'index_output': 0, 'learner_name': 'Naključni gozd', 'max_depth': 3, 'max_features': 5, 'min_samples_split': 5, 'n_estimators': 10, 'savedWidgetGeometry': None, 'use_max_depth': False, 'use_max_features': False, 'use_min_samples_split': True, 'use_random_state': False, '__version__': 1} + {'C': 1.0, 'auto_apply': True, 'coef0': 0.0, 'controlAreaVisible': True, 'degree': 3, 'epsilon': 0.1, 'gamma': 0.0, 'kernel_type': 0, 'learner_name': 'SVM', 'limit_iter': True, 'max_iter': 100, 'nu': 0.5, 'nu_C': 1.0, 'savedWidgetGeometry': None, 'svm_type': 0, 'tol': 0.001, '__version__': 1} + gASVjAEAAAAAAAB9lCiMEmFwcGVuZF9wcmVkaWN0aW9uc5SIjBRhcHBlbmRfcHJvYmFiaWxpdGll +c5SJjAphdXRvY29tbWl0lIiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21l +dHJ5lEMuAdnQywABAAAAAAFXAAAAwQAABEQAAALAAAABVwAAANcAAAREAAACwAAAAAAAAJSMEHNl +bGVjdGVkX2xlYXJuZXKUXZRLAGGMEXNlbGVjdGVkX3F1YW50aXR5lEsAjAtfX3ZlcnNpb25fX5RL +AYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdzlIwHQ29udGV4dJST +lCmBlH2UKIwHY2xhc3Nlc5RdlCiMC0lyaXMtc2V0b3NhlIwPSXJpcy12ZXJzaWNvbG9ylIwOSXJp +cy12aXJnaW5pY2GUZYwEdGltZZRHQdanFjGoUuOMBnZhbHVlc5R9lCiMCXNlbGVjdGlvbpSPlGgK +SwF1dWJhdS4= + + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, 'stored_sort': [], '__version__': 1} + {'auto_commit': True, 'color_by_class': True, 'controlAreaVisible': True, 'savedWidgetGeometry': None, 'select_rows': True, 'show_attribute_labels': True, 'show_distributions': False, 'stored_selection': {'rows': [], 'columns': []}, 'stored_sort': [], '__version__': 1} + + + + + diff --git a/i18n/si/static/canvas/workflows/si/470-misclassification-scatterplot.ows b/i18n/si/static/canvas/workflows/si/470-misclassification-scatterplot.ows new file mode 100644 index 00000000000..9d53ada4a64 --- /dev/null +++ b/i18n/si/static/canvas/workflows/si/470-misclassification-scatterplot.ows @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + Prikazuje različne vrste napačnih klasifikacij. Pri podatkovni zbirki Iris je Iris virginica zamenjan z versicolor in obratno. + Napačne klasifikacije za podatkovno zbirko Iris so najbolje vidne pri projekciji dolžine (length) in širine (width) cvetnega lista. + Logistično regresijo zamenjaj s poljubno drugo klasifikacijsko metodo. + + + + + + + gASVvAMAAAAAAAB9lCiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjAxyZWNlbnRfcGF0aHOUXZSMHm9y +YW5nZXdpZGdldC51dGlscy5maWxlZGlhbG9nc5SMClJlY2VudFBhdGiUk5QpgZR9lCiMB2Fic3Bh +dGiUjC0vVXNlcnMvamFuZXovb3JhbmdlMy9PcmFuZ2UvZGF0YXNldHMvaXJpcy50YWKUjAZwcmVm +aXiUjA9zYW1wbGUtZGF0YXNldHOUjAdyZWxwYXRolIwIaXJpcy50YWKUjAV0aXRsZZSMAJSMBXNo +ZWV0lGgQjAtmaWxlX2Zvcm1hdJROdWJhjAtyZWNlbnRfdXJsc5RdlIwTc2F2ZWRXaWRnZXRHZW9t +ZXRyeZRDMgHZ0MsAAgAAAAACEwAAATwAAAQlAAADUgAAAhMAAAFSAAAEJQAAA1IAAAAAAAAAAAWg +lIwLc2hlZXRfbmFtZXOUfZSMBnNvdXJjZZRLAIwDdXJslGgQjA1kb21haW5fZWRpdG9ylH2UjAtf +X3ZlcnNpb25fX5RLAYwQY29udGV4dF9zZXR0aW5nc5RdlIwVb3Jhbmdld2lkZ2V0LnNldHRpbmdz +lIwHQ29udGV4dJSTlCmBlH2UKIwEdGltZZRHQdanFk28/Q2MBnZhbHVlc5R9lCiMCXZhcmlhYmxl +c5RdlGgbfZRoKF2UKF2UKIwMc2VwYWwgbGVuZ3RolIwUT3JhbmdlLmRhdGEudmFyaWFibGWUjBJD +b250aW51b3VzVmFyaWFibGWUk5RLAGgQiGVdlCiMC3NlcGFsIHdpZHRolGgwSwBoEIhlXZQojAxw +ZXRhbCBsZW5ndGiUaDBLAGgQiGVdlCiMC3BldGFsIHdpZHRolGgwSwBoEIhlXZQojARpcmlzlGgu +jBBEaXNjcmV0ZVZhcmlhYmxllJOUSwGMLElyaXMtc2V0b3NhLCBJcmlzLXZlcnNpY29sb3IsIEly +aXMtdmlyZ2luaWNhlIllZXNoHUsBdYwKYXR0cmlidXRlc5QojAxzZXBhbCBsZW5ndGiUSwKGlIwL +c2VwYWwgd2lkdGiUSwKGlIwMcGV0YWwgbGVuZ3RolEsChpSMC3BldGFsIHdpZHRolEsChpR0lIwF +bWV0YXOUKYwKY2xhc3NfdmFyc5SMBGlyaXOUXZQojAtJcmlzLXNldG9zYZSMD0lyaXMtdmVyc2lj +b2xvcpSMDklyaXMtdmlyZ2luaWNhlGWGlIWUjBJtb2RpZmllZF92YXJpYWJsZXOUXZR1YmF1Lg== + + gASVlQMAAAAAAAB9lCiMFGNvbXBhcmlzb25fY3JpdGVyaW9ulEsAjBJjb250cm9sQXJlYVZpc2li +bGWUiIwNY3Zfc3RyYXRpZmllZJSIjAduX2ZvbGRzlEsDjAluX3JlcGVhdHOUSwOMCnJlc2FtcGxp +bmeUSwCMBHJvcGWURz+5mZmZmZmajAtzYW1wbGVfc2l6ZZRLCYwTc2F2ZWRXaWRnZXRHZW9tZXRy +eZRDLgHZ0MsAAQAAAAADcgAAAnEAAAZ9AAAD+gAAA3IAAAKHAAAGfQAAA/oAAAAAAACUjBJzaHVm +ZmxlX3N0cmF0aWZpZWSUiIwIdXNlX3JvcGWUiYwLc2NvcmVfdGFibGWUfZSMEHNob3dfc2NvcmVf +aGludHOUfZQojAZNb2RlbF+UiIwGVHJhaW5flImMBVRlc3RflImMAkNBlIiMF1ByZWNpc2lvblJl +Y2FsbEZTdXBwb3J0lIiMC1RhcmdldFNjb3JllIiMCVByZWNpc2lvbpSIjAZSZWNhbGyUiIwCRjGU +iIwDQVVDlIiMB0xvZ0xvc3OUiYwLU3BlY2lmaWNpdHmUiYwXTWF0dGhld3NDb3JyQ29lZmZpY2ll +bnSUiIwDTVNFlIiMBFJNU0WUiIwDTUFFlIiMAlIylIiMBkNWUk1TRZSJjA9DbHVzdGVyaW5nU2Nv +cmWUiIwKU2lsaG91ZXR0ZZSIjBdBZGp1c3RlZE11dHVhbEluZm9TY29yZZSIdXOMC19fdmVyc2lv +bl9flEsEjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0dGluZ3OUjAdDb250 +ZXh0lJOUKYGUfZQojAR0aW1llEdB1qcWTcZskIwGdmFsdWVzlH2UKIwPY2xhc3Nfc2VsZWN0aW9u +lIwnKEJyZXosIHBva2HFvmkgcG9wcmXEjWplIHByZWsgcmF6cmVkb3YplEr/////hpSMDGZvbGRf +ZmVhdHVyZZROSv7///+GlIwVZm9sZF9mZWF0dXJlX3NlbGVjdGVklIlK/v///4aUaA19lGgmSwR1 +jAphdHRyaWJ1dGVzlCiMDHNlcGFsIGxlbmd0aJRLAoaUjAtzZXBhbCB3aWR0aJRLAoaUjAxwZXRh +bCBsZW5ndGiUSwKGlIwLcGV0YWwgd2lkdGiUSwKGlHSUjAVtZXRhc5QpjApjbGFzc192YXJzlIwE +aXJpc5RLAYaUhZR1YmF1Lg== + + {'C_index': 61, 'auto_apply': True, 'class_weight': False, 'controlAreaVisible': True, 'learner_name': 'Logistična regresija', 'penalty_type': 1, 'savedWidgetGeometry': b'\x01\xd9\xd0\xcb\x00\x01\x00\x00\x00\x00\x04b\x00\x00\x02/\x00\x00\x05\x8e\x00\x00\x03L\x00\x00\x04b\x00\x00\x02E\x00\x00\x05\x8e\x00\x00\x03L\x00\x00\x00\x00\x00\x00', '__version__': 2} + gASV/AEAAAAAAAB9lCiMEmFwcGVuZF9wcmVkaWN0aW9uc5SIjBRhcHBlbmRfcHJvYmFiaWxpdGll +c5SJjAphdXRvY29tbWl0lIiMEmNvbnRyb2xBcmVhVmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21l +dHJ5lENCAdnQywADAAAAAAOAAAABtQAABm8AAAPBAAADgQAAAdQAAAZuAAADwAAAAAAAAAAAB4AA +AAOBAAAB1AAABm4AAAPAlIwQc2VsZWN0ZWRfbGVhcm5lcpSMB2NvcHlyZWeUjA5fcmVjb25zdHJ1 +Y3RvcpSTlIwIYnVpbHRpbnOUjARsaXN0lJOUaA0ph5RSlEsAYYwRc2VsZWN0ZWRfcXVhbnRpdHmU +SwCMC19fdmVyc2lvbl9flEsBjBBjb250ZXh0X3NldHRpbmdzlF2UjBVvcmFuZ2V3aWRnZXQuc2V0 +dGluZ3OUjAdDb250ZXh0lJOUKYGUfZQojAR0aW1llEdB1qcWTdButYwHY2xhc3Nlc5RdlCiMC0ly +aXMtc2V0b3NhlIwPSXJpcy12ZXJzaWNvbG9ylIwOSXJpcy12aXJnaW5pY2GUZYwGdmFsdWVzlH2U +KIwJc2VsZWN0aW9ulI+UKEsASwGGlEsASwKGlEsBSwKGlEsCSwGGlEsBSwCGlEsCSwCGlJBoEUsB +dXViYXUu + + gASVJwMAAAAAAAB9lCiMC2F1dG9fY29tbWl0lIiMC2F1dG9fc2FtcGxllIiMEmNvbnRyb2xBcmVh +VmlzaWJsZZSIjBNzYXZlZFdpZGdldEdlb21ldHJ5lENCAdnQywADAAAAAAIlAAAA5wAABVwAAAPA +AAACJgAAAQYAAAVbAAADvwAAAAAAAAAAB4AAAAImAAABBgAABVsAAAO/lIwJc2VsZWN0aW9ulE6M +EXRvb2x0aXBfc2hvd3NfYWxslIiMD3Zpc3VhbF9zZXR0aW5nc5R9lIwFZ3JhcGiUfZQojAthbHBo +YV92YWx1ZZRLgIwNY2xhc3NfZGVuc2l0eZSIjBFqaXR0ZXJfY29udGludW91c5SIjAtqaXR0ZXJf +c2l6ZZRLAYwTbGFiZWxfb25seV9zZWxlY3RlZJSJjBZvcnRob25vcm1hbF9yZWdyZXNzaW9ulImM +C3BvaW50X3dpZHRolEsKjAlzaG93X2dyaWSUiYwLc2hvd19sZWdlbmSUiIwNc2hvd19yZWdfbGlu +ZZSJdYwLX192ZXJzaW9uX1+USwWMEGNvbnRleHRfc2V0dGluZ3OUXZSMFW9yYW5nZXdpZGdldC5z +ZXR0aW5nc5SMB0NvbnRleHSUk5QpgZR9lCiMDm9yZGVyZWRfZG9tYWlulF2UKIwMc2VwYWwgbGVu +Z3RolEsChpSMC3NlcGFsIHdpZHRolEsChpSMDHBldGFsIGxlbmd0aJRLAoaUjAtwZXRhbCB3aWR0 +aJRLAoaUjARpcmlzlEsBhpRljAphdHRyaWJ1dGVzlH2UKGggSwJoJksCaCRLAmgiSwJoKEsBdYwG +dmFsdWVzlH2UKIwKYXR0cl9jb2xvcpSMBGlyaXOUS2WGlIwKYXR0cl9sYWJlbJROSv7///+GlIwK +YXR0cl9zaGFwZZROSv7///+GlIwJYXR0cl9zaXpllE5K/v///4aUjAZhdHRyX3iUjAxwZXRhbCBs +ZW5ndGiUS2aGlIwGYXR0cl95lIwLcGV0YWwgd2lkdGiUS2aGlGgKfZRoFksFdYwFbWV0YXOUfZSM +BHRpbWWUR0HWpxZN2ADNdWJhdS4= + + + + + + diff --git a/i18n/si/tests-config.yaml b/i18n/si/tests-config.yaml new file mode 100644 index 00000000000..40996649a57 --- /dev/null +++ b/i18n/si/tests-config.yaml @@ -0,0 +1 @@ +exclude-pattern: '' diff --git a/i18n/si/tests-msgs.jaml b/i18n/si/tests-msgs.jaml new file mode 100644 index 00000000000..0de4c688e3e --- /dev/null +++ b/i18n/si/tests-msgs.jaml @@ -0,0 +1,14731 @@ +canvas/tests/test_mainwindow.py: + class `TestMainWindow`: + def `test_settings_dialog`: + exec: null + show: null +classification/tests/test_base.py: + class `TestModelMapping`: + def `setUpClass`: + iris: null + def `test_no_common_values`: + iris: null + abc: null + __main__: null +classification/tests/test_calibration.py: + class `TestThresholdClassifier`: + def `setUp`: + a: null + b: null + def `test_non_binary_base`: + a: null + b: null + c: null + def `test_np_data`: + heart_disease: null + class `TestThresholdLearner`: + def `test_fit_storage`: + Orange.evaluation.performance_curves.Curves.from_results: null + Orange.classification.calibration.TestOnTrainingData: null + a: null + b: null + heart_disease: null + store_models: null + def `test_non_binary_class`: + a: null + b: null + c: null + class `TestCalibratedClassifier`: + def `setUp`: + a: null + b: null + def `test_np_data`: + heart_disease: null + class `TestCalibratedLearner`: + def `test_fit_storage`: + Orange.classification.calibration._SigmoidCalibration.fit: null + Orange.classification.calibration.TestOnTrainingData: null + heart_disease: null + a: null + b: null + store_models: null + __main__: null +classification/tests/test_catgb_cls.py: + class `TestCatGBClassifier`: + Missing 'catboost' package: null + def `setUpClass`: + iris: null + def `test_set_params`: + n_estimators: null + max_depth: null + def `test_discrete_variables`: + zoo: null + titanic: null + def `test_missing_values`: + heart_disease: null + def `test_retain_x`: + heart_disease: null + def `test_doesnt_modify_data`: + iris: null + __main__: null +classification/tests/test_gb_cls.py: + class `TestGBClassifier`: + def `setUpClass`: + iris: null + def `test_set_params`: + n_estimators: null + max_depth: null + __main__: null +classification/tests/test_outlier_detection.py: + class `_TestDetector`: + def `setUpClass`: + iris: null + def `assert_table_appended_outlier`: + Outlier: null + class `TestOneClassSVMLearner`: + def `test_OneClassSVM`: + c1: null + c2: null + def `test_OneClassSVM_ignores_y`: + x1: null + x2: null + y1: null + y2: null + class `TestEllipticEnvelopeLearner`: + def `setUpClass`: + c1: null + c2: null + def `test_single_data_to_model_domain`: + data_to_model_domain: null + def `test_EllipticEnvelope_ignores_y`: + x1: null + x2: null + y1: null + y2: null + def `test_transform`: + Mahalanobis: null + class `TestOutlierModel`: + def `test_unique_name`: + Outlier: null + Outlier (1): null + def `test_transformer`: + Outlier: null + def `test_pickle_model`: + .pkl: null + def `test_pickle_prediction`: + .pkl: null + __main__: null +classification/tests/test_simple_tree.py: + class `SimpleTreeTest`: + def `test_nonan_classification`: + x: null + y: null + ab: null + def `test_nonan_regression`: + x: null + y: null + x2: null + def `test_stub`: + x: null + y: null + __main__: null +classification/tests/test_xgb_cls.py: + class `TestXGBCls`: + Missing 'xgboost' package: null + def `setUpClass`: + iris: null + def `test_set_params`: + n_estimators: null + max_depth: null + __main__: null +data/tests/test_aggregate.py: + def `create_sample_data`: + a: null + b: null + cvar: null + dvar: null + val1: null + val2: null + svar: null + sval1: null + sval2: null + class `DomainTest`: + def `test_simple_aggregation`: + a: null + mean: null + b: null + a - mean: null + b - mean: null + def `test_aggregation`: + a: null + b: null + cvar: null + Mean: null + mean: null + Median: null + median: null + Mean1: null + dvar: null + Count defined: null + count: null + Count: null + size: null + svar: null + Concatenate: null + cvar - Mean: null + cvar - Median: null + cvar - Mean1: null + dvar - Count defined: null + dvar - Count: null + svar - Concatenate: null + sval1sval2: null + sval2: null + sval1sval2sval1: null + sval2sval1: null + def `test_preserve_table_class`: + a: null + mean: null + __main__: null +data/tests/test_domain.py: + class `DomainTest`: + def `test_bool_raises_warning`: + y: null + def `test_empty`: + y: null + def `test_conversion`: + a: null + abc: null + cab: null + b: null + def: null + efg: null + c: null + __main__: null +data/tests/test_io.py: + class `TestTableFilters`: + def `test_guess_data_type_continuous`: + 1: null + 2: null + 3: null + def `test_guess_data_type_discrete`: + 1: null + 2: null + a: null + def `test_guess_data_type_string`: + a: null + def `test_guess_data_type_time`: + 2019-10-10: null + 2019-10-01: null + 2019-10-10T12:08:51: null + 2019-10-01T12:08:51: null + 2019-10-10 12:08:51: null + 2019-10-01 12:08:51: null + 2019-10-10 12:08: null + 2019-10-01 12:08: null + def `test_guess_data_type_values_order`: + something1: null + something12: null + something2: null + something20: null + class `TestWriters`: + def `setUp`: + a: null + xyz: null + b: null + c: null + d: null + foo bar baz: null + def `test_write_tab`: + .tab: null + utf-8: null + ' +c\td\ta\tb +continuous\tstring\tx y z\tcontinuous +class\tmeta\t\t +3.0\tfoo\ty\t0.5 +1.0\tbar\tz\t +7.0\tbaz\t\t1.0625': null + def `test_roundtrip_xlsx`: + .xlsx: null + __main__: null +data/tests/test_io_base.py: + class `InitTestData`: + def `setUpClass`: + 0.1: null + 0.5: null + 21.0: null + 0.2: null + 2.5: null + 123.0: null + 0.0: null + a: null + b: null + c: null + d: null + red: null + 2019-10-10: null + 2019-10-12: null + green: null + 2019-10-11: null + m#a: null + cC#b: null + m#c: null + i#e: null + f: null + aa: null + 1.0: null + 2.0: null + w: null + e: null + g: null + s: null + yes no: null + meta: null + class: null + weight: null + i: null + no: null + class `TestTableHeader`: + def `test_rename_variables`: + a: null + b: null + a (1): null + a (2): null + def `test_get_header_data_1`: + a: null + b: null + c: null + d: null + def `test_get_header_data_1_flags`: + a: null + b: null + c: null + d: null + e: null + f: null + m: null + i: null + def `test_get_header_data_3`: + a: null + b: null + c: null + d: null + w: null + e: null + f: null + g: null + s: null + yes no: null + meta: null + class: null + weight: null + i: null + class `TestTableBuilder`: + def `test_string_column`: + s: null + red: null + green: null + def `test_continuous_column`: + c: null + 0.1: null + 0.2: null + 0.0: null + def `test_continuous_column_raises`: + a: null + 2: null + 3: null + 4: null + c: null + def `test_time_column`: + t: null + 2019-10-10: null + 2019-10-12: null + 2019-10-11: null + def `test_discrete_column`: + d: null + green: null + red: null + def `test_column_parts_discrete_values`: + green red: null + green: null + red: null + def `test_unknown_type_column`: + 0.1: null + 0.2: null + 0.0: null + class `TestDataTableMixin`: + def `test_parse_headers_1`: + a: null + b: null + c: null + d: null + def `test_parse_headers_1_flags`: + m#a: null + cC#b: null + m#c: null + d: null + i#e: null + f: null + def `test_parse_headers_3`: + a: null + b: null + c: null + d: null + w: null + e: null + f: null + g: null + s: null + yes no: null + meta: null + class: null + weight: null + i: null + def `test_adjust_data_width_lengthen`: + a: null + b: null + c: null + d: null + e: null + m: null + def `test_adjust_data_width_shorten`: + a: null + b: null + c: null + m: null + def `test_adjust_data_width_empty`: + a: null + b: null + __main__: null +data/tests/test_io_util.py: + class `TestIoUtil`: + def `test_guess_continuous_w_nans`: + 9: null + 98: null + ?: null + __main__: null +data/tests/test_pandas.py: + class `TestPandasCompat`: + Missing package 'pandas': null + def `test_table_from_frame`: + a: null + 2017-12-19: null + b: null + 1724-12-20: null + c: null + 1: null + 2: null + 0: null + abaa: null + index: null + def `test_table_from_frame_keep_ids`: + iris: null + _oa: null + _o: null + 1: null + _o20: null + _o30: null + def `test_table_to_frame`: + iris: null + sepal length: null + Iris-setosa: null + def `test_table_to_frame_object_dtype`: + a: null + def `test_table_to_frame_nans`: + a: null + b: null + def `test_table_to_frame_metas`: + zoo: null + def `test_not_orangedf`: + iris: null + def `test_table_from_frame_date`: + 2017-12-19: null + 1724-12-20: null + def `test_table_from_frame_time`: + 00:00:00.25: null + 20:20:20.30: null + 1970-01-01 00:00:00.25: null + 1970-01-01 20:20:20.30: null + def `test_table_from_frame_datetime`: + 2017-12-19 00:00:00.50: null + 1724-12-20 20:20:20.30: null + def `test_table_from_frame_timezones`: + 2017-12-19 00:00:00: null + 1724-12-20 20:20:20: null + 2017-12-19 00:00:00Z: null + 1724-12-20 20:20:20Z: null + 2017-12-19 00:00:00+1: null + 1724-12-20 20:20:20+1: null + CET: null + def `test_table_from_frame_no_datetime`: + object: null + def `testa_table_from_frame_string`: + a: null + b: null + c: null + d: null + e: null + f: null + s1: null + s2: null + object: null + string: null + 5: null + def `test_time_variable_compatible`: + time: null + def `test_table_to_frame_on_all_orange_dataset`: + Convert all Orange demo dataset. It takes about 5s which is way to slow: null + Orange/datasets/: null + def `_filename_to_dataset_name`: + .: null + def `_get_orange_demo_datasets`: + .tab: null + Failed to process Table('{}'): null + def `test_table_from_frames`: + brown-selected: null + def `test_table_from_frames_not_orange_dataframe`: + x1: null + x2: null + x3: null + y: null + m1: null + m2: null + def `test_table_from_frames_same_index`: + a: null + b: null + x1: null + x2: null + x3: null + y: null + m1: null + m2: null + object: null + index: null + c: null + class `TestTablePandas`: + def `setUp`: + table: null + Base class: null + def `test_slice`: + c2: null + d1: null + def `test_concat_table`: + c2: null + d1: null + def `test_merge`: + c2: null + d15: null + def `test_new_column`: + new: null + class `TestDenseTablePandas`: + def `setUp`: + c1: null + c2: null + d1: null + a: null + b: null + y: null + c3: null + d2: null + c: null + d: null + s1: null + s2: null + a b c d e f g: null + ABCDEF: null + haha: null + hoho: null + def `test_contiguous_metas`: + 1.4.0: null + pandas-dev/pandas#39263: null + def `test_amend_dimension_mismatch`: + Leading dimension mismatch (not 7 == 9): null + class `TestSparseTablePandas`: + c2: null + Continuous Feature 2: null + d1: null + 0: null + 1: null + Discrete Feature 2: null + value1: null + value2: null + Continuous Class: null + Discrete Class: null + m: null + f: null + __main__: null +data/tests/test_sql_mssql.py: + class `TestPymssqlBackend`: + def `test_connection_error`: + host: null + port: null + database: null + DB: null + def `test_parse_ex`: + Foo: null + __main__: null +data/tests/test_table.py: + class `TestTableInit`: + def `test_warnings`: + x: null + def `test_invalid_call_with_kwargs`: + iris: null + def `test_from_numpy`: + abcde: null + foo: null + abcd: null + e: null + no: null + yes: null + s: null + def `test_from_numpy_sparse`: + abc: null + def `test_concatenate_horizontal`: + abcdefg: null + def `test_concatenate_names`: + abcdefg: null + tab2: null + tab3: null + def `test_with_column`: + abcdefg: null + t: null + abcde: null + def `test_copy`: + x: null + y: null + z: null + class `TestTableLocking`: + def `setUpClass`: + CI: null + def `setUp`: + abcdefg: null + def `test_unlock_table_derived`: + iris: null + class `TestTableFilters`: + def `setUp`: + c1: null + c2: null + d1: null + a: null + b: null + y: null + c3: null + d2: null + c: null + d: null + s1: null + s2: null + a b c d e f g: null + ABCDEF: null + def `test_row_filters_is_defined`: + ab: null + abdg: null + abcdef: null + cdefg: null + c1: null + abdefg: null + c: null + def `test_row_filter_no_discrete`: + a: null + def `test_row_filter_continuous`: + adg: null + dg: null + a: null + def `test_row_filter_string`: + c: null + e: null + cde: null + def `test_row_stringlist`: + bBdDe: null + bd: null + bDe: null + bde: null + def `test_row_stringregex`: + [bBdDe]: null + bd: null + def `test_is_defined`: + c3: null + abcdeg: null + class `TableColumnViewTests`: + def `setUp`: + y: null + d: null + a: null + b: null + t: null + m: null + abc def ghi: null + y2: null + class `TestTableGetColumn`: + def `test_get_column_proper_view`: + y: null + def `test_get_column_discrete`: + d: null + a: null + b: null + c: null + def `test_sparse`: + y: null + def `test_get_column_no_variable`: + y3: null + def `test_index_by_int`: + y: null + t: null + m: null + class `TestTableGetColumnView`: + def `test_get_column_view_by_var`: + y: null + t: null + m: null + def `test_get_column_view_by_name`: + y: null + t: null + m: null + y2: null + def `test_get_column_view_by_index`: + y2: null + def `test_sparse`: + ignore: null + y: null + error: null + .*dense copy.*: null + def `test_mapped`: + ignore: null + d: null + a: null + b: null + c: null + error: null + .*mapped copy.*: null + def `test_meta_is_float`: + x: null + y: null + a: null + b: null + __main__: null +data/tests/test_util.py: + class `TestGetUniqueNames`: + def `test_get_unique_names`: + foo: null + bar: null + baz: null + baz (3): null + qux: null + foo (1): null + baz (4): null + baz (3) (1): null + quux: null + bar (4): null + qux (4): null + qux (1): null + bar (1): null + def `test_get_unique_names_with_domain`: + foo: null + bar: null + baz: null + baz (3): null + qux: null + foo (1): null + baz (4): null + baz (3) (1): null + quux: null + bar (4): null + qux (4): null + qux (1): null + bar (1): null + def `test_get_unique_names_not_equal`: + foo: null + bar: null + baz: null + baz (3): null + qux: null + foo (1): null + baz (4): null + baz (3) (1): null + quux: null + bar (1): null + def `test_get_unique_names_duplicated_proposals`: + foo: null + bar: null + baz: null + baz (3): null + boo: null + foo (1): null + boo (1): null + boo (2): null + foo (4): null + boo (4): null + boo (5): null + baz (4): null + bong: null + def `test_get_unique_names_from_duplicates`: + foo: null + bar: null + baz: null + bar (1): null + bar (2): null + x: null + x (1): null + x (2): null + x (3): null + x (2) (1): null + x (4): null + x (5): null + x (2) (2): null + iris: null + iris (1): null + iris (2): null + iris (3): null + iris (4): null + iris (1) (1): null + iris (1) (2): null + iris (1) (3): null + def `test_get_unique_names_domain`: + a: null + t: null + c: null + d: null + e: null + t (1): null + t (2): null + t (3): null + d (1): null + d (2): null + class `TestSanitizedName`: + def `test_sanitized_name`: + Foo: null + Foo Bar: null + Foo_Bar: null + 0Foo: null + _0Foo: null + 1 Foo Bar: null + _1_Foo_Bar: null + __main__: null +data/tests/test_variable.py: + class `VariableTest`: + def `test_copy_copies_attributes`: + x: null + a: null + b: null + c: null + def `test_rename`: + x: null + x2: null + _name: null + def `varcls_modified`: + a: null + class `TestVariable`: + def `setUpClass`: + x: null + def `test_name`: + Variable(name='x'): null + def `test_to_val`: + x: null + foo: null + 42: null + ?: null + def `test_properties`: + y: null + d: null + s: null + def `test_properties_as_predicates`: + y: null + s: null + def `test_strange_eq`: + a: null + somestring: null + def `test_eq_with_compute_value`: + a: null + c: null + def `test_hash`: + a: null + b: null + def `test_hash_eq`: + a: null + b: null + b2: null + c: null + def `test_compute_value_eq_warning`: + x: null + def `variabletest`: + def `decorate`: + varcls: null + class `TestDiscreteVariable`: + def `test_to_val`: + F: null + M: null + Feature 0: null + ?: null + G: null + def `test_make`: + a: null + F: null + M: null + def `test_val_from_str`: + a: null + F: null + M: null + def `test_val_from_str_add`: + a: null + F: null + M: null + N: null + def `test_repr`: + a: null + F: null + M: null + DiscreteVariable(name='a', values=('F', 'M')): null + 1234567: null + DiscreteVariable(name='a', values=('1', '2', '3', '4', '5', '6', '7')): null + def `test_no_nonstringvalues`: + foo: null + a: null + b: null + c: null + def `test_no_duplicated_values`: + foo: null + a: null + b: null + c: null + def `test_unpickle`: + A: null + two: null + one: null + three: null + def `test_mapper_dense`: + a: null + abc: null + dca: null + b: null + c: null + def `test_mapper_sparse`: + a: null + abc: null + dca: null + acd: null + def `test_mapper_inplace`: + a: null + abc: null + dca: null + acd: null + def `test_mapper_dim_check`: + a: null + abc: null + dca: null + def `test_mapper_from_no_values`: + a: null + dca: null + def `varcls_modified`: + A: null + B: null + def `test_copy_checks_len_values`: + gender: null + F: null + M: null + N: null + W: null + def `test_pickle_backward_compatibility`: + default: null + ..: null + tests: null + datasets: null + sailing-orange-3-20.pkl: null + iris-orange-3-25.pkl: null + class `TestContinuousVariable`: + def `test_make`: + age: null + def `test_decimals`: + a: null + 4.6543: null + 4.2500: null + ?: null + 0.00000: null + 1e-12: null + def `test_more_decimals`: + a: null + 4: null + 4.12: null + 4.00: null + 4.25: null + 4.1234: null + def `test_adjust_decimals`: + a: null + 5: null + 4.65432: null + ' 5.12 ': null + 4.65: null + 5.00: null + class `TestStringVariable`: + def `test_val`: + a: null + ?: null + foo: null + '"foo"': null + class `TestTimeVariable`: + 2015-10-12 14:13:11.01+0200: null + 2015-10-12 14:13:11.010000+0200: null + 2015-10-12T14:13:11.81+0200: null + 2015-10-12 14:13:11.810000+0200: null + 2015-10-12 14:13:11.81: null + 2015-10-12 14:13:11.810000: null + 2015-10-12T14:13:11.81: null + 2015-10-12 14:13:11+0200: null + 2015-10-12T14:13:11+0200: null + 20151012T141311+0200: null + 20151012141311+0200: null + 2015-10-12 14:13:11: null + 2015-10-12T14:13:11: null + 2015-10-12 14:13: null + 2015-10-12 14:13:00: null + 20151012T141311: null + 20151012141311: null + 2015-10-12: null + 20151012: null + 2015-285: null + 2015-10: null + 2015-10-01: null + 2015: null + 2015-01-01: null + 01:01:01.01: null + 01:01:01.010000: null + 010101.01: null + 01:01:01: null + 01:01: null + 01:01:00: null + 1970-01-01 00:00:00: null + 1969-12-31 23:59:59: null + 1969-12-31 23:59:58.9: null + 1969-12-31 23:59:58.900000: null + 1900-01-01: null + nan: null + ?: null + 1444651991.81: null + 2015-10-12 12:13:11.810000: null + def `test_parse_repr`: + time: null + def `test_parse_utc`: + time: null + 2015-10-18 22:48:20: null + +0200: null + 2015-10-18 20:48:20: null + 2015-10-18T22:48:20: null + +02:00: null + def `test_parse_timestamp`: + time: null + 2016-06-14 23:08:00: null + def `test_parse_invalid`: + var: null + 123: null + def `test_have_date`: + time: null + 1937-08-02: null + 16:20: null + 1970-01-01 16:20:00: null + def `test_no_date_no_time`: + relative time: null + 1.6: null + def `test_readwrite_timevariable`: + '\ +Date,Feature +time,continuous +, +1920-12-12,1.0 +1920-12-13,3.0 +1920-12-14,5.5 +': null + Date: null + 1920-12-12: null + def `test_repr_value`: + time: null + 416.3: null + def `test_have_date_have_time_in_construct`: + time: null + def `test_additional_formats`: + 2021-11-25: null + 2022-02-07: null + 25.11.2021: null + 07.02.2022: null + 07. 02. 2022: null + 7.2.2022: null + 7. 2. 2022: null + 25.11.21: null + 07.02.22: null + 07. 02. 22: null + 7.2.22: null + 7. 2. 22: null + 11/25/2021: null + 02/07/2022: null + 2/7/2022: null + 11/25/21: null + 02/07/22: null + 2/7/22: null + 20211125: null + 20220207: null + 2021-11-25 00:00:00: null + 2022-02-07 10:11:12: null + 2022-02-07 10:11:12.00: null + 25.11.2021 00:00:00: null + 07.02.2022 10:11:12: null + 07. 02. 2022 10:11:12: null + 7.2.2022 10:11:12: null + 7. 2. 2022 10:11:12: null + 07.02.2022 10:11:12.00: null + 07. 02. 2022 10:11:12.00: null + 7.2.2022 10:11:12.00: null + 7. 2. 2022 10:11:12.00: null + 25.11.21 00:00:00: null + 07.02.22 10:11:12: null + 07. 02. 22 10:11:12: null + 7.2.22 10:11:12: null + 7. 2. 22 10:11:12: null + 07.02.22 10:11:12.00: null + 07. 02. 22 10:11:12.00: null + 7.2.22 10:11:12.00: null + 7. 2. 22 10:11:12.00: null + 11/25/2021 00:00:00: null + 02/07/2022 10:11:12: null + 2/7/2022 10:11:12: null + 02/07/2022 10:11:12.00: null + 2/7/2022 10:11:12.00: null + 11/25/21 00:00:00: null + 02/07/22 10:11:12: null + 2/7/22 10:11:12: null + 02/07/22 10:11:12.00: null + 2/7/22 10:11:12.00: null + 20211125000000: null + 20220207101112: null + 20220207101112.00: null + 2022-02-07 10:11: null + 07.02.2022 10:11: null + 07. 02. 2022 10:11: null + 7.2.2022 10:11: null + 7. 2. 2022 10:11: null + 07.02.22 10:11: null + 07. 02. 22 10:11: null + 7.2.22 10:11: null + 7. 2. 22 10:11: null + 02/07/2022 10:11: null + 2/7/2022 10:11: null + 02/07/22 10:11: null + 2/7/22 10:11: null + 202202071011: null + 00:00:00: null + 10:11:12: null + 10:11:12.00: null + 000000: null + 101112: null + 101112.00: null + 10:11: null + 2021: null + 11-25: null + 02-07: null + 25.11.: null + 07.02.: null + 07. 02.: null + 7.2.: null + 7. 2.: null + 11/25: null + 02/07: null + 2/7: null + coerce: null + PickleContinuousVariable: null + with_name: null + Feature 0: null + PickleDiscreteVariable: null + with_str_value: null + F: null + M: null + PickleStringVariable: null + class `VariableTestMakeProxy`: + def `test_make_proxy_disc`: + abc: null + def `test_make_proxy_cont`: + abc: null + def `test_proxy_has_separate_attributes`: + image: null + origin: null + a: null + b: null + c: null + __main__: null +distance/tests/test_distance.py: + class `CommonTests`: + def `test_sparse`: + abc: null + class `CommonFittedTests`: + def `test_mismatching_attributes`: + a: null + b: null + c: null + d: null + class `CommonNormalizedTests`: + def `test_zero_variance`: + d: null + class `FittedDistanceTest`: + def `setUpClass`: + c1: null + c2: null + c3: null + d1: null + a: null + b: null + d2: null + c: null + d: null + d3: null + class `JaccardDistanceTest`: + def `setUp`: + abc: null + def `test_zero_instances`: + abc: null + class `TestDataUtilities`: + def `test_remove_discrete`: + 123: null + abc: null + xy: null + t: null + def `test_remove_non_binary`: + 12: null + abc: null + 123: null + def: null + xy: null + t: null + __main__: null +evaluation/tests/test_performance_curves.py: + class `TestCurves`: + def `test_curves_from_results`: + Orange.evaluation.performance_curves.Curves.__init__: null + def `test_curves_from_results_nans`: + Orange.evaluation.performance_curves.Curves.__init__: null +misc/tests/test_collections.py: + class `TestFrozenDict`: + def `test_removed_methods`: + a: null + b: null + def `test_functions_as_dict`: + a: null + b: null + c: null + class `TestUtils`: + def `test_natural_sorted`: + something1: null + something20: null + something2: null + something12: null + def `test_natural_sorted_text`: + b: null + aa: null + c: null + dd: null + def `test_natural_sorted_numbers_str`: + 1: null + 20: null + 2: null + 12: null + class `TestDictMissingConst`: + def `test_dict_missing`: + <->: null + A: null + B: null + __main__: null +misc/tests/test_distmatrix.py: + class `DistMatrixTest`: + def `test_reader_selection`: + Orange.misc._distmatrix_xlsx.read_matrix: null + _from_dst: null + test.dst: null + test.xlsx: null + def `test_auto_symmetricized_result`: + ABC: null + ABCD: null + def `test_auto_symmetricized_dont_apply`: + abc: null + def: null + def `test_trivial_labels`: + abc: null + a: null + c: null + xy: null + st: null + b: null + x: null + 2: null + 5: null + g: null + __main__: null +misc/tests/test_distmatrix_xlsx.py: + xlsx_files: null + class `ReadMatrixTest`: + def `setUpClass`: + distances.xlsx: null + def `test_layouts`: + Barcelona Belgrade Berlin Brussels: null + lower_row_labels: null + upper_col_labels: null + lower_col_labels: null + upper_row_labels: null + upper_both_labels: null + AERU: null + lower_both_labels: null + upper_no_labels: null + lower_no_labels: null + upper_with_diag: null + lower_with_diag: null + with_nans: null + non_square_both: null + abcdef: null + non_square_row_labels: null + non_square_col_labels: null + non_square_no_labels: null + non_square_off: null + abcd??: null + ???ABCDE: null + just_numbers: null + def `test_fast_floats`: + numpy.cumsum: null + non_square_off: null + numbers_upper_left: null + def `test_errors`: + sheet: Zavihek + koala: null + E15: null + non_square_off_err: null + empty: prazen + no data: null + def `test_active_worksheet`: + E15: null + sheet: zavihek + empty: prazen + class `FunctionsTest`: + def `setUpClass`: + distances.xlsx: null + def `test_get_sheet`: + lower_row_labels: null + def `test_non_empty_cells`: + upper_row_labels: null + non_square_both: null + non_square_off: null + a: null + E: null + no data: null + .*empty.*: .*prazen.* + numpy.cumsum: null + numbers_upper_left: null + def `test_get_labels`: + a: null + b: null + c: null + bb: null + 1: null + 2: null + ?: null + 1.5: null + def `test_matrix_from_cells`: + 3.15: null + .*D3.*: null + foo: null + def `test_write`: + .xlsx: null + aa: null + bb: null + cc: null + dd: null + ee: null + __main__: null +misc/tests/test_embedder_utils.py: + class `TestProxies`: + def `setUp`: + http_proxy: null + https_proxy: null + def `tearDown`: + http_proxy: null + https_proxy: null + def `test_add_scheme`: + http_proxy: null + test1.com: null + https_proxy: null + test2.com: null + http://test1.com: null + http://: null + http://test2.com: null + https://: null + test1.com/path: null + test2.com/path: null + http://test1.com/path: null + http://test2.com/path: null + https://test1.com:123: null + https://test2.com:124: null + def `test_both_urls`: + http_proxy: null + http://test1.com:123: null + https_proxy: null + https://test2.com:124: null + http://: null + https://: null + all://: null + def `test_http_only`: + http_proxy: null + http://test1.com:123: null + http://: null + https://: null + def `test_https_only`: + https_proxy: null + https://test1.com:123: null + https://: null + http://: null + __main__: null +misc/tests/test_server_embedder.py: + httpx.AsyncClient.post: null + '{"embedding": [0, 1]}': null + class `TestServerEmbedder`: + def `setUp`: + test: null + https://test.com: null + image: null + test_var: null + test1: null + test2: null + test3: null + def `test_on_non_json_response`: + blabla: null + def `test_on_json_wrong_key_response`: + '{"wrong-key": [0, 1]}': null + def `test_persistent_caching`: + test: null + https://test.com: null + image: null + def `test_different_models_caches`: + different_emb: null + https://test.com: null + image: null + test: null + def `test_too_many_examples_for_one_batch`: + test_var: null + test{i}: null + def `test_connection_error`: + test_var: null + test{i}: null + def `test_read_error`: + test_var: null + test{i}: null + def `test_encode_data_instance`: + abc: null + __main__: null +modelling/tests/test_catgb.py: + class `TestCatGBLearner`: + Missing 'catboost' package: null + def `setUpClass`: + iris: null + housing: null + def `test_params`: + n_estimators: null + max_depth: null + __main__: null +modelling/tests/test_gb.py: + class `TestGBLearner`: + def `setUpClass`: + iris: null + housing: null + def `test_params`: + n_estimators: null + max_depth: null + __main__: null +modelling/tests/test_xgb.py: + class `TestXGB`: + Missing 'xgboost' package: null + def `setUpClass`: + iris: null + housing: null + def `test_params`: + n_estimators: null + max_depth: null + __main__: null +preprocess/tests/test_discretize.py: + class `TestFixedWidth`: + def `test_discretization`: + c{i}: null + < 0.10: null + 0.10 - 0.20: null + 0.20 - 0.30: null + ≥ 0.30: null + < 0.2: null + ≥ 0.2: null + class `TestFixedTimeWidth`: + def `test_discretization`: + t: null + 1914: null + 1945: null + t2: null + t3: null + < 1920: null + 1920 - 1930: null + 1930 - 1940: null + ≥ 1940: null + < 1915: null + 1915 - 1920: null + 1920 - 1925: null + 1925 - 1930: null + 1930 - 1935: null + 1935 - 1940: null + 1940 - 1945: null + ≥ 1945: null + 1914-07-28: null + 1918-11-11: null + 1915-01-01: null + 1915-07-01: null + 1916-01-01: null + 1916-07-01: null + 1917-01-01: null + 1917-07-01: null + 1918-01-01: null + 1918-07-01: null + < 15 Jan: null + 15 Jan - Jul: null + 15 Jul - 16 Jan: null + 16 Jan - Jul: null + 16 Jul - 17 Jan: null + 17 Jan - Jul: null + 17 Jul - 18 Jan: null + 18 Jan - Jul: null + ≥ 18 Jul: null + 1914-11-11: null + 1914-09-01: null + 1914-11-01: null + < Sep: null + Sep - Nov: null + ≥ Nov: null + 1914-08-01: null + 1914-10-01: null + < Aug: null + Aug - Sep: null + Sep - Oct: null + Oct - Nov: null + 1914-06-28 10:45: null + 1914-07-04 15:25: null + 1914-06-29: null + 1914-07-01: null + 1914-07-03: null + < Jun 29: null + Jun 29 - Jul 01: null + Jul 01 - Jul 03: null + ≥ Jul 03: null + 1914-06-30: null + 1914-07-02: null + 1914-07-04: null + Jun 29 - Jun 30: null + Jun 30 - Jul 01: null + Jul 01 - Jul 02: null + Jul 02 - Jul 03: null + Jul 03 - Jul 04: null + ≥ Jul 04: null + 1914-12-30 22:45: null + 1915-01-02 15:25: null + 1914-12-31: null + 1915-01-02: null + < 14 Dec 31: null + 14 Dec 31 - 15 Jan 01: null + 15 Jan 01 - Jan 02: null + ≥ 15 Jan 02: null + 1914-06-28 15:25: null + 1914-06-28 12:00: null + 1914-06-28 14:00: null + < 12:00: null + 12:00 - 14:00: null + ≥ 14:00: null + 1914-06-28 11:00: null + 1914-06-28 13:00: null + 1914-06-28 15:00: null + < 11:00: null + 11:00 - 12:00: null + 12:00 - 13:00: null + 13:00 - 14:00: null + 14:00 - 15:00: null + ≥ 15:00: null + 1914-06-28 22:45: null + 1914-06-29 03:25: null + 1914-06-28 23:00: null + 1914-06-29 00:00: null + 1914-06-29 01:00: null + 1914-06-29 02:00: null + 1914-06-29 03:00: null + < Jun 28 23:00: null + Jun 28 23:00 - Jun 29 00:00: null + Jun 29 00:00 - 01:00: null + Jun 29 01:00 - 02:00: null + Jun 29 02:00 - 03:00: null + ≥ Jun 29 03:00: null + 1914-06-28 22:43: null + 1914-06-28 23:01: null + 1914-06-28 22:50: null + 1914-06-28 22:55: null + < 22:45: null + 22:45 - 22:50: null + 22:50 - 22:55: null + 22:55 - 23:00: null + ≥ 23:00: null + 1914-06-30 23:48: null + 1914-07-01 00:06: null + 1914-06-30 23:50: null + 1914-06-30 23:55: null + 1914-07-01 00:00: null + 1914-07-01 00:05: null + < Jun 30 23:50: null + Jun 30 23:50 - 23:55: null + Jun 30 23:55 - Jul 01 00:00: null + Jul 01 00:00 - 00:05: null + ≥ Jul 01 00:05: null + 1914-06-29 23:48: null + 1914-06-30 00:06: null + 1914-06-29 23:50: null + 1914-06-29 23:55: null + 1914-06-30 00:00: null + 1914-06-30 00:05: null + < Jun 29 23:50: null + Jun 29 23:50 - 23:55: null + Jun 29 23:55 - Jun 30 00:00: null + Jun 30 00:00 - 00:05: null + ≥ Jun 30 00:05: null + 1914-06-29 23:48:05: null + 1914-06-29 23:51:59: null + 1914-06-29 23:49: null + 1914-06-29 23:51: null + < 23:49: null + 23:49 - 23:50: null + 23:50 - 23:51: null + ≥ 23:51: null + 1914-06-29 23:48:05.123: null + 1914-06-29 23:48:33.684: null + 1914-06-29 23:48:10: null + 1914-06-29 23:48:20: null + 1914-06-29 23:48:30: null + < 23:48:10: null + 23:48:10 - 23:48:20: null + 23:48:20 - 23:48:30: null + ≥ 23:48:30: null + 1914-12-31 23:59:58.1: null + 1915-01-01 00:00:01.8: null + 1914-12-31 23:59:59: null + 1915-01-01 00:00:00: null + 1915-01-01 00:00:01: null + < 23:59:59: null + 23:59:59 - 00:00:00: null + 00:00:00 - 00:00:01: null + ≥ 00:00:01: null + class `TestBinningDiscretizer`: + def `test_no_data`: + y: null + def `test_call`: + Orange.preprocess.discretize.time_binnings: null + Orange.preprocess.discretize.decimal_binnings: null + Orange.preprocess.discretize.Binning._create_binned_var: null + y: null + t: null + def `test_binning_selection`: + y: null + t{x}: null + < t1: null + t1 - t2: null + ≥ t2: null + t2 - t3: null + t3 - t4: null + ≥ t4: null + class `TestTimeBinning`: + def `test_binning`: + def `tr1`: + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec: null + 10 years: 10 let + 1970: null + 1980: null + 1990: null + 5 years: 5 let + 1975: null + 1985: null + 2 years: 2 leti + 1974: null + 1976: null + 1978: null + 1982: null + 1984: null + 1986: null + 1988: null + 1 year: 1 leto + 1977: null + 1979: null + 1981: null + 1983: null + 1987: null + 1989: null + 6 months: 6 mesecev + 75 Jan: null + Jul: null + 76 Jan: null + 77 Jan: null + 78 Jan: null + 79 Jan: null + 80 Jan: null + 81 Jan: null + 82 Jan: null + 83 Jan: null + 84 Jan: null + 85 Jan: null + 86 Jan: null + 87 Jan: null + 88 Jan: null + 89 Jan: null + 3 months: 3 meseci + 75 Apr: null + Oct: null + Apr: null + 2 months: 2 meseca + 75 Mar: null + May: null + Sep: null + Nov: null + Mar: null + 1 month: 1 mesec + Jun: null + Aug: null + Dec: null + Feb: null + 75 Dec: null + 2 weeks: 2 tedna + 75 Dec 03: null + 17: null + 31: null + 76 Jan 14: null + 1 week: 1 teden + 10: null + 24: null + 76 Jan 07: null + 1 day: 1 dan + 75 Dec 02: null + 03: null + 04: null + 05: null + 06: null + 07: null + 08: null + 09: null + 11: null + 12: null + 13: null + 14: null + 15: null + 16: null + 18: null + 19: null + 20: null + 21: null + 22: null + 23: null + 25: null + 26: null + 27: null + 28: null + 29: null + 30: null + 76 Jan 01: null + 02: null + 75 Dec 25: null + 12 hours: 12 ur + 75 Dec 25 00:00: null + 12:00: null + 26 00:00: null + 27 00:00: null + 28 00:00: null + 29 00:00: null + 30 00:00: null + 31 00:00: null + 76 Jan 01 00:00: null + 02 00:00: null + 03 00:00: null + 6 hours: 6 ur + 06:00: null + 18:00: null + 75 Dec 29: null + 75 Dec 29 00:00: null + 3 hours: 3 ure + 03:00: null + 09:00: null + 15:00: null + 21:00: null + 75 Dec 31: null + 75 Dec 31 00:00: null + 2 hours: 2 uri + 02:00: null + 04:00: null + 08:00: null + 10:00: null + 14:00: null + 16:00: null + 20:00: null + 22:00: null + 1 hour: 1 ura + 01:00: null + 05:00: null + 07:00: null + 11:00: null + 13:00: null + 17:00: null + 19:00: null + 23:00: null + 75 Dec 31 06:00: null + 30 minutes: 30 minut + Dec 31 06:00: null + 06:30: null + 07:30: null + 08:30: null + 09:30: null + 10:30: null + 11:30: null + 12:30: null + 13:30: null + 14:30: null + 15:30: null + 16:30: null + 17:30: null + 18:30: null + 19:30: null + 20:30: null + 21:30: null + 22:30: null + 23:30: null + Jan 01 00:00: null + 00:30: null + 75 Dec 31 21:00: null + 75 Dec 31 22:00: null + 75 Dec 31 23:00: null + Dec 31 23:00: null + 01:30: null + 02:30: null + 15 minutes: 15 minut + 23:15: null + 23:45: null + 00:15: null + 00:45: null + 01:15: null + 01:45: null + 02:15: null + 10 minutes: 10 minut + 23:10: null + 23:20: null + 23:40: null + 23:50: null + 00:10: null + 00:20: null + 00:40: null + 00:50: null + 01:10: null + 01:20: null + 01:40: null + 01:50: null + 02:10: null + 5 minutes: 5 minut + 23:05: null + 23:25: null + 23:35: null + 23:55: null + 00:05: null + 00:25: null + 00:35: null + 00:55: null + 01:05: null + 01:25: null + 01:35: null + 01:55: null + 02:05: null + Jun 09 00:00: null + 10 00:00: null + Jun 09 06:00: null + Jun 09 09:00: null + Jun 09 10:00: null + 10:15: null + 10:45: null + 11:15: null + 11:45: null + 12:15: null + 12:45: null + 13:15: null + 10:10: null + 10:20: null + 10:40: null + 10:50: null + 11:10: null + 11:20: null + 11:40: null + 11:50: null + 12:10: null + 12:20: null + 12:40: null + 12:50: null + 13:10: null + 13:20: null + 10:05: null + 10:25: null + 10:35: null + 10:55: null + 11:05: null + 11:25: null + 11:35: null + 11:55: null + 12:05: null + 12:25: null + 12:35: null + 12:55: null + 13:05: null + 1 minute: 1 minuta + 10:01: null + 10:02: null + 10:03: null + 10:04: null + 10:06: null + 10:07: null + 10:08: null + 10:09: null + 10:11: null + 10:12: null + 10:13: null + 10:14: null + 10:16: null + 10:17: null + 10:18: null + 10:19: null + 10:21: null + 10:22: null + 10:23: null + 10:24: null + 10:26: null + 10:27: null + 10:28: null + 10:29: null + 10:31: null + 10:32: null + 10:33: null + 10:34: null + 10:36: null + 10:37: null + 10:38: null + 10:39: null + 10:41: null + 10:42: null + 10:43: null + 10:44: null + 10:46: null + 10:47: null + 10:48: null + 10:49: null + 30 seconds: 30 sekund + 10:00:00: null + 10:00:30: null + 10:01:00: null + 10:01:30: null + 10:02:00: null + 10:02:30: null + 10:03:00: null + 10:03:30: null + 10:04:00: null + 10:04:30: null + 10:05:00: null + 10:05:30: null + 10:06:00: null + 10:06:30: null + 10:07:00: null + 10:07:30: null + 10:08:00: null + 10:08:30: null + 10:09:00: null + 10:09:30: null + 10:10:00: null + 10:10:30: null + 10:11:00: null + 10:11:30: null + 10:12:00: null + 10:12:30: null + 10:13:00: null + 10:13:30: null + 10:14:00: null + 10:14:30: null + 10:15:00: null + 10:15:30: null + 10:16:00: null + 10:16:30: null + 10:17:00: null + 10:17:30: null + 10:18:00: null + 10:18:30: null + 10:19:00: null + 10:19:30: null + 10:20:00: null + 10:20:30: null + 15 seconds: 15 sekund + 10:12:45: null + 10:13:15: null + 10:13:45: null + 10:14:15: null + 10:14:45: null + 10:15:15: null + 10:15:45: null + 10:16:15: null + 10:16:45: null + 10:17:15: null + 10:17:45: null + 10:18:15: null + 10 seconds: 10 sekund + 10:12:40: null + 10:12:50: null + 10:13:10: null + 10:13:20: null + 10:13:40: null + 10:13:50: null + 10:14:10: null + 10:14:20: null + 10:14:40: null + 10:14:50: null + 10:15:10: null + 10:15:20: null + 10:15:40: null + 10:15:50: null + 10:16:10: null + 10:16:20: null + 10:16:40: null + 10:16:50: null + 10:17:10: null + 10:17:20: null + 10:17:40: null + 10:17:50: null + 10:18:10: null + 10:18:20: null + 5 seconds: 5 sekund + 10:12:35: null + 10:12:55: null + 10:13:05: null + 1 second: 1 sekunda + 10:12:33: null + 10:12:34: null + 10:12:36: null + 10:12:37: null + 10:12:38: null + 10:12:39: null + 10:12:41: null + 10:12:42: null + 10:12:43: null + 10:12:44: null + 10:12:46: null + 10:12:47: null + 10:12:48: null + 10:12:49: null + 10:12:51: null + 10:12:52: null + 10:12:53: null + 10:12:54: null + 10:12:56: null + 10:12:57: null + 10:12:58: null + 10:12:59: null + 10:13:01: null + 10:13:02: null + 10:13:03: null + 10:13:04: null + 10:13:06: null + 10:13:07: null + 10:13:08: null + 10:13:09: null + 10:13:11: null + 10:13:12: null + 10:13:13: null + 50 years: 50 let + 1950: null + 2000: null + 2050: null + 25 years: 25 let + 2025: null + 2010: null + 2020: null + 1995: null + 2005: null + 2015: null + 1972: null + 1992: null + 1994: null + 1996: null + 1998: null + 2002: null + 2004: null + 2006: null + 2008: null + 2012: null + 1973: null + 1991: null + 1993: null + 1997: null + 1999: null + 2001: null + 2003: null + 2007: null + 2009: null + 2011: null + class `TestBinDefinition`: + def `test_labels`: + 1: null + 2: null + 3.14: null + %.3f: null + 1.000: null + 2.000: null + 3.140: null + b{x:g}: null + b1: null + b2: null + b3.14: null + abc: null + def `test_width_label`: + 3: null + 3.14: null + class `TestDiscretizer`: + def `test_equality`: + x: null + y: null + __main__: null +preprocess/tests/test_fss.py: + class `SelectBestFeaturesTest`: + def `test_no_nice_features`: + x: null + -inf: null + inf: null + __main__: null +preprocess/tests/test_impute.py: + class `TestReplaceUnknowns`: + def `test_equality`: + x: null + y: null + class `TestReplaceUnknownsRandom`: + def `test_equality`: + x: null + abc: null + y: null + class `TestFixedValuesByType`: + def `setUp`: + d: null + abc: null + c: null + t: null + s: null + foo: null + def `test_all_defined`: + foo: null + def `test_with_default`: + foo: null + bar: null + class `TestReplaceUnknownsModel`: + def `test_eq`: + iris: null + __main__: null +preprocess/tests/test_transformation.py: + class `TestTransformEquality`: + def `setUp`: + d1: null + abc: null + d2: null + def `test_mapping`: + a: null + 1: null + b: null + 2: null + c: null + 3: null + nan: null + f: null + k: null + j: null + class `TestIndicator`: + def `test_nan`: + d: null + abcde: null + __main__: null +regression/tests/test_catgb_reg.py: + class `TestCatGBRegressor`: + Missing 'catboost' package: null + def `setUpClass`: + housing: null + def `test_set_params`: + n_estimators: null + max_depth: null + __main__: null +regression/tests/test_curvefit.py: + class `TestCreateLambda`: + def `test_create_lambda_simple`: + a + b: null + a: null + b: null + def `test_create_lambda_var`: + var + a + b: null + var: null + a: null + b: null + def `test_create_lambda_fun`: + power(a, 2): null + power: null + a: null + def `test_create_lambda_var_fun`: + var1 + power(a, 2) + power(a, 2): null + var1: null + var2: null + power: null + a: null + def `test_create_lambda_x`: + var1 + x: null + var1: null + var2: null + x: null + def `test_create_lambda_ast`: + a + b: null + eval: null + a: null + b: null + def `test_create_lambda`: + a * var1 + b * exp(var2 * power(pi, 0)): null + var1: null + var2: null + var3: null + exp: null + power: null + pi: null + a: null + b: null + class `TestCurveFitLearner`: + def `setUpClass`: + housing: null + def `test_init_str`: + a + b: null + def `test_init_ast`: + a + b: null + eval: null + def `test_fit`: + CRIM: null + def `test_fit_no_params`: + CRIM: null + def `test_predict`: + CRIM: null + def `test_predict_constant`: + CRIM: null + def `test_coefficients`: + a: null + b: null + c: null + LSTAT: null + def `test_inadequate_data`: + iris: null + sepal length: null + def `test_missing_values`: + CRIM: null + def `test_cv`: + CRIM: null + def `test_cv_preprocess`: + a: null + CRIM: null + def `test_predict_single_instance`: + CRIM: null + def `test_predict_table`: + CRIM: null + def `test_predict_numpy`: + CRIM: null + def `test_predict_sparse`: + CRIM: null + def `test_can_copy_str`: + a * exp(-b * CRIM) + c: null + exp: null + def `test_can_copy_callable`: + CRIM: null + def `test_can_copy_with_imputer`: + a * exp(-b * CRIM) + c: null + exp: null + def `test_can_pickle_str`: + a * exp(-b * CRIM) + c: null + exp: null + def `test_can_pickle_callable`: + CRIM: null + __main__: null +regression/tests/test_gb_reg.py: + class `TestGBRegressor`: + def `setUpClass`: + housing: null + def `test_set_params`: + n_estimators: null + max_depth: null + __main__: null +regression/tests/test_xgb_reg.py: + class `TestXGBReg`: + Missing 'xgboost' package: null + def `setUpClass`: + housing: null + def `test_set_params`: + n_estimators: null + max_depth: null + def `test_scorer`: + Missing 'xgboost' package: null + __main__: null +tests/test_ada_boost.py: + class `TestSklAdaBoostLearner`: + def `setUpClass`: + iris: null + housing: null +tests/test_base.py: + class `TestLearner`: + def `test_uses_default_preprocessors_unless_custom_pps_specified`: + 'Learner should use default preprocessors, unless preprocessors ': null + were specified in init: null + def `test_overrides_custom_preprocessors`: + 'Learner should override default preprocessors when specified in ': null + constructor: null + def `test_use_default_preprocessors_property`: + 'Learner did not properly insert custom preprocessor into ': null + preprocessor list: null + Custom preprocessor was inserted in incorrect order: null + def `test_preprocessors_can_be_passed_in_as_non_iterable`: + 'Preprocessors should be able to be passed in as single object ': null + as well as an iterable object: null + def `test_preprocessors_can_be_passed_in_as_generator`: + 'Preprocessors should be able to be passed in as single object ': null + as well as an iterable object: null + def `test_callback`: + iris: null + class `TestSklLearner`: + def `test_linreg`: + 'Either LinearRegression no longer supports weighted tables or ': null + SklLearner.supports_weights is out-of-date.: null + def `test_callback`: + iris: null + __main__: null +tests/test_basic_stats.py: + class `TestDomainBasicStats`: + def `setUp`: + zoo: null +tests/test_basket_reader.py: + def `with_file`: + def `fle_decorator`: + def `decorated`: + utf-8: null + class `TestBasketReader`: + def `test_read_variable_is_value_syntax`: + a=1,b=2,c=3: null + a: null + b: null + c: null + def `test_read_variable_only_syntax`: + a,b,c,d,e: null + def `test_handles_spaces_between_variables`: + a=1, b=2, c=3: null + def `test_variables_can_be_listed_in_any_order`: + a,b\nc,b,a: null + def `test_handles_unicode`: + č,š,ž: null + def `test_handles_quote`: + a=4,"x"=1.0,"y"=2.0,b=5\n"x"=1.0: null + def `test_sums_duplicates`: + a,a,b\nb=2,b=3,c: null + def `test_data_name`: + datasets/iris_basket.basket: null + iris_basket: null + __main__: null +tests/test_classification.py: + def `all_learners`: + Orange.classification.: null + _: null + base: null + class `ModelTest`: + def `test_predict_single_instance`: + titanic: null + def `test_prediction_dimensions`: + abcde: null + y: null + a: null + b: null + in test for type '{type(inp)}': null + def `test_learner_adequacy`: + housing: null + def `test_value_from_probs`: + i: null + c: null + 0123: null + def `test_probs_from_value`: + v: null + c: null + 12: null + i: null + 0123: null + def `test_incompatible_domain`: + iris: null + titanic: null + def `test_result_shape`: + iris: null + def `test_result_shape_numpy`: + iris: null + a: null + b: null + def `test_predict_proba`: + heart_disease: null + class `ExpandProbabilitiesTest`: + def `prepareTable`: + Feature %i: null + Class %i: null + 01: null + class `SklTest`: + def `test_multinomial`: + titanic: null + def `test_nan_columns`: + iris: null + class `ClassfierListInputTest`: + def `test_discrete`: + titanic: null + crew: null + adult: null + male: null + def `test_continuous`: + iris: null + class `UnknownValuesInPrediction`: + def `test_unknown`: + iris: null + def `test_missing_class`: + datasets/adult_sample_missing: null + nu: null + class `LearnerAccessibility`: + def `setUp`: + ignore: null + .*: null + def `test_all_learners_accessible_in_Orange_classification_namespace`: + %s is not visible in Orange.classification: null + ' namespace': null + def `test_all_models_work_after_unpickling`: + iris: null + titanic: null + %s does not return same values when unpickled %s: null + def `test_all_models_work_after_unpickling_pca`: + iris: null + titanic: null + %s does not return same values when unpickled %s: null + def `test_adequacy_all_learners`: + housing: null + def `test_adequacy_all_learners_multiclass`: + datasets/test8.tab: null + __main__: null +tests/test_clustering_dbscan.py: + class `TestDBSCAN`: + def `setUp`: + iris: null + def `test_dbscan_parameters`: + euclidean: null + auto: null +tests/test_clustering_hierarchical.py: + class `TestHierarchical`: + def `setUpClass`: + Ann: null + Bob: null + Curt: null + Danny: null + Eve: null + Fred: null + Greg: null + Hue: null + Ivy: null + Jon: null + lower: null + def `test_form`: + lower: null + upper: null + def `test_pre_post_order`: + A: null + B: null + C: null + def `test_table_clustering`: + single: null + class `TestTree`: + def `test_tree`: + Tree(value=0, branches=()): null +tests/test_clustering_kmeans.py: + class `TestKMeans`: + def `setUp`: + iris: null + def `test_kmeans_parameters`: + random: null + def `test_model_data_table_domain`: + a: null + housing: null +tests/test_clustering_louvain.py: + class `TestLouvain`: + def `setUp`: + iris: null + def `test_louvain_parameters`: + l2: null + def `test_graph`: + l2: null +tests/test_contingency.py: + class `TestDiscrete`: + def `setUpClass`: + zoo: null + datasets/test9.tab: null + def `test_discrete`: + amphibian: null + predator: null + fish: null + def `test_discrete_missing`: + zoo: null + nan: null + amphibian: null + predator: null + fish: null + def `test_array_with_unknowns`: + zoo: null + nan: null + predator: null + def `test_discrete_with_fallback`: + zoo: null + def `test_continuous`: + iris: null + sepal width: null + Iris-setosa: null + Iris-virginica: null + def `test_continuous_missing`: + iris: null + nan: null + sepal width: null + Iris-setosa: null + Iris-virginica: null + def `test_continuous_array_with_unknowns`: + iris: null + nan: null + sepal width: null + def `test_mixedtype_metas`: + zoo: null + 1: null + nan: null + def `_construct_sparse`: + d%i: null + abc: null + c%i: null + y: null + def `test_sparse`: + b: null + c3: null + def `test_get_contingency`: + b: null + c4: null + def `test_get_contingencies`: + b: null + def `test_compute_contingency_invalid`: + X: null + C: null + C{}: null +tests/test_continuize.py: + class `TestDomainContinuizer`: + def `setUp`: + datasets/test4: null + def `test_default`: + c1: null + c2: null + d2=a: null + d2=b: null + d3=a: null + d3=b: null + d3=c: null + a: null + b: null + c: null + def `test_continuous_transform_class`: + c1: null + c2: null + d2=a: null + d2=b: null + d3=a: null + d3=b: null + d3=c: null + def `test_multi_indicators`: + c1: null + c2: null + d2=a: null + d2=b: null + d3=a: null + d3=b: null + d3=c: null + a: null + b: null + c: null + def `test_multi_lowest_base`: + c1: null + c2: null + d2=b: null + d3=b: null + d3=c: null + a: null + b: null + c: null + def `test_multi_ignore`: + c1: null + c2: null + def `test_multi_ignore_class`: + c1: null + c2: null + d2=b: null + def `test_multi_ignore_multi`: + c1: null + c2: null + d2=b: null + cl1: null + def `test_as_ordinal`: + c1: null + c2: null + d2: null + d3: null + cl1: null + a: null + b: null + c: null + def `test_as_ordinal_class`: + c1: null + c2: null + d2: null + d3: null + cl1: null + def `test_as_normalized_ordinal`: + c1: null + c2: null + d2: null + d3: null + cl1: null + a: null + b: null + c: null +tests/test_cur.py: + class `TestCUR`: + def `setUpClass`: + datasets/ionosphere.tab: null + def `__reconstruction_test_helper`: + fro: null +tests/test_data_util.py: + class `TestSharedComputeValue`: + def `test_compat_compute_value`: + iris: null + def `test_with_row_indices`: + iris: null + cv: null + def `test_single_call`: + iris: null + def `test_eq_hash`: + x: null + y: null +tests/test_datasets.py: + class `TestDatasets`: + def `test_access`: + location: null + iris: null + def `test_filter`: + features: null + continuous: null + location: null + def `test_have_all`: + ../datasets: null + .tab: null + def `test_datasets_info_features`: + location: null + http: null + rows: null + missing: null + features: null + meta: null + discrete: null + continuous: null + target: null + type: null + values: null +tests/test_discretize.py: + class `TestEntropyMDL`: + def `test_entropy_constant`: + v1: null + c1: null + 1: null + class `TestDiscretizer`: + def `setUp`: + x: null + def `test_create_discretized_var_formatting`: + < 1: null + 1 - 2: null + 2 - 3: null + ≥ 3: null + < 10: null + ≥ 10: null + < 10.123: null + ≥ 10.123: null + < 5: null + 5 - 10.25: null + ≥ 10.25: null + 5 - 10.1234: null + ≥ 10.1234: null + def `test_transform`: + iris: null + def `test_remove_constant`: + iris: null + def `test_keep_constant`: + iris: null + def `test_discretize_class`: + iris: null + def `test_discretize_metas`: + iris: null + class `TestDiscretizeTable`: + def `test_fixed`: + Feature 2: null + def `test_leave_discrete`: + a: null + MF: null + b: null + c: null + AB: null + d: null + class `TestInstanceConversion`: + def `test_single_instance`: + iris: null + Iris-virginica: null +tests/test_distances.py: + class `TestDistMatrix`: + def `setUpClass`: + iris: null + def `test_from_file`: + '3 axis=0 asymmetric col_labels row_labels + ann bert chad + danny 0.12 3.45 6.78 + eve 9.01 2.34 5.67 + frank 8.90 1.23 4.56': null + ann: null + bert: null + chad: null + danny: null + eve: null + frank: null + '3 axis=1 row_labels + danny 0.12 3.45 6.78 + eve 9.01 2.34 5.67 + frank 8.90': null + '3 axis=1 symmetric + 0.12 3.45 6.78 + 9.01 2.34 5.67 + 8.90': null + '3 row_labels + starič 0.12 3.45 6.78 + aleš 9.01 2.34 5.67 + anže 8.90': null + utf-8: null + starič: null + aleš: null + anže: null + empty file: prazna datoteka + axis=1\n1\t3\n4: null + distance file must begin with dimension: datoteka se mora začeti z dimenzijo matrike + 3 col_labels\na\tb\n1\n\2\n3: null + mismatching number of column labels, 2 != 3: napačno število oznak stolpcev, 2 != 3 + 3 col_labels\na\tb\tc\td\n1\n\2\n3: null + mismatching number of column labels, 4 != 3: napačno število oznak stolpcev, 4 != 3 + 2\n 1\t2\t3\n 5: null + too many columns in matrix row 1: preveč stolpcev v vrstici 1 + 2 row_labels\na\t1\t2\t3\nb\t5: null + too many columns in matrix row 'a': preveč stolpcev v vrstici 'a' + 2 noflag\n 1\t2\t3\n 5: null + invalid flag 'noflag': null + 2 noflag=5\n 1\t2\t3\n 5: null + invalid flag 'noflag=5': null + 2\n1\n2\n3: null + too many rows: preveč vrstic + 2\n1\nasd: null + invalid element at row 2, column 1: napačna vrednost v vrstici 2, stolpcu 1 + 2 row_labels\na\t1\nb\tasd: null + invalid element at row 'b', column 1: napačna vrednost v vrstici 'b', stolpcu 1 + 2 col_labels row_labels\nd\te\na\t1\nb\tasd: null + invalid element at row 'b', column 'd': napačna vrednost v vrstici 'b', stolpcu 'd' + 2 col_labels\nd\te\n1\nasd: null + invalid element at row 2, column 'd': napačna vrednost v vrstici 2, stolpcu 'd' + def `test_save`: + '3 axis=1 row_labels + danny 0.12 3.45 6.78 + eve 9.01 2.34 5.67 + frank 8.90': null + danny: null + eve: null + frank: null + '3 axis=0 asymmetric col_labels row_labels + ann bert chad + danny 0.12 3.45 6.78 + eve 9.01 2.34 5.67 + frank 8.90 1.23 4.56': null + ann: null + bert: null + chad: null + class `TestEuclidean`: + def `setUpClass`: + iris: null + class `TestManhattan`: + def `setUpClass`: + iris: null + class `TestCosine`: + def `setUpClass`: + iris: null + class `TestJaccard`: + def `setUpClass`: + titanic: null + class `TestSpearmanR`: + def `setUpClass`: + datasets/breast-cancer-wisconsin.tab: null + class `TestSpearmanRAbsolute`: + def `setUpClass`: + datasets/breast-cancer-wisconsin.tab: null + class `TestPearsonR`: + def `setUpClass`: + datasets/breast-cancer-wisconsin.tab: null + class `TestPearsonRAbsolute`: + def `setUpClass`: + datasets/breast-cancer-wisconsin.tab: null + class `TestMahalanobis`: + def `test_correctness`: + mahalanobis: null + def `test_iris`: + iris: null + def `test_dimensions`: + iris: null + class `TestBhattacharyya`: + def `test_dense_array`: + iris: null + class `TestDistances`: + def `setUpClass`: + datasets/test5.tab: null + def `test_preprocess`: + c: null + d: null + a: null + b: null + cls: null + e: null + f: null + m: null + m1: null + m2: null + def `test_distance_to_instance`: + iris: null + __main__: null +tests/test_distribution.py: + class `TestDiscreteDistribution`: + def `setUp`: + rgb: null + r: null + g: null + b: null + a: null + num: null + 1: null + 2: null + 3: null + def `test_from_table`: + zoo: null + type: null + def `test_construction`: + zoo: null + type: null + def `test_fallback`: + zoo: null + type: null + def `test_fallback_with_weights_and_nan`: + zoo: null + type: null + def `test_pickle`: + zoo: null + def `test_deepcopy`: + zoo: null + def `test_equality`: + zoo: null + def `test_indexing`: + zoo: null + amphibian: null + mammal: null + def `test_hash`: + zoo: null + type: null + def `test_add`: + zoo: null + type: null + def `test_normalize`: + zoo: null + type: null + def `test_modus`: + zoo: null + type: null + mammal: null + def `test_array_with_unknowns`: + zoo: null + type: null + class `TestContinuousDistribution`: + def `setUpClass`: + iris: null + n1: null + n2: null + def `test_from_table`: + petal length: null + def `test_construction`: + petal length: null + def `test_hash`: + petal length: null + def `test_normalize`: + petal length: null + def `test_random`: + petal length: null + class `TestClassDistribution`: + def `test_class_distribution`: + zoo: null + type: null + def `test_multiple_target_variables`: + n1: null + c1: null + r: null + g: null + b: null + a: null + c2: null + c3: null + class `TestGetDistribution`: + def `test_get_distribution`: + iris: null + class `TestDomainDistribution`: + def `test_get_distributions`: + iris: null + def `test_sparse_get_distributions`: + d%i: null + abc: null + c%i: null + ignore: null + .*: null + def `test_compute_distributions_metas`: + datasets/test9.tab: null + O: null + __main__: null +tests/test_doctest.py: + Orange/widgets: null + Orange/canvas: null + Orange/datasets/: null + win32: null + def `find_modules`: + __file__: null + .py: null + .: null + def `suite`: + 1.14: null + def `setUp`: + Skip doctest on numpy >= 1.14.0: null + 'Unimportable module: {}': null +tests/test_domain.py: + def `create_domain`: + AGE: null + Gender: null + M: null + F: null + incomeA: null + income: null + education: null + GS: null + HS: null + C: null + SSN: null + race: null + White: null + Hypsanic: null + African: null + Other: null + arrival: null + PickleDomain: null + empty_domain: null + with_continuous_variable: null + age: null + with_discrete_variable: null + gender: null + with_mixed_variables: null + with_continuous_class: null + incomeA: null + with_discrete_class: null + education: null + with_multiple_classes: null + with_metas: null + ssn: null + with_class_and_metas: null + income: null + race: null + arrival: null + class `TestDomainInit`: + def `test_init_source`: + Gender: null + def `test_init_source_class`: + Gender: null + income: null + def `test_from_numpy_names`: + Feature {}: null + Feature {:02}: null + Feature {:03}: null + Feature: null + Target: null + Meta {:03}: null + def `test_nonunique_domain_error`: + a: null + def `test_from_numpy_values`: + v{}: null + def `test_wrong_types`: + income: null + def `test_get_item`: + AGE: null + income: null + SSN: null + def `test_index`: + AGE: null + income: null + SSN: null + def `test_get_item_error`: + no_such_thing: null + def `test_index_error`: + no_such_thing: null + def `test_contains`: + AGE: null + income: null + SSN: null + no_such_thing: null + def `test_str`: + []: null + [AGE]: null + [ | AGE]: null + [Gender | AGE]: null + [Gender, income]: null + [Gender, income | AGE]: null + [Gender | AGE, income]: null + [Gender | AGE, income] {SSN}: null + [Gender | AGE, income] {SSN, race}: null + [] {SSN, race}: null + def `test_get_conversion`: + new_income: null + def `test_conversion`: + White: null + M: null + HS: null + 1234567: null + def `test_preprocessor_chaining`: + a: null + 01: null + b: null + y: null + def `test_different_domains_with_same_attributes_are_equal`: + var1: null + def `test_domain_conversion_is_fast_enough`: + f%i: null + c%i: null + m%i: null + def `test_domain_conversion_sparsity`: + a: null + b: null + c: null + d: null + e: null + f: null + def `test_get_item_similar_vars`: + Cluster: null + c: null + Cluster x: null + a: null + b: null + class `TestDomainFilter`: + def `setUp`: + iris: null + def `test_filter_visible`: + hidden: null + __main__: null +tests/test_evaluation_clustering.py: + class `TestClusteringEvaluation`: + def `test_kmeans`: + iris: null +tests/test_evaluation_scoring.py: + class `TestScoreMetaType`: + class `Score3`: + foo: null + def `test_registry`: + Score2: null + Score3: null + Score4: null + Score5: null + def `test_names`: + Score2: null + foo: null + Score4: null + Score5: null + class `TestPrecision`: + def `setUpClass`: + iris: null + def `test_precision_iris`: + weighted: null + def `test_precision_multiclass`: + y: null + 01234: null + weighted: null + def `test_precision_binary`: + y: null + 01: null + macro: null + class `TestRecall`: + def `setUpClass`: + iris: null + def `test_recall_iris`: + weighted: null + def `test_recall_multiclass`: + y: null + 01234: null + weighted: null + def `test_recall_binary`: + y: null + 01: null + macro: null + class `TestF1`: + def `setUpClass`: + iris: null + def `test_recall_iris`: + weighted: null + def `test_F1_multiclass`: + y: null + 01234: null + weighted: null + def `test_F1_binary`: + y: null + 01: null + class `TestAUC`: + def `setUpClass`: + iris: null + def `test_auc_on_multiclass_data_returns_1d_array`: + titanic: null + datasets/lenses.tab: null + def `compute_auc`: + x: null + 01: null + class `TestLogLoss`: + def `test_log_loss`: + iris: null + def `test_log_loss_calc`: + titanic: null + class `TestMatthewsCorrCoefficient`: + def `setUpClass`: + heart_disease: null + iris: null + housing: null + class `TestSpecificity`: + def `setUpClass`: + iris: null + def `test_specificity_iris`: + weighted: null + def `test_precision_multiclass`: + y: null + 01234: null + weighted: null + def `test_precision_binary`: + y: null + 01: null + def `test_errors`: + binary: null + abc: null + __main__: null +tests/test_evaluation_testing.py: + class `TestSampling`: + def `setUpClass`: + iris: null + def `run_test_failed`: + def `fails`: + failing learner: null + def `run_test_preprocessor`: + iris: null + class `TestValidation`: + def `setUp`: + iris: null + def `test_warn_deprecations`: + Orange.evaluation.testing.Validation.__call__: null + def `test_obsolete_call_constructor`: + Orange.evaluation.testing.Validation.__call__: null + n_jobs: null + callback: null + learners: null + class `TestCrossValidation`: + def `setUpClass`: + iris: null + housing: null + def `test_augmented_data_classification`: + iris: null + Naive Bayes: null + Majority: null + def `test_augmented_data_regression`: + housing: null + Linear Regression: null + Mean Learner: null + class `TestCrossValidationFeature`: + def `add_meta_fold`: + fold: null + def `test_init`: + fold: null + abc: null + def `test_unknown`: + nan: null + def `test_bad_feature`: + fold: null + abc: null + x: null + ab: null + y: null + cd: null + class `TestLeaveOneOut`: + def `test_probs`: + iris: null + class `TestTestOnTestData`: + def `run_test_failed`: + def `fails`: + failing learner: null + def `test_train_data_argument`: + Orange.evaluation.testing.Validation.__new__: null + data: null + test_data: null + class `TestTrainTestSplit`: + def `test_fixed_training_size`: + iris: null + class `TestResults`: + def `setUp`: + iris: null +tests/test_filter.py: + class `TestFilterValues`: + def `setUp`: + iris: null + def `test_values`: + Orange.data.Table._filter_values: null + class `TestIsDefinedFilter`: + def `setUp`: + datasets/imports-85.tab: null + def `test_eq_hash`: + a: null + b: null + def `test_is_defined_filter_not_implemented`: + Orange.data.Table._filter_is_defined: null + class `TestHasClassFilter`: + def `setUp`: + datasets/imports-85.tab: null + def `test_has_class_multiclass`: + x: null + 01: null + y1: null + y2: null + def `test_has_class_filter_not_implemented`: + Orange.data.Table._filter_has_class: null + class `TestFilterContinuous`: + def `setUp`: + abcd: null + def `test_position`: + a: null + b: null + c: null + d: null + def `test_str`: + feature(1) = 1: null + foo: null + foo = 1: null + a = 1: null + a ≠ 1: null + a < 1: null + a ≤ 1: null + a > 1: null + a ≥ 1: null + 1 ≤ a ≤ 2: null + not 1 ≤ a ≤ 2: null + a is defined: null + invalid operator: null + class `TestFilterString`: + def `setUp`: + zoo: null + def `test_case_sensitive`: + name: null + Aardvark: null + def `test_operators`: + name: null + aardvark: null + bass: null + aa: null + a: null + aaz: null + ard: null + ra: null + aar: null + aard: null + ?: null + nan: null + class `TestSameValueFilter`: + def `setUp`: + zoo: null + type: null + legs: null + name: null + mammal: null + girl: null + def `test_same_value_filter_table`: + mammal: null + girl: null + def `test_has_class_filter_not_implemented`: + Orange.data.Table._filter_same_value: null + class `TestFilterReprs`: + def `setUp`: + zoo: null + type: null + mammal: null + def `test_reprs`: + name: null + Aardvark: null + ^c...$: null +tests/test_fitter.py: + class `DummyFitter`: + dummy: null + classification: null + regression: null + class `FitterTest`: + def `setUpClass`: + heart_disease: null + housing: null + def `test_dispatches_to_correct_learner`: + Classification learner was never called for classification: null + problem: null + Regression learner was called for classification problem: null + Regression learner was never called for regression problem: null + Classification learner was called for regression problem: null + def `test_constructs_learners_with_appropriate_parameters`: + class `DummyFitter`: + classification: null + regression: null + Fitter did not properly distribute params to learners: null + def `test_correctly_sets_preprocessors_on_learner`: + Fitter did not properly pass the `use_default_preprocessors`: null + attribute to its learners: null + Fitter did not properly pass its preprocessors to its learners: null + def `test_properly_delegates_preprocessing`: + class `DummyFitter`: + classification: null + regression: null + def `test_default_kwargs_with_change_kwargs`: + class `DummyClassificationLearner`: + def `__init__`: + classification_default: null + class `DummyRegressionLearner`: + def `__init__`: + regression_default: null + class `DummyFitter`: + classification: null + regression: null + def `_change_kwargs`: + param: null + classification_param: null + regression_param: null + iris: null + housing: null + classification_default: null + regression_default: null +tests/test_freeviz.py: + class `TestFreeviz`: + def `setUpClass`: + iris: null + housing: null + zoo: null + def `test_regression`: + housing: null + def `test_weights`: + Test weights is too slow.: null + iris: null + def `test_raising_errors`: + iris: null + titanic: null + def `test_transform_changed_domain`: + titanic: null +tests/test_fss.py: + class `TestFSS`: + def `setUpClass`: + titanic: null + heart_disease: null + iris: null + datasets/imports-85.tab: null + class `TestSelectRandomFeatures`: + def `test_select_random_features`: + heart_disease: null +tests/test_impute.py: + class `TestDoNotImpute`: + def `test_str`: + iris: null + def `test_support`: + iris: null + class `TestAverage`: + def `test_replacement`: + a: null + b: null + ABC: null + c: null + class `TestDefault`: + def `test_default`: + B: null + a: null + b: null + c: null + C: null + def `test_str`: + 1: null + y: null + class `TestAsValue`: + def `_create_table`: + A: null + 0: null + 1: null + 2: null + B: null + C: null + def `test_replacement`: + 1: null + 2: null + N/A: NN + undef: ne + def: da + def `test_sparse`: + undef: ne + def: da + class `TestModel`: + def `test_replacement`: + A: null + 0: null + 1: null + 2: null + B: null + C: null + Z: null + P: null + M: null + def `test_support`: + iris: null + def `test_str`: + y: null + def `test_bad_domain`: + iris: null + class `TestRandom`: + def `test_replacement`: + A: null + 0: null + 1: null + 2: null + B: null + C: null + class `TestImputer`: + def `test_imputer`: + datasets/imports-85.tab: null +tests/test_instance.py: + class `TestInstance`: + def `setUpClass`: + Feature %i: null + Class %i: null + Meta 1: null + XYZ: null + Meta 2: null + Meta 3: null + def `test_init_x_arr`: + x: null + g: null + MF: null + def `test_init_x_list`: + x: null + g: null + MF: null + def `test_init_xy_arr`: + x: null + g: null + MF: null + y: null + ABC: null + def `test_init_xy_list`: + x: null + g: null + MF: null + y: null + ABC: null + def `test_init_xym`: + x: null + g: null + MF: null + y: null + ABC: null + M: null + B: null + X: null + Foo: null + def `test_init_inst`: + x: null + g: null + MF: null + y: null + ABC: null + M: null + B: null + X: null + Foo: null + z: null + w: null + def `test_get_item`: + x: null + g: null + MF: null + y: null + ABC: null + M: null + B: null + X: null + Foo: null + Meta 2: null + asdf: null + def `test_list`: + x: null + g: null + MF: null + y: null + ABC: null + M: null + B: null + X: null + Foo: null + def `test_set_item`: + x: null + g: null + MF: null + y: null + ABC: null + M: null + B: null + X: null + Foo: null + F: null + C: null + A: null + Y: null + Meta 1: null + Z: null + N: null + asdf: null + def `test_str`: + x: null + g: null + MF: null + [42, M]: null + y: null + ABC: null + M: null + B: null + [42, M | B]: null + X: null + Foo: null + [42, M | B] {X, 43, Foo}: null + [ | B] {X, 43, Foo}: null + [] {X, 43, Foo}: null + [{}]: null + ', ': null + {x:g}: null + {}: null + def `test_repr`: + [0, 1, 2, 3, 4, ...]: null + [0.000, 1.000, 2.000, 3.000, 4.000, ...]: null + def `test_eq`: + x: null + g: null + MF: null + y: null + ABC: null + M: null + B: null + X: null + Foo: null + C: null + Y: null + 33: null + Bar: null + def `test_instance_id`: + x: null + __main__: null +tests/test_io.py: + class `WildcardReader`: + .wild: null + .wild[0-9]: null + Dummy reader for testing extensions: null + class `TestChooseReader`: + def `test_usual_extensions`: + t.tab: null + t.csv: null + t.pkl: null + test.undefined_extension: null + def `test_wildcard_extension`: + t.wild: null + t.wild2: null + t.wild2a: null + class `SameExtension`: + .same_extension: null + Same extension, different priority: null + class `TestMultipleSameExtension`: + def `test_find_reader`: + some.same_extension: null + class `TestLocate`: + def `test_locate_sample_datasets`: + iris.tab: null + iris: null + def `test_locate_wildcard_extension`: + t.wild9: null + t.wild8: null + wt: null + \n: null + t: null + class `TestReader`: + def `test_open_bad_pickle`: + pickle.load: null + foo: null + def `test_empty_columns`: + '\ + a, b + 1, 0, + 1, 2, + ': null + Columns with no headers were removed.: null + def `test_type_annotations`: + test_file: null + def `test_header_call`: + csv.DictWriter.writerow: null + iris: null + def `test_load_pickle`: + default: null + datasets/sailing-orange-3-20.pkl: null + datasets/sailing-orange-3-20.pkl.gz: null + datasets/sailing-orange-3-21.pkl: null + datasets/sailing-orange-3-21.pkl.gz: null + __main__: null +tests/test_knn.py: + class `TestKNNLearner`: + def `setUpClass`: + iris: null + housing: null + def `test_nan`: + Feat 1: null + Class: null + def `test_random`: + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature 5: null + Target 1: null + abcdefghij: null + def `test_KNN_mahalanobis`: + mahalanobis: null + def `test_KNN_regression`: + mahalanobis: null +tests/test_lda.py: + class `TestLDA`: + def `test_lda`: + iris: null + eigen: null + def `test_transform_changed_domain`: + iris: null +tests/test_linear_bfgs_regression.py: + class `TestLinearRegressionLearner`: + def `test_preprocessors`: + housing: null +tests/test_linear_regression.py: + class `TestLinearRegressionLearner`: + def `setUpClass`: + housing: null + def `test_linear_scorer`: + LSTAT: null + def `test_scorer`: + LSTAT: null +tests/test_logistic_regression.py: + class `TestLogisticRegressionLearner`: + def `setUpClass`: + iris: null + heart_disease.tab: null + zoo: null + def `test_LogisticRegressionNormalization`: + Re-enable when Logistic regression supports normalization.: null + c0: null + def `test_probability`: + l1: null + def `test_learner_scorer`: + chest pain: null + def `test_learner_scorer_multiclass`: + legs: null + feathers: null + fins: null + backbone: null + milk: null + aquatic: null + def `test_auto_solver`: + l2: null + auto: null + lbfgs: null + l1: null + liblinear: null +tests/test_majority.py: + class `TestMajorityLearner`: + def `setUpClass`: + iris: null + def `test_missing`: + iris: null + ?: null + def `test_continuous`: + datasets/imports-85.tab: null + def `test_returns_random_class`: + bool: null + Majority always returns the same value.: null +tests/test_manifold.py: + class `TestManifold`: + def `setUpClass`: + datasets/ionosphere.tab: null + iris: null + def `__mds_test_helper`: + precomputed: null + euclidean: null + def `test_mds_pca_init`: + PCA: null + precomputed: null + euclidean: null + def `__lle_test_helper`: + ltsa: null + dense: null + hessian: null + modified: null + def `test_torgerson`: + auto: null + lapack: null + arpack: null + madness: null + class `TestTSNE`: + def `setUpClass`: + iris: null + def `test_continue_optimization`: + Embedding should change after further optimization.: null + def `test_bh_correctness`: + bh: null + random: null + def `test_fft_correctness`: + fft: null + random: null + def `test_pickle`: + Windows: null + Files locked on Windows: null + exact: null + approx: null + Pickling failed with `neighbors={neighbors}`: null +tests/test_mean.py: + class `TestMeanLearner`: + def `test_empty`: + datasets/imports-85.tab: null + def `test_discrete`: + iris: null +tests/test_naive_bayes.py: + class `TestNaiveBayesLearner`: + def `setUpClass`: + titanic: null + def `test_NaiveBayes`: + iris: null + def `test_degenerate`: + A: null + B: null + C: null + CLASS: null + M: null + F: null + def `test_allnan_cv`: + datasets/lenses.tab: null + def `test_compare_results_of_predict_and_predict_storage`: + titanic: null + def `_test_predictions`: + a: null + ab: null + b: null + abc: null + c: null + y: null + def `_test_predictions_with_absent_class`: + a: null + ab: null + b: null + abc: null + c: null + y: null + abcd: null + def `test_no_attributes`: + y: null + abc: null + def `test_no_targets`: + x: null + abc: null + y: null + __main__: null +tests/test_neural_network.py: + class `TestNNLearner`: + def `setUpClass`: + iris: null + housing: null + def `setUp`: + ignore: null + .*: null +tests/test_normalize.py: + class `TestNormalizer`: + def `compare_tables`: + c1: null + c2: null + d1: null + d2: null + n1: null + n2: null + c3: null + d3: null + c4: null + cl1: null + cl2: null + def `setUpClass`: + datasets/test5.tab: null + def `test_normalize_default`: + a: null + ?: null + b: null + c: null + def `test_normalize_transform_by_sd`: + a: null + ?: null + b: null + c: null + def `test_normalize_transform_class`: + a: null + ?: null + b: null + c: null + def `test_normalize_transform_by_span`: + a: null + ?: null + b: null + c: null + def `test_normalize_transform_by_span_zero`: + a: null + ?: null + b: null + c: null + def `test_normalize_transform_by_span_class`: + a: null + ?: null + b: null + c: null + def `test_normalize_transform_by_span_zero_class`: + a: null + ?: null + b: null + c: null + def `test_skip_normalization`: + skip-normalization: null + def `test_datetime_normalization`: + datasets/test10.tab: null + 1995-01-21: null + a: null + ?: null + 2003-07-23: null + b: null + 1967-03-12: null + c: null + def `test_retain_vars_attributes`: + iris: null + foo: null + baz: null + def `test_number_of_decimals`: + Foo: null + -1.225: null + 0.0: null + 1.225: null + __main__: null +tests/test_orange.py: + class `TestOrange`: + def `test_orange_has_modules`: + canvas: null + datasets: null + testing: null + tests: null + setup: null + util: null + widgets: null +tests/test_orangetree.py: + class `TestTree`: + def `test_refuse_binarize_too_many_values`: + x: null + v{}: null + def `test_find_mapping`: + x: null + abcdefgh: null + r1: null + r2: null + abcd: null + def `test_find_threshold`: + x: null + r1: null + abcd: null + r2: null + def `test_no_data`: + r1: null + ab: null + r2: null + abcd: null + r3: null + def `test_all_values_missing`: + r1: null + ab: null + r2: null + abcd: null + r3: null + def `test_single_valued_attr`: + r1: null + a: null + def `test_allow_null_nodes`: + x: null + abc: null + r1: null + r2: null + ab: null + class `TestClassifier`: + def `setUpClass`: + sufficient_majority: null + iris: null + heart_disease: null + y: null + nyx: null + class `TestRegressor`: + def `setUpClass`: + housing: null + datasets/imports-85.tab: null + y: null + class `TestNodes`: + def `test_node`: + y: null + foo: null + def `test_discrete_node`: + y: null + abc: null + foo: null + nan: null + def `test_mapped_node`: + y: null + abc: null + foo: null + nan: null + 1001: null + def `test_numeric_node`: + y: null + foo: null + nan: null + class `TestTreeModel`: + def `setUp`: + v1: null + v2: null + abc: null + v3: null + def: null + y: null + def `test_compile_and_run_cont`: + nan: null + d1: null + d2: null + abc: null + d3: null + def: null + dy: null + def `test_null_nodes`: + d4: null + ab: null + ey: null + def `test_print`: + ' [ 1 42] v1 ≤ 13 + [ 2 42] v2 a + [ 3 42] v2 b + [ 4 42] v2 c + [ 5 42] v1 > 13 + [ 6 42] v3 f + [ 7 42] v3 d or e +': ' [ 1 42] v1 ≤ 13 + [ 2 42] v2 a + [ 3 42] v2 b + [ 4 42] v2 c + [ 5 42] v1 > 13 + [ 6 42] v3 f + [ 7 42] v3 d ali e +' + def `test_compile_and_run_cont_sparse`: + nan: null +tests/test_pca.py: + class `TestPCA`: + def `setUpClass`: + datasets/ionosphere.tab: null + iris: null + zoo: null + def `__rnd_pca_test_helper`: + randomized: null + def `test_improved_randomized_pca_properly_called`: + randomized: null + arpack: null + def `test_improved_randomized_pca_dense_data`: + full: null + randomized: null + def `test_improved_randomized_pca_sparse_data`: + full: null + randomized: null + def `test_incremental_pca`: + 0.20: null + https://github.com/scikit-learn/scikit-learn/issues/12234: null + def `test_PCA_scorer`: + petal length: null + petal width: null +tests/test_polynomial_learner.py: + class `TestPolynomialLearner`: + def `test_PolynomialLearner`: + x: null + y: null +tests/test_preprocess.py: + class `TestRemoveConstant`: + def `test_nothing_to_remove`: + iris: null + class `TestRemoveNaNRows`: + def `test_remove_row`: + iris: null + class `TestRemoveNaNColumns`: + def `test_column_filtering`: + iris: null + def `test_column_filtering_sparse`: + iris: null + class `TestAdaptiveNormalize`: + def `setUp`: + iris: null + class `TestRemoveSparse`: + def `setUp`: + a: null + b: null + __main__: null +tests/test_preprocess_cur.py: + class `TestCURProjector`: + def `setUpClass`: + datasets/ionosphere.tab: null +tests/test_preprocess_pca.py: + class `TestPCAProjector`: + def `setUpClass`: + datasets/ionosphere.tab: null +tests/test_radviz.py: + class `TestRadViz`: + def `setUpClass`: + iris: null + titanic: null +tests/test_random_forest.py: + class `RandomForestTest`: + def `setUpClass`: + iris: null + housing: null + def `test_classification_scorer`: + petal length: null + petal width: null + def `test_regression_scorer`: + LSTAT: null + RM: null + def `test_scorer_feature`: + datasets/test4.tab: null + def `test_max_features_cls`: + heart_disease: null + __main__: null +tests/test_randomize.py: + class `TestRandomizer`: + def `setUpClass`: + zoo: null + def `test_randomize_keep_original_data`: + zoo: null +tests/test_regression.py: + def `all_learners`: + Orange.regression.: null + base: null + class `TestRegression`: + def `test_adequacy_all_learners`: + iris: null + def `test_adequacy_all_learners_multiclass`: + datasets/test8.tab: null + def `test_missing_class`: + datasets/imports-85.tab: null + __main__: null +tests/test_remove.py: + class `TestRemover`: + def `setUpClass`: + datasets/test8.tab: null + def `test_remove`: + iris: null + sepal length: null + sepal width: null + petal length: null + removed: null + reduced: null + sorted: null + def `test_remove_constant_attr`: + c0: null + d0: null + cl1: null + cl0: null + cl3: null + cl4: null + 4: null + 6: null + 1: null + 2: null + 3: null + removed: null + reduced: null + sorted: null + def `test_remove_constant_class`: + c1: null + c0: null + d1: null + d0: null + cl1: null + cl0: null + 1: null + 4: null + 6: null + 2: null + 3: null + removed: null + reduced: null + sorted: null + def `test_remove_unused_values_attr`: + c1: null + c0: null + d1: null + d0: null + cl1: null + cl0: null + cl3: null + cl4: null + 1: null + 4: null + 2: null + 3: null + removed: null + reduced: null + sorted: null + def `test_remove_unused_values_class`: + c1: null + c0: null + d1: null + d0: null + cl1: null + cl0: null + cl3: null + cl4: null + 1: null + 4: null + 6: null + 2: null + 3: null + removed: null + reduced: null + sorted: null + def `test_remove_unused_values_metas`: + datasets/test9.tab: null + b: null + c: null + d: null + 1: null + 2: null + f: null + hey: null + def `test_remove_unused_values_attr_sparse`: + 1: null + 4: null + 2: null + 3: null + removed: null + reduced: null + sorted: null + def `test_remove_mapping`: + iris: null + def `test_remove_mapping_after_compute_value`: + housing: null +tests/test_rules.py: + class `TestRuleInduction`: + def `setUp`: + titanic: null + iris: null + def `test_base_RuleLearner`: + data_stopping: null + cover_and_remove: null + rule_stopping: null + rule_finder: null + search_algorithm: null + search_strategy: null + quality_evaluator: null + complexity_evaluator: null + general_validator: null + significance_validator: null + def `testOrderedCN2SDLearner`: + gamma: null + def `testUnorderedCN2SDLearner`: + gamma: null + __main__: null +tests/test_score_feature.py: + class `FeatureScoringTest`: + def `setUpClass`: + zoo: null + housing: null + datasets/breast-cancer-wisconsin.tab: null + datasets/lenses.tab: null + def `test_chi2`: + c: null + def `test_anova`: + c: null + def `test_relieff`: + Bare_Nuclei: null + Clump thickness: null + Marginal_Adhesion: null + tear_rate: null + def `test_rrelieff`: + LSTAT: null + RM: null + def `test_fcbf`: + legs: null + milk: null + toothed: null + feathers: null + backbone: null + 1: null + 2: null + target: null + ignore: null + invalid value.*double_scalars: null + invalid value.*true_divide: null + def `test_learner_with_transformation`: + iris: null + def `test_learner_transform_without_variable`: + def `preprocessor_random_column`: + nat: null + __main__: null +tests/test_sgd.py: + class `TestSGDRegressionLearner`: + def `setUp`: + ignore: null + .*: null + def `test_coefficients`: + housing: null + class `TestSGDClassificationLearner`: + def `setUpClass`: + iris: null + def `setUp`: + ignore: null + .*: null + def `test_predictions_shapes`: + modified_huber: null +tests/test_simple_random_forest.py: + class `TestSimpleRandomForestLearner`: + def `test_SimpleRandomForest_classification`: + iris: null + def `test_SimpleRandomForest_regression`: + housing: null + __main__: null +tests/test_simple_tree.py: + class `TestSimpleTreeLearner`: + def `setUp`: + d{}: null + 0: null + 1: null + c{}: null + yc: null + 2: null + yr: null + def `test_SimpleTree_classification_tree`: + '{ 1 4 -1.17364 { 1 5 0.37564 { 2 0.00 0.00 0.56 } ': null + '{ 2 0.00 3.00 1.14 } } { 1 4 -0.41863 { 1 5 0.14592 ': null + '{ 2 3.54 0.54 0.70 } { 2 2.46 0.46 2.47 } } { 1 4 0.24404 ': null + '{ 1 4 0.00654 { 1 3 -0.15750 { 2 1.00 0.00 0.45 } ': null + '{ 2 1.00 3.00 0.48 } } { 2 1.00 5.00 0.70 } } { 1 5 0.32635 ': null + { 2 0.52 2.52 4.21 } { 2 2.48 3.48 1.30 } } } } }: null + def `test_SimpleTree_regression_tree`: + '{ 0 2 { 1 4 0.13895 { 1 4 -0.32607 { 2 4.60993 1.71141 } ': null + '{ 2 4.96454 3.56122 } } { 2 7.09220 -4.32343 } } { 1 4 -0.35941 ': null + '{ 0 0 { 1 5 -0.20027 { 2 3.54255 0.95095 } { 2 5.50000 -5.56049 } ': null + '} { 2 7.62411 2.03615 } } { 1 5 0.40797 { 1 3 0.83459 ': null + '{ 2 3.71094 0.27028 } { 2 5.18490 3.70920 } } { 2 5.77083 5.93398 ': null + } } } }: null + def `test_SimpleTree_single_instance`: + iris: null + def `test_SimpleTree_to_string_classification`: + d1: null + ef: null + c1: null + cls: null + abc: null + e: null + a: null + b: null + f: null + c: null + \n: null + d1 ([2.0, 2.0, 2.0])\n: null + ': e\n': null + ' c1 ([2.0, 2.0, 0.0])\n': null + ' : <=2.5\n': null + ' c1 ([1.0, 2.0, 0.0])\n': null + ' : <=1.5 --> a ([1.0, 1.0, 0.0])\n': null + ' : >1.5 --> b ([0.0, 1.0, 0.0])\n': null + ' : >2.5 --> a ([1.0, 0.0, 0.0])\n': null + ': f --> c ([0.0, 0.0, 2.0])': null + def `test_SimpleTree_to_string_regression`: + d1: null + ef: null + c1: null + cls: null + e: null + f: null + \n: null + 'd1 (20: 6.0)\n': null + ': e\n': null + ' c1 (15: 4.0)\n': null + ' : <=2.5\n': null + ' c1 (16.6667: 3.0)\n': null + ' : <=1.5 --> (15: 2.0)\n': null + ' : >1.5 --> (20: 1.0)\n': null + ' : >2.5 --> (10: 1.0)\n': null + ': f --> (30: 2.0)': null + def `test_SimpleTree_to_string_cls_decimals`: + datasets/lenses.tab: null + ' astigmatic ([4.0, 3.0, 5.0])': null + \n: null + def `test_SimpleTree_to_string_reg_decimals`: + housing: null + ' LSTAT (19.9: 430.0)': null + \n: null + __main__: null +tests/test_softmax_regression.py: + class `TestSoftmaxRegressionLearner`: + def `setUpClass`: + iris: null +tests/test_sparse_reader.py: + '\ +abc, def, g=1, h , ij k =5, t # ignore this, foo=42 + +def , g , h,ij,kl=4,m,,, +# nothing here +\t\t\tdef +': null + '\ +abc, g=1, h , ij | k =5, t # ignore this, foo=42 + +, g , h,ij|,kl=4, k ;m,,, +# nothing here +\t\t\t;def +': null + class `TestTabReader`: + def `test_read_simple`: + ascii: null + abc: null + def: null + g: null + h: null + ij k: null + t: null + ij: null + kl: null + m: null + def `test_read_complex`: + ascii: null + abc: null + g: null + h: null + ij: null + k: null + t: null + kl: null + m: null + def: null + __main__: null +tests/test_sparse_table.py: + class `InterfaceTest`: + def `test_row_assignment`: + ignore: null + .*: null + def `test_value_assignment`: + ignore: null + .*: null + def `test_str`: + iris: null + def `test_Y_setter_1d`: + iris: null + def `test_Y_setter_2d`: + iris: null + def `test_Y_setter_2d_single_instance`: + iris: null +tests/test_stack.py: + class `TestStackedFitter`: + def `setUpClass`: + iris: null + housing: null +tests/test_statistics.py: + def `dense_sparse`: + def `_wrapper`: + def `sparse_with_explicit_zero`: + Can not inject explicit zero into non-sparse matrix: null + ignore: null + .*: null + class `TestUtil`: + def `setUp`: + nan: null + def `test_stats_non_numeric`: + a: null + b: null + def `test_stats_long_string_mem_use`: + a: null + def `test_nanmin_nanmax`: + ignore: null + .*All-NaN slice encountered.*: null + def `test_mean`: + ignore: null + .*mean\(\) resulted in nan.*: null +tests/test_svm.py: + class `TestSVMLearner`: + def `setUpClass`: + datasets/ionosphere.tab: null + def `test_LinearSVM`: + ignore: null + .*: null + def `test_SVR`: + rbf: null + def `test_NuSVR`: + rbf: null + __main__: null +tests/test_tab_reader.py: + class `TestTabReader`: + def `test_read_easy`: + '\ + Feature 1\tFeature 2\tClass 1\tClass 42 + d \tM F \td \td + \t \tclass \tclass + 1.0 \tM \t5 \trich + \tF \t7 \tpoor + 2.0 \tM \t4 \t + ': null + Feature 1: null + Feature 2: null + Class 1: null + Class 42: null + def `test_read_save_quoted`: + '\ + S\tA + s\td + m\t + """a"""\ti + """b"""\tj + """c\td"""\tk + ': null + '"a"': null + '"b"': null + '"c\td"': null + def `test_read_and_save_attributes`: + '\ + Feature 1\tFeature 2\tClass 1\tClass 42 + d \tM F \td \td + \ta=1 b=2 \tclass x=a\\ longer\\ string \tclass + 1.0 \tM \t5 \trich + ': null + Feature 2: null + a: null + b: null + Class 1: null + x: null + a longer string: null + /path/to/somewhere: null + path: null + def `test_read_data_oneline_header`: + '\ + data1\tdata2\tdata3 + 0.1\t0.2\t0.3 + 1.1\t1.2\t1.5 + ': null + data1: null + def `test_read_data_no_header`: + '\ + 0.1\t0.2\t0.3 + 1.1\t1.2\t1.5 + ': null + Feature 1: null + def `test_read_data_no_header_feature_reuse`: + '\ + 0.1\t0.2\t0.3 + 1.1\t1.2\t1.5 + ': null + def `test_renaming`: + '\ + a\t b\t a\t a\t b\t a\t c\t a\t b + c\t c\t c\t c\t c\t c\t c\t c\t c + \t \t \t \t class\t class\t \t \t meta + 0\t 0\t 0\t 0\t 0\t 0\t 0\t 0 ': null + wt: null + .tab: null + a (1): null + b (1): null + a (2): null + a (3): null + c: null + a (5): null + b (2): null + a (4): null + b (3): null + def `test_dataset_with_weird_names_and_column_attributes`: + datasets/weird.tab: null + 5534fab7fad58d5df50061f1: null + 5534fab8fad58d5de20061f8: null + Gene expressions (dd_AX4_on_Ka_20Hr_bio1_mapped.bam): null + Gene expressions (dd_AX4_on_Ka_20Hr_bio2_mapped.bam): null + 1: null + 2: null + def `test_sheets`: + \n: null + xd dbac: null + def `test_attributes_saving`: + test: null + out.tab: null + def `test_attributes_saving_as_txt`: + a: null + aa: null + b: null + bb: null + out.tab: null + def `test_data_name`: + iris: null + def `test_metadata`: + a: null + aa: null + b: null + bb: null + out.tab: null + .metadata: null + def `test_no_metadata`: + out.tab: null + .metadata: null + def `test_had_metadata_now_there_is_none`: + a: null + aa: null + out.tab: null + .metadata: null + def `test_number_of_decimals`: + heart_disease: null + age: null + ST by exercise: null + housing: null + CRIM: null + INDUS: null + AGE: null + def `test_many_discrete`: + Poser\nd\n\n: null + K: null + \n: null +tests/test_table.py: + class `TableTestCase`: + def `test_indexing_class`: + datasets/test1: null + t: null + f: null + d: null + def `test_filename`: + iris: null + iris.tab: null + datasets/test2.tab: null + test2.tab: null + def `test_indexing`: + ignore: null + datasets/test2: null + c: null + 0: null + b: null + a: null + A: null + e: null + i: null + def `test_indexing_example`: + ignore: null + datasets/test2: null + c: null + 0: null + b: null + a: null + A: null + e: null + i: null + def `test_indexing_assign_value`: + ignore: null + datasets/test2: null + a: null + A: null + B: null + b: null + def `test_indexing_assign_example`: + ignore: null + datasets/test2: null + a: null + 3.14: null + 1: null + f: null + t: null + 0: null + 3.16: null + e: null + mmmapp: null + def `test_slice`: + ignore: null + datasets/test2: null + def `test_assign_slice_value`: + ignore: null + datasets/test2: null + b: null + a: null + A: null + ABAAACCDE: null + def `test_multiple_indices`: + ignore: null + datasets/test2: null + def `test_assign_multiple_indices_value`: + ignore: null + datasets/test2: null + b: null + ?: null + def `test_set_multiple_indices_example`: + ignore: null + datasets/test2: null + def `test_bool`: + iris: null + datasets/test3: null + def `test_checksum`: + zoo: null + name: null + non-animal: null + def `test_total_weight`: + zoo: null + def `test_has_missing`: + zoo: null + ?: null + datasets/test3: null + def `test_shuffle`: + zoo: null + name: null + def `test_copy_sparse`: + iris: null + def `test_concatenate`: + abc: null + y: null + ABC: null + m1: null + m2: null + foo: null + bar: null + baz: null + qux: null + a: null + c: null + b: null + d: null + e: null + f: null + t2: null + g: null + h: null + i: null + j: null + k: null + l: null + m: null + n: null + t3: null + def `test_concatenate_exceptions`: + zoo: null + iris: null + def `test_concatenate_sparse`: + iris: null + Concatenated X is not sparse.: null + Concatenated Y is not dense.: null + Concatenated metas is not dense.: null + def `test_pickle`: + zoo: null + iris: null + def `test_pickle_setstate`: + zoo: null + Orange.data.Table.__setstate__: null + X: null + _Y: null + metas: null + W: null + _X: null + Y: null + _metas: null + _W: null + def `test_translate_through_slice`: + iris: null + petal length: null + sepal length: null + def `test_saveTab`: + iris: null + test-save.tab: null + test-save.tab.metadata: null + a: null + zoo: null + test-zoo.tab: null + test-zoo: null + Meta attributes don't match.: null + Attributes don't match.: null + Weights don't match.: null + test-zoo.tab.metadata: null + test-zoo-weights.tab: null + test-zoo-weights: null + test-zoo-weights.tab.metadata: null + def `test_save_pickle`: + iris: null + iris.pickle: null + def `test_from_numpy`: + d: null + abcd: null + e: null + no: null + yes: null + f: null + def `test_filter_is_defined`: + iris: null + def `test_filter_has_class`: + iris: null + def `test_filter_random`: + iris: null + Filter returns too uneven distributions: null + def `test_filter_values_nested`: + iris: null + def `test_filter_string_works_for_numeric_columns`: + s: null + 5: null + 15: null + 2: null + rows: null + {} returned wrong number of rows: null + def `test_filter_value_continuous`: + iris: null + def `test_filter_value_continuous_args`: + iris: null + petal length: null + sepal length: null + def `test_valueFilter_discrete`: + zoo: null + mammal: null + martian: null + def `test_valueFilter_string_is_defined`: + datasets/test9.tab: null + def `test_valueFilter_discrete_meta_is_defined`: + datasets/test9.tab: null + def `test_valueFilter_string_case_sens`: + zoo: null + name: null + girl: null + lion: null + ea: null + sea: null + ion: null + def `test_valueFilter_string_case_insens`: + zoo: null + name: null + girl: null + GIrl: null + giRL: null + CHiCKEN: null + chicken: null + liOn: null + lion: null + iR: null + ir: null + GI: null + gi: null + ion: null + def `test_valueFilter_regex`: + zoo: null + name: null + ^c...$: null + def `test_valueFilter_stringList`: + zoo: null + name: null + swan: null + tuna: null + wasp: null + WoRm: null + TOad: null + vOLe: null + rows: null + {} returned wrong number of rows: null + def `test_table_dtypes`: + iris: null + def `test_attributes`: + iris: null + test: null + modified: null + def `test_is_sparse`: + iris: null + def `test_repr_sparse_with_one_row`: + iris: null + \n: null + '[[sepal length=5.1, sepal width=3.5, ': null + petal length=1.4, petal width=0.2 | Iris-setosa]]: null + def `test_str`: + iris: null + [5.1, 3.5, 1.4, 0.2 | Iris-setosa]: null + \n: null + [[5.1, 3.5, 1.4, 0.2 | Iris-setosa],: null + ' [5.9, 3.0, 5.1, 1.8 | Iris-virginica]]': null + def `test_str_sparse`: + iris: null + '[sepal length=5.1, sepal width=3.5, ': null + petal length=1.4, petal width=0.2 | Iris-setosa]: null + \n: null + [: null + ,: null + '[sepal length=5.9, sepal width=3.0, ': null + petal length=5.1, petal width=1.8 | Iris-virginica]: null + ' ': null + ]: null + class `TableTests`: + Feature %i: null + Class %i: null + Meta %i: null + class `CreateTableWithFilename`: + data.tab: null + def `test_read_data_calls_reader`: + os.path.exists: null + .xlsx: null + test.xlsx: null + def `test_raises_error_if_file_does_not_exist`: + os.path.exists: null + def `test_raises_error_if_file_has_unknown_extension`: + os.path.exists: null + file.invalid_extension: null + def `test_calling_new_with_string_argument_calls_read_data`: + Orange.data.table.Table.from_file: null + def `test_calling_new_with_keyword_argument_filename_calls_read_data`: + Orange.data.table.Table.from_file: null + class `CreateTableWithUrl`: + def `test_url_no_scheme`: + www.foo.bar/xx.csv: null + Orange.data.io.UrlReader.urlopen: null + http://: null + class `_MockUrlOpen`: + content-disposition: null + 'attachment; filename="Something-FormResponses.tsv"; ': null + filename*=UTF-8: null + Something%20%28Responses%29.tsv: null + def `read`: + '\ +a\tb\tc +1\t2\t3 +2\t3\t4': null + def `test_trimmed_urls`: + Orange.data.io.urlopen: null + https://docs.google.com/spreadsheets/d/ABCD/edit: null + https://www.dropbox.com/s/ABCD/filename.csv: null + Mozilla/5.0: null + User-agent: null + Something-FormResponses: null + class `CreateTableWithDomain`: + def `test_calling_new_with_domain_calls_new_from_domain`: + Orange.data.table.Table.from_domain: null + class `CreateTableWithData`: + def `test_creates_a_table_from_domain_and_list`: + a: null + mf: null + b: null + y: null + abc: null + ?: null + m: null + c: null + def `test_creates_a_table_from_domain_and_list_and_weights`: + a: null + mf: null + b: null + y: null + abc: null + ?: null + m: null + c: null + def `test_creates_a_table_from_domain_and_list_and_metas`: + Meta 1: null + XYZ: null + Meta 2: null + Meta 3: null + a: null + mf: null + b: null + y: null + abc: null + X: null + bb: null + ?: null + Y: null + aa: null + m: null + Z: null + c: null + def `test_creates_a_table_from_list_of_instances`: + iris: null + def `test_creates_a_table_from_list_of_instances_with_metas`: + zoo: null + def `test_creates_a_discrete_class_if_Y_has_few_distinct_values`: + v1: null + v2: null + def `test_calling_new_with_domain_and_numpy_arrays_calls_new_from_numpy`: + Orange.data.table.Table.from_numpy: null + class `CreateTableWithDomainAndTable`: + def `test_transform`: + x: null + def `test_transform_same_domain`: + iris: null + def `test_can_filter_row_with_slice_from_table_rows`: + convert: null + def `test_can_filter_row_with_slice_from_table`: + Orange.data.table._FromTableConversion: null + def `test_from_table_with_boolean_row_filter`: + from_table_rows: null + new: null + def `test_from_table_sparse_move_some_to_empty_metas`: + iris: null + def `test_from_table_sparse_move_all_to_empty_metas`: + iris: null + def `test_from_table_sparse_move_to_nonempty_metas`: + brown-selected: null + def `test_from_table_partwise`: + sum_x: null + sum_y: null + sum_metas: null + def `long_table`: + abcdef: null + def `test_from_table_shared_compute_value`: + iris: null + def `test_attributes_copied`: + A: null + Test: null + B: null + Changed: null + class `InterfaceTest`: + Continuous Feature 1: null + Continuous Feature 2: null + Discrete Feature 1: null + 0: null + 1: null + Discrete Feature 2: null + value1: null + value2: null + Continuous Class: null + Discrete Class: null + m: null + f: null + class `TestRowInstance`: + def `test_assignment`: + zoo: null + mammal: null + fish: null + Foo: null + def `test_iteration_with_assignment`: + iris: null + class `TestTableTranspose`: + def `test_transpose_no_class`: + c1: null + c2: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_discrete_class`: + c1: null + c2: null + cls: null + a: null + b: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_continuous_class`: + c1: null + c2: null + cls: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + 4: null + 3: null + 2: null + 1: null + Feature name: Ime spremenljivke + def `test_transpose_missing_class`: + c1: null + c2: null + cls: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + 3: null + 2: null + 1: null + Feature name: Ime spremenljivke + def `test_transpose_multiple_class`: + c1: null + c2: null + cls1: null + cls2: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + 0: null + 1: null + 2: null + 3: null + 4: null + 5: null + 6: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_metas`: + c1: null + c2: null + m1: null + aa: null + bb: null + cc: null + dd: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_discrete_metas`: + c1: null + c2: null + m1: null + aa: null + bb: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_continuous_metas`: + c1: null + c2: null + m1: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + 0: null + 1: null + Feature name: Ime spremenljivke + def `test_transpose_missing_metas`: + c1: null + c2: null + m1: null + aa: null + bb: null + dd: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_multiple_metas`: + c1: null + c2: null + m1: null + m2: null + aa: null + aaa: null + bb: null + bbb: null + cc: null + ccc: null + dd: null + ddd: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_class_and_metas`: + c1: null + c2: null + m1: null + m2: null + cls: null + aa: null + aaa: null + bb: null + bbb: null + cc: null + ccc: null + dd: null + ddd: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + 1: null + 2: null + 3: null + 4: null + Feature name: Ime spremenljivke + def `test_transpose_attributes_of_attributes_discrete`: + c1: null + c2: null + attr1: null + a: null + attr2: null + aa: null + b: null + bb: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_attributes_of_attributes_continuous`: + c1: null + c2: null + attr1: null + 1.1: null + attr2: null + 1.3: null + 2.2: null + 2.3: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_attributes_of_attributes_missings`: + c1: null + c2: null + attr1: null + a: null + attr2: null + aa: null + b: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + Feature name: Ime spremenljivke + def `test_transpose_class_metas_attributes`: + c1: null + c2: null + attr1: null + a1: null + attr2: null + aa1: null + b1: null + bb1: null + m1: null + m2: null + cls: null + aa: null + aaa: null + bb: null + bbb: null + cc: null + ccc: null + dd: null + ddd: null + Feature 1: null + Feature 2: null + Feature 3: null + Feature 4: null + 1: null + 2: null + 3: null + 4: null + Feature name: Ime spremenljivke + def `test_transpose_duplicate_feature_names`: + iris: null + def `test_transpose`: + zoo: null + Feature name: Ime spremenljivke + def `test_transpose_callback`: + zoo: null + def `test_transpose_no_class_remove_inst`: + c1: null + c2: null + 1: null + 3: null + 5: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_discrete_class_remove_inst`: + c1: null + c2: null + cls: null + a: null + b: null + 1: null + 3: null + 5: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_continuous_class_remove_inst`: + c1: null + c2: null + cls: null + 1: null + 3: null + 5: null + 7: null + 4: null + 2: null + Feature name: Ime spremenljivke + def `test_transpose_missing_class_remove_inst`: + c1: null + c2: null + cls: null + 1: null + 3: null + 5: null + 7: null + 2: null + Feature name: Ime spremenljivke + def `test_transpose_multiple_class_remove_inst`: + c1: null + c2: null + cls1: null + cls2: null + 1: null + 3: null + 5: null + 7: null + 0: null + 2: null + 4: null + 6: null + Feature name: Ime spremenljivke + def `test_transpose_metas_remove_inst`: + c1: null + c2: null + m1: null + aa: null + bb: null + cc: null + dd: null + 1: null + 3: null + 5: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_discrete_metas_remove_inst`: + c1: null + c2: null + m1: null + aa: null + bb: null + 1: null + 3: null + 5: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_continuous_metas_remove_inst`: + c1: null + c2: null + m1: null + 1: null + 3: null + 5: null + 7: null + 0: null + Feature name: Ime spremenljivke + def `test_transpose_missing_metas_remove_inst`: + c1: null + c2: null + m1: null + aa: null + bb: null + dd: null + 1: null + 3: null + 5: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_multiple_metas_remove_inst`: + c1: null + c2: null + m1: null + m2: null + aa: null + aaa: null + bb: null + bbb: null + cc: null + ccc: null + dd: null + ddd: null + 1: null + 3: null + 5: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_class_and_metas_remove_inst`: + c1: null + c2: null + m1: null + m2: null + cls: null + aa: null + aaa: null + bb: null + bbb: null + cc: null + ccc: null + dd: null + ddd: null + 1: null + 3: null + 5: null + 7: null + 2: null + 4: null + Feature name: Ime spremenljivke + def `test_transpose_attributes_of_attributes_discrete_remove_inst`: + c1: null + c2: null + attr1: null + a: null + attr2: null + aa: null + b: null + bb: null + 1: null + 3: null + 5: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_attributes_of_attributes_continuous_remove_inst`: + c1: null + c2: null + attr1: null + 1.1: null + attr2: null + 1.3: null + 2.2: null + 2.3: null + 1: null + 3: null + 5: null + 7: null + Feature name: Ime spremenljivke + def `test_transpose_attributes_of_attributes_missings_remove_inst`: + c1: null + c2: null + attr1: null + a: null + attr2: null + aa: null + b: null + 0: null + 2: null + 4: null + 6: null + Feature name: Ime spremenljivke + def `test_transpose_class_metas_attributes_remove_inst`: + c1: null + c2: null + attr1: null + a1: null + attr2: null + aa1: null + b1: null + bb1: null + m1: null + m2: null + cls: null + aa: null + aaa: null + bb: null + bbb: null + cc: null + ccc: null + dd: null + ddd: null + 1: null + 3: null + 5: null + 7: null + 2: null + 4: null + Feature name: Ime spremenljivke + def `test_transpose_name`: + iris: null + class `TestTableSparseDense`: + def `setUp`: + iris: null + def `test_sparse_dense_transformation`: + iris: null + def `test_from_table_add_one_sparse_column`: + S1: null + def `test_from_table_add_lots_of_sparse_columns`: + S: null + def `test_from_table_replace_attrs_with_sparse`: + S1: null + def `test_from_table_sparse_metas`: + S1: null + def `test_from_table_sparse_metas_with_strings`: + text: null + S: null + class `ConcurrencyTests`: + def `test_from_table_non_blocking`: + iris: null + a: null + class `EfficientTransformTests`: + def `setUp`: + iris: null + __main__: null +tests/test_third_party.py: + class `TestPkgResources`: + def `test_parse_version`: + 3.4.1: null + 3.4.0: null + 3.4.dev: null + 3.4.1.dev: null + 3.4.2.dev: null +tests/test_transformation.py: + class `TestTransformation`: + def `setUpClass`: + zoo: null + def `test_pickling_target_domain`: + _target_domain: null + class `IdentityTest`: + def `test_identity`: + X: null + C: null + 0: null + 1: null + 2: null + S: null + A: null + B: null + D: null + def `test_eq_and_hash`: + x: null + y: null +tests/test_tree.py: + class `TestSklTreeLearner`: + def `test_classification`: + iris: null + def `test_regression`: + housing: null + class `TestTreeLearner`: + def `test_uses_preprocessors`: + iris: null + class `TestDecisionTreeClassifier`: + def `setUpClass`: + iris: null + def `test_criterion`: + entropy: null + def `test_splitter`: + random: null +tests/test_txt_reader.py: + '\ +Feature 1\tFeature 2\tFeature 3 +1.0 \t1.3 \t5 +2.0 \t42 \t7 +': null + '\ +Feature 1, Feature 2,Feature 3 +1.0, 1.3, 5 +2.0, 42, 7 +': null + '\ +1.0 \t1.3 \t5 +2.0 \t42 \t7 +': null + '\ +1.0, 1.3, 5 +2.0, 42, 7 +': null + '\ +a,b +d,c +, +e,1 +f,g +': null + '\ +A,B +1,A +2,B +3,A +?,B +5,? +': null + class `TestTabReader`: + def `read_easy`: + wt: null + 1: null + 2: null + 3: null + def `test_read_tab`: + 'Feature ': null + def `test_read_csv`: + 'Feature ': null + def `test_read_nonutf8_encoding`: + datasets/binary-blob.tab: null + NUL: null + error: null + datasets/invalid_characters.tab: null + def `test_noncontinous_marked_continuous`: + wt: null + line 5, column 2: null + def `test_pr1734`: + foo: null + wt: null + '\ +foo +time + +123123123 +': null + def `test_csv_sniffer`: + datasets/test_asn_data_working.csv: null +tests/test_url_reader.py: + class `TestUrlReader`: + def `test_basic_file`: + https://datasets.biolab.si/core/titanic.tab: null + https://datasets.biolab.si/core/grades.xlsx: null + def `test_zipped`: + http://datasets.biolab.si/core/philadelphia-crime.csv.xz: null + def `test_special_characters`: + http://file.biolab.si/text-semantics/data/elektrotehniski-: null + vestnik-clanki/detektiranje-utrdb-v-šahu-.txt: null + def `test_base_url_with_query`: + https://datasets.biolab.si/core/grades.xlsx?a=1&b=2: null + def `test_url_with_fragment`: + https://datasets.biolab.si/core/grades.xlsx#tab=1: null + def `test_special_characters_with_query_and_fragment`: + http://file.biolab.si/text-semantics/data/elektrotehniski-: null + vestnik-clanki/detektiranje-utrdb-v-šahu-.txt?a=1&b=2#c=3: null + __main__: null +tests/test_util.py: + class `TestUtil`: + def `test_get_entry_point`: + Orange3: null + gui_scripts: null + orange-canvas: null + def `test_export_globals`: + SOMETHING: null + TestUtil: null + def `test_deprecated`: + deprecated: null + identity: null + def `test_reprable`: + x: null + \n: null + ' ': null + ReplaceUnknownsRandom(: null + variable=ContinuousVariable(name='x',number_of_decimals=3),: null + distribution=Continuous([[0.],[0.]])): null + LogisticRegressionLearner(): null + def `test_deepgetattr`: + l.__len__.__call__: null + l.__nx__.__x__: null + def `test_nan_eq`: + nan: null + inf: null + def `test_nan_hash_stand`: + nan: null + def `test_vstack`: + dense: null + sparse: null + def `test_hstack`: + dense: null + sparse: null + def `assertCorrectArrayType`: + dense: null + sparse: null + def `test_raise_deprecations`: + ORANGE_DEPRECATIONS_ERROR: null + ORANGE_DEPRECATIONS_ERROR not set: null + foo: null + def `test_stats_sparse`: + iris: null + def `test_csc_array_equal`: + ignore: null + .*: null + __main__: null +tests/test_value.py: + class `TestValue`: + def `test_pickling_discrete_values`: + iris: null + def `test_pickling_string_values`: + zoo: null + name: null + def `test_compare_continuous`: + housing: null + MEDV: null + def `test_compare_discrete`: + G: null + M: null + F: null + def `test_compare_string`: + zoo: null + name: null + aardvark: null + def `test_hash`: + var: null + test: null + red: null + green: null + blue: null + def `test_as_values`: + x: null + s: null + a: null + b: null +tests/test_xlsx_reader.py: + def `get_dataset`: + xlsx_files: null + def `get_xlsx_reader`: + .xlsx: null + def `get_xls_reader`: + .xls: null + class `TestExcelReader`: + def `test_read_round_floats`: + round_floats: null + 1: null + 2: null + def `test_write_file`: + .xlsx: null + zoo: null + class `TestExcelHeader0`: + def `test_read`: + header_0: null + Feature {}: null + class `TextExcelSheets`: + def `test_sheets`: + header_0_sheet: null + Sheet1: null + my_sheet: null + Sheet3: null + def `test_named_sheet`: + header_0_sheet: null + my_sheet: null + header_0_sheet-my_sheet: null + def `test_named_sheet_table_xlsx`: + header_0_sheet.xlsx: null + my_sheet: null + header_0_sheet-my_sheet: null + def `test_named_sheet_table_xls`: + header_0_sheet.xls: null + my_sheet: null + header_0_sheet-my_sheet: null + class `TestExcelHeader1`: + def `test_no_flags`: + header_1_no_flags: null + green: null + red: null + def `test_flags`: + header_1_flags: null + d: null + b: null + acf: null + green: null + red: null + class `TestExcelHeader3`: + def `test_read`: + header_3: null + d: null + g: null + nan: null + b: null + acf: null + green: null + red: null + abcdefghijklmnopqrstuvw: null + class `TestMissingValues`: + def `test_read_errors`: + missing: null + C: null + __main__: null +tests/sql/test_filter.py: + class `TestIsDefinedSql`: + def `setUpDB`: + m: null + f: null + def `test_on_all_columns`: + postgres: null + mssql: null + def `test_selected_columns`: + postgres: null + mssql: null + def `test_all_columns_negated`: + postgres: null + def `test_selected_columns_negated`: + postgres: null + mssql: null + def `test_can_inherit_is_defined_filter`: + postgres: null + class `TestHasClass`: + def `setUpDB`: + m: null + f: null + def `test_has_class`: + postgres: null + mssql: null + def `test_negated`: + postgres: null + mssql: null + class `TestSameValueSql`: + def `setUpDB`: + a: null + m: null + f: null + b: null + def `test_on_continuous_attribute`: + postgres: null + mssql: null + def `test_on_continuous_attribute_with_unknowns`: + postgres: null + mssql: null + def `test_on_continuous_attribute_with_unknown_value`: + postgres: null + mssql: null + def `test_on_continuous_attribute_negated`: + postgres: null + def `test_on_discrete_attribute`: + postgres: null + mssql: null + a: null + def `test_on_discrete_attribute_with_unknown_value`: + postgres: null + mssql: null + def `test_on_discrete_attribute_with_unknowns`: + postgres: null + mssql: null + m: null + def `test_on_discrete_attribute_negated`: + postgres: null + mssql: null + a: null + def `test_on_discrete_attribute_value_passed_as_int`: + postgres: null + mssql: null + def `test_on_discrete_attribute_value_passed_as_float`: + postgres: null + mssql: null + class `TestValuesSql`: + def `setUpDB`: + a: null + m: null + f: null + b: null + def `test_values_filter_with_no_conditions`: + postgres: null + mssql: null + def `test_discrete_value_filter`: + postgres: null + mssql: null + a: null + def `test_discrete_value_filter_with_multiple_values`: + postgres: null + a: null + b: null + def `test_discrete_value_filter_with_None`: + postgres: null + def `test_continuous_value_filter_equal`: + postgres: null + mssql: null + def `test_continuous_value_filter_not_equal`: + postgres: null + def `test_continuous_value_filter_less`: + postgres: null + mssql: null + def `test_continuous_value_filter_less_equal`: + postgres: null + def `test_continuous_value_filter_greater`: + postgres: null + def `test_continuous_value_filter_greater_equal`: + postgres: null + def `test_continuous_value_filter_between`: + postgres: null + def `test_continuous_value_filter_outside`: + postgres: null + mssql: null + def `test_continuous_value_filter_isdefined`: + postgres: null + class `TestFilterStringSql`: + def `setUpDB`: + Lorem ipsum dolor sit amet, consectetur adipiscing: null + elit. Vestibulum vel dolor nulla. Etiam elit lectus, mollis nec: null + mattis sed, pellentesque in turpis. Vivamus non nisi dolor. Etiam: null + lacinia dictum purus, in ullamcorper ante vulputate sed. Nullam: null + congue blandit elementum. Donec blandit laoreet posuere. Proin: null + quis augue eget tortor posuere mollis. Fusce vestibulum bibendum: null + neque at convallis. Donec iaculis risus volutpat malesuada: null + vehicula. Ut cursus tempor massa vulputate lacinia. Pellentesque: null + eu tortor sed diam placerat porttitor et volutpat risus. In: null + vulputate rutrum lacus ac sagittis. Suspendisse interdum luctus: null + sem auctor commodo.: null + ' ': null + def `test_filter_string_is_defined`: + postgres: null + def `test_filter_string_equal`: + postgres: null + mssql: null + in: null + def `test_filter_string_equal_case_insensitive_value`: + postgres: null + In: null + in: null + def `test_filter_string_equal_case_insensitive_data`: + postgres: null + donec: null + Donec: null + def `test_filter_string_not_equal`: + postgres: null + in: null + def `test_filter_string_not_equal_case_insensitive_value`: + postgres: null + In: null + in: null + def `test_filter_string_not_equal_case_insensitive_data`: + postgres: null + donec: null + Donec: null + def `test_filter_string_less`: + postgres: null + mssql: null + A: null + def `test_filter_string_less_case_insensitive_value`: + postgres: null + In: null + in: null + def `test_filter_string_less_case_insensitive_data`: + postgres: null + donec: null + def `test_filter_string_less_equal`: + postgres: null + mssql: null + A: null + def `test_filter_string_less_equal_case_insensitive_value`: + postgres: null + In: null + in: null + def `test_filter_string_less_equal_case_insensitive_data`: + postgres: null + donec: null + def `test_filter_string_greater`: + postgres: null + mssql: null + volutpat: null + def `test_filter_string_greater_case_insensitive_value`: + postgres: null + In: null + in: null + def `test_filter_string_greater_case_insensitive_data`: + postgres: null + donec: null + def `test_filter_string_greater_equal`: + postgres: null + volutpat: null + def `test_filter_string_greater_equal_case_insensitive_value`: + postgres: null + In: null + in: null + def `test_filter_string_greater_equal_case_insensitive_data`: + postgres: null + donec: null + def `test_filter_string_between`: + postgres: null + a: null + c: null + def `test_filter_string_between_case_insensitive_value`: + postgres: null + I: null + O: null + i: null + o: null + def `test_filter_string_between_case_insensitive_data`: + postgres: null + i: null + O: null + o: null + def `test_filter_string_contains`: + postgres: null + et: null + def `test_filter_string_contains_case_insensitive_value`: + postgres: null + eT: null + et: null + def `test_filter_string_contains_case_insensitive_data`: + postgres: null + do: null + def `test_filter_string_outside`: + postgres: null + am: null + di: null + def `test_filter_string_outside_case_insensitive`: + postgres: null + d: null + k: null + def `test_filter_string_starts_with`: + postgres: null + D: null + def `test_filter_string_starts_with_case_insensitive`: + postgres: null + D: null + d: null + def `test_filter_string_ends_with`: + postgres: null + s: null + def `test_filter_string_ends_with_case_insensitive`: + postgres: null + S: null + s: null + def `test_filter_string_list`: + postgres: null + et: null + in: null + def `test_filter_string_list_case_insensitive_value`: + postgres: null + Et: null + In: null + et: null + in: null + def `test_filter_string_list_case_insensitive_data`: + postgres: null + mssql: null + donec: null + Donec: null + __main__: null +tests/sql/test_misc.py: + class `MiscSqlTests`: + def `test_discretization`: + postgres: null + sepal length: null + def `test_get_conditional_distribution`: + postgres: null + Cannot import widgets: null + sepal length: null + def `test_create_sql_contingency`: + postgres: null + Cannot import widgets: null +tests/sql/test_naive_bayes_sql.py: + class `NaiveBayesTest`: + def `test_NaiveBayes`: + postgres: null + Iris-setosa: null + Iris-virginica: null + Iris-versicolor: null + iris: null +tests/sql/test_sql_table.py: + class `TestSqlTable`: + def `discrete_variable`: + mf: null + def `test_constructs_correct_attributes`: + postgres: null + col0: null + '"col0"': null + col1: null + '"col1"': null + f: null + m: null + col2: null + '"col2"': null + def `test_make_attributes`: + postgres: null + def `test_len`: + postgres: null + mssql: null + def `test_bool`: + postgres: null + mssql: null + def `test_len_with_filter`: + postgres: null + mssql: null + m: null + x: null + def `test_XY_small`: + postgres: null + mssql: null + col2: null + 0: null + 1: null + 2: null + def `test_XY_large`: + postgres: null + mssql: null + Orange.data.sql.table.AUTO_DL_LIMIT: null + col2: null + 0: null + 1: null + 2: null + def `test_download_data`: + postgres: null + mssql: null + X: null + Y: null + metas: null + W: null + ids: null + col2: null + 0: null + 1: null + 2: null + def `test_query_all`: + postgres: null + mssql: null + def `test_unavailable_row`: + postgres: null + mssql: null + def `test_query_subset_of_attributes`: + postgres: null + mssql: null + sepal length: null + sepal width: null + double width: null + 2 * "sepal width": null + def `test_query_subset_of_rows`: + postgres: null + def `test_getitem_single_value`: + postgres: null + mssql: null + Iris-setosa: null + def `test_type_hints`: + postgres: null + mssql: null + iris: null + def `test_joins`: + postgres: null + "SELECT a.""sepal length"", + b. ""petal length"", + CASE WHEN b.""petal length"" < 3 THEN '<' + ELSE '>' + END AS ""qualitative petal length"" + FROM iris a + INNER JOIN iris b ON a.""sepal width"" = b.""sepal width"" + WHERE a.""petal width"" < 1 + ORDER BY a.""sepal length"", b. ""petal length"" ASC": null + qualitative petal length: null + <: null + >: null + def `_mock_attribute`: + '"%s"': null + def `test_universal_table`: + postgres: null + ' + SELECT + v1.col2 as v1, + v2.col2 as v2, + v3.col2 as v3, + v4.col2 as v4, + v5.col2 as v5 + FROM %(table_name)s v1 + INNER JOIN %(table_name)s v2 ON v2.col0 = v1.col0 AND v2.col1 = 2 + INNER JOIN %(table_name)s v3 ON v3.col0 = v2.col0 AND v3.col1 = 3 + INNER JOIN %(table_name)s v4 ON v4.col0 = v1.col0 AND v4.col1 = 4 + INNER JOIN %(table_name)s v5 ON v5.col0 = v1.col0 AND v5.col1 = 5 + WHERE v1.col1 = 1 + ORDER BY v1.col0 + ': null + '"%s"': null + iris: null + Iris-setosa: null + Iris-virginica: null + Iris-versicolor: null + def `test_class_var_type_hints`: + postgres: null + mssql: null + iris: null + def `test_meta_type_hints`: + postgres: null + mssql: null + iris: null + def `test_metas_type_hints`: + postgres: null + mssql: null + iris: null + def `test_select_all`: + postgres: null + mssql: null + SELECT * FROM iris: null + def `test_discrete_bigint`: + postgres: null + bigint: null + def `test_continous_bigint`: + postgres: null + mssql: null + bigint: null + def `test_discrete_int`: + postgres: null + int: null + def `test_continous_int`: + postgres: null + mssql: null + int: null + def `test_discrete_smallint`: + postgres: null + smallint: null + def `test_continous_smallint`: + postgres: null + mssql: null + smallint: null + def `test_boolean`: + postgres: null + F: null + T: null + False: null + True: null + boolean: null + def `test_discrete_char`: + postgres: null + mssql: null + M: null + F: null + char(1): null + def `test_discrete_bigger_char`: + postgres: null + M: null + F: null + char(10): null + def `test_meta_char`: + postgres: null + mssql: null + ABCDEFGHIJKLMNOPQRSTUVW: null + char(1): null + def `test_discrete_varchar`: + postgres: null + mssql: null + M: null + F: null + varchar(1): null + def `test_meta_varchar`: + postgres: null + mssql: null + ABCDEFGHIJKLMNOPQRSTUVW: null + varchar(1): null + def `test_time_date`: + postgres: null + 2014-04-12: null + 2014-04-13: null + 2014-04-14: null + 2014-04-15: null + 2014-04-16: null + date: null + def `test_time_time`: + postgres: null + 17:39:51: null + 11:51:48.46: null + 05:20:21.492149: null + 21:47:06: null + 04:47:35.8: null + time: null + def `test_time_timetz`: + postgres: null + 17:39:51+0200: null + 11:51:48.46+01: null + 05:20:21.4921: null + 21:47:06-0600: null + 04:47:35.8+0330: null + timetz: null + def `test_time_timestamp`: + postgres: null + 2014-07-15 17:39:51.348149: null + 2008-10-05 11:51:48.468149: null + 2008-11-03 05:20:21.492149: null + 2015-01-02 21:47:06.228149: null + 2016-04-16 04:47:35.892149: null + timestamp: null + def `test_time_timestamptz`: + postgres: null + 2014-07-15 17:39:51.348149+0200: null + 2008-10-05 11:51:48.468149+02: null + 2008-11-03 05:20:21.492149+01: null + 2015-01-02 21:47:06.228149+0100: null + 2016-04-16 04:47:35.892149+0330: null + timestamptz: null + def `test_double_precision`: + postgres: null + mssql: null + double precision: null + def `test_numeric`: + postgres: null + mssql: null + numeric(15, 2): null + def `test_real`: + postgres: null + mssql: null + real: null + def `test_serial`: + postgres: null + serial: null + def `test_smallserial`: + postgres>90200: null + smallserial: null + def `test_bigserial`: + postgres>90200: null + bigserial: null + def `test_text`: + postgres: null + ABCDEFGHIJKLMNOPQRSTUVW: null + text: null + def `test_other`: + postgres: null + bcd4d9c0-361e-bad4-7ceb-0d171cdec981: null + 544b7ddc-d861-0201-81c8-9f7ad0bbf531: null + b35a10f7-7901-f313-ec16-5ad9778040a6: null + b267c4be-4a26-60b5-e664-737a90a40e93: null + uuid: null + foo: null + def `test_recovers_connection_after_sql_error`: + postgres: null + mssql: null + SELECT 1/%s FROM %s: null + SELECT %s FROM %s: null + def `test_basic_stats`: + postgres: null + sepal length: null + def `test_basic_stats_on_large_data`: + postgres: null + Orange.data.sql.table.LARGE_TABLE: null + sepal length: null + def `test_distributions`: + postgres: null + mssql: null + def `test_contingencies`: + postgres: null + sepal width: null + iris: null + def `test_pickling_restores_connection_pool`: + postgres: null + def `test_list_tables_with_schema`: + postgres: null + DROP SCHEMA IF EXISTS orange_tests CASCADE: null + CREATE SCHEMA orange_tests: null + CREATE TABLE orange_tests.efgh (id int): null + INSERT INTO orange_tests.efgh (id) VALUES (1): null + INSERT INTO orange_tests.efgh (id) VALUES (2): null + orange_tests: null + efgh: null + def `test_nan_frequency`: + postgres: null + mssql: null + __main__: null +widgets/data/tests/test_owaggregatecolumns.py: + class `TestOWAggregateColumn`: + def `setUp`: + c1 c2 c3: null + t1 t2: null + a: null + b: null + c: null + d1 d2 d3: null + s1: null + foo: null + bar: null + c4: null + def `test_no_input`: + c1 c2 t2: null + def `test_compute_data`: + c1 c2 t2: null + Sum: Vsota + Max: null + def `test_var_name`: + test: null + d1: null + def `test_var_types`: + t1 c2 t2: null + t1 t2: null + Min: null + Max: null + Mean: null + Median: null + def `test_operations`: + c1 c2 t2: null + Sum: Vsota + Product: Produkt + Min: null + Max: null + Mean: null + Variance: Varianca + Median: Mediana + error in '{self.widget.operation}': null + def `test_operations_with_nan`: + c1 c2 t2: null + Sum: Vsota + Product: Produkt + Min: null + Max: null + Mean: null + Variance: Varianca + Median: Mediana + error in '{self.widget.operation}': null + def `test_contexts`: + c1 c2 t2: null + def `test_features_signal`: + c1 c2 t1: null + c1 t2: null + agg: null + c1 t2 d1: null + foo: null + d1 d2: null + def `test_selection_radios`: + c1 t2: null + agg: null + def `test_operation_changed`: + agg: null + Max: null + def `test_and_others`: + "'c1'": null + "'c1', 'c2', 'd1', 'd2', 't1' and 'd3'": "'c1', 'c2', 'd1', 'd2', 't1' in 'd3'" + "'c1', 'c2', 'd1', 'd2', 't1' and 1 more": "'c1', 'c2', 'd1', 'd2', 't1' in še 1 druga" + "'c1', 'c2' and 4 more": "'c1', 'c2' in še 4 druge" + def `test_missing`: + "'{attrs[0].name}'": null + "'{attrs[0].name}' and '{attrs[1].name}'": "'{attrs[0].name}' in '{attrs[1].name}'" + def `test_report`: + c1 c2 t2: null + c{i:02}: null + __main__: null +widgets/data/tests/test_owcolor.py: + class `AttrDescTest`: + def `test_name`: + x: null + y: null + def `test_no_compute_value`: + x: null + def `test_reset`: + x: null + y: null + def `test_to_dict`: + x: null + y: null + rename: null + foo: null + class `DiscAttrTest`: + def `setUp`: + x: null + a: null + b: null + c: null + def `test_values`: + a: null + b: null + c: null + d: null + def `test_create_variable`: + z: null + d: null + a: null + c: null + palette: null + def `test_reset`: + d: null + def `test_to_dict`: + y: null + rename: null + b2: null + renamed_values: null + b: null + colors: null + a: null + '#010203': null + c: null + '#020304': null + d: null + x: null + '#123456': null + def `test_from_dict_coliding_values`: + renamed_values: null + a: null + b: null + duplicate names: podvojenih imen + c: null + e: null + def `test_from_dict_exceptions`: + rename: null + colors: null + a: null + '#000000': null + '#00': null + '#qwerty': null + renamed_values: null + class `ContAttrDescTest`: + def `setUp`: + x: null + def `test_palette`: + foo: null + def `test_create_variable`: + z: null + colors: null + def `test_to_dict`: + x: null + y: null + rename: null + linear_viridis: null + colors: null + def `test_from_dict_exceptions`: + x: null + colors: null + no such palette: null + class `BaseTestColorTableModel`: + def `test_data`: + bar: null + def `test_set_data`: + foo: null + class `TestDiscColorTableModel`: + def `setUp`: + x: null + abc: null + y: null + def: null + z: null + ghijk: null + def `test_data`: + e: null + k: null + foo: null + def `test_set_data`: + k: null + foo: null + g: null + h: null + i: null + j: null + class `TestContColorTableModel`: + def `setUp`: + z: null + w: null + u: null + def `test_data`: + color_strip: null + Copy to all: Dodeli vsem + def `test_set_data`: + color_strip: null + class `TestColorStripDelegate`: + def `setUp`: + z: null + w: null + u: null + def `test_color_combo`: + closeEditor: null + def `test_paint`: + paint: null + class `TestOWColor`: + def `setUp`: + iris: null + def `test_invalid_input_colors`: + a: null + colors: null + invalid: null + def `test_unconditional_commit_on_new_signal`: + now: null + def `test_commit_on_data_changed`: + deferred: null + y: null + def `test_model_content`: + heart_disease: null + def `test_report`: + zoo: null + def `test_string_variables`: + zoo: null + def `test_changed_compute_value`: + x: null + def `test_reset`: + a: null + b: null + def `test_save`: + getSaveFileName: null + foo: null + bar: null + def `test_save_low`: + varA: null + abc: null + a2: null + varB: null + X: null + varC: null + c2: null + varD: null + linear_viridis: null + varE: null + foo.colors: null + categorical: null + rename: null + renamed_values: null + b: null + numeric: null + colors: null + def `test_load`: + Orange.widgets.data.owcolor.QMessageBox.critical: null + getOpenFileName: null + builtins.open: null + foo.colors: null + *.colors: null + json.load: null + err: null + d: null + def `test_load_ignore_warning`: + Orange.widgets.data.owcolor.QMessageBox.warning: null + foo: null + "'foo'": null + bar: null + "'foo' and 'bar'": "'foo' in 'bar'" + baz: null + "'foo', 'bar' and 'baz'": "'foo', 'bar' in 'baz'" + qux: null + "'foo', 'bar', 'baz' and 'qux'": "'foo', 'bar', 'baz' in 'qux'" + quux: null + "'foo', 'bar', 'baz', 'qux' and 'quux'": "'foo', 'bar', 'baz', 'qux' in 'quux'" + corge: null + "'foo', 'bar', 'baz', 'qux' and 2 other": "'foo', 'bar', 'baz', 'qux' in še dve drugi spremenljivki" + grault: null + "'foo', 'bar', 'baz', 'qux' and 3 other": "'foo', 'bar', 'baz', 'qux' in še tri druge spremenljivke" + def `_create_descs`: + var{c}: null + a: null + b: null + c: null + AB: null + CDE: null + def `test_parse_var_defs`: + categorical: null + varA: null + rename: null + a2: null + varB: null + renamed_values: null + b: null + X: null + numeric: null + varC: null + c2: null + varD: null + colors: null + linear_viridis: null + a: null + c: null + def `test_parse_var_defs_invalid`: + categorical: null + a: null + numeric: null + rename: null + b: null + def `test_parse_var_defs_shows_warnings`: + Orange.widgets.data.owcolor.QMessageBox.warning: null + categorical: null + varA: null + renamed_values: null + a: null + b: null + numeric: null + duplicate names: podvojenih imen + def `test_parse_var_defs_no_rename`: + Orange.widgets.data.owcolor.QMessageBox.warning: null + categorical: null + varA: null + rename: null + varB: null + numeric: null + duplicated names: podvojenih imen + X: null + varD: null + __main__: null +widgets/data/tests/test_owconcatenate.py: + class `TestOWConcatenate`: + def `setUp`: + iris: null + titanic: null + def `test_source`: + Source ID: Vir + Source: Izvir + class_vars: null + attributes: null + metas: null + iris: null + titanic: null + def `test_source_ignore_compute_value`: + iris: null + titanic: null + def `test_unconditional_commit_on_new_signal`: + now: null + def `test_same_var_name`: + x: null + abcd: null + def: null + def `test_duplicated_id_column`: + x: null + abcd: null + x (1): null + def `test_domain_intersect`: + X1: null + X2: null + X3: null + a: null + b: null + D1: null + D2: null + D3: null + S1: null + S2: null + def `test_domain_union`: + X1: null + X2: null + X3: null + a: null + b: null + D1: null + D2: null + D3: null + S1: null + S2: null + def `test_domain_union_duplicated_names`: + X1: null + X2: null + X3: null + a: null + b: null + D1: null + S1: null + X1 (1): null + X2 (1): null + X2 (2): null + X1 (2): null + def `test_get_part_union`: + X1: null + X2: null + X3: null + X4: null + a: null + b: null + D1: null + D2: null + D3: null + S1: null + S2: null + S3: null + attributes: null + class_vars: null + metas: null + def `test_get_part_intersection`: + X1: null + X2: null + X3: null + X4: null + a: null + b: null + D1: null + D2: null + D3: null + S1: null + S2: null + S3: null + attributes: null + class_vars: null + metas: null + def `test_get_unique_vars`: + X1: null + X2: null + a: null + b: null + c: null + e: null + d: null + abc: null + ebd: null + abced: null + def `test_different_number_decimals`: + x: null + __main__: null +widgets/data/tests/test_owcontinuize.py: + class `TestOWContinuize`: + def `test_empty_data`: + iris: null + def `test_continuous`: + housing: null + def `test_one_column_equal_values`: + iris: null + def `test_one_column_nan_values_normalize_sd`: + iris: null + def `test_one_column_nan_values_normalize_span`: + iris: null + def `test_disable_normalize_sparse`: + def `assert_enabled`: + Error in {method}: null + iris: null + def `test_normalizations`: + xyz: null + class `TestOWContinuizeUtils`: + def `test_dummy_coding_zero_based`: + foo: null + abc: null + foo=b: null + foo=c: null + def `test_dummy_coding_base_value`: + foo: null + abc: null + foo=b: null + foo=c: null + foo=a: null + def `test_one_hot_coding`: + foo: null + abc: null + foo={c}: null + class `TestWeightedIndicator`: + def `test_equality`: + d1: null + abc: null + d2: null + __main__: null +widgets/data/tests/test_owcorrelations.py: + class `TestOWCorrelations`: + def `setUpClass`: + iris: null + zoo: null + heart_disease: null + housing: null + def `test_input_data_with_constant_features`: + c1: null + c2: null + d1: null + def `test_input_data_cont_target`: + MEDV: null + def `test_output_correlations`: + Correlation: Korelacije + FDR: null + def `test_correlation_type`: + Spearman correlation: Spearmanova korelacija + def `test_select_feature`: + petal length: null + petal width: null + sepal length: null + def `test_vizrank_use_heuristic`: + Orange.widgets.data.owcorrelations.SIZE_LIMIT: null + def `test_select_feature_against_heuristic`: + Orange.widgets.data.owcorrelations.SIZE_LIMIT: null + class `TestCorrelationRank`: + def `setUpClass`: + iris: null + def `test_row_for_state`: + +0.200: null + class `TestKMeansCorrelationHeuristic`: + def `setUpClass`: + datasets/breast-cancer-wisconsin: null + def `test_get_states_one_cluster`: + iris: null + __main__: null +widgets/data/tests/test_owcreateclass.py: + class `TestHelpers`: + def `setUpClass`: + abc: null + a: null + bc: null + abcd: null + aa: null + bcd: null + rabc: null + x: null + def `test_map_by_substring`: + abc: null + a: null + bc: null + Bc: null + def `test_map_by_substring_with_map_values`: + abc: null + a: null + bc: null + def `test_value_from_string_substring`: + x: null + Orange.widgets.data.owcreateclass.map_by_substring: null + def `test_value_string_substring_flags`: + x: null + Orange.widgets.data.owcreateclass.map_by_substring: null + def `test_value_from_discrete_substring`: + x: null + def `test_value_from_discrete_substring_flags`: + x: null + Orange.widgets.data.owcreateclass.map_by_substring: null + def `test_valuefromstringsubstring_equality`: + d1: null + d2: null + abc: null + def: null + ghi: null + def `test_valuefromsdiscretesubstring_equality`: + d1: null + abc: null + ghi: null + d2: null + def: null + class `TestOWCreateClass`: + def `setUp`: + heart_disease: null + zoo: null + iris: null + def `_test_default_rules`: + C{i}: null + def `test_string_data`: + a: null + 54: null + 47: null + class: razred + name: null + def `_set_repeated`: + repeated: null + a: null + not repeated: null + b: null + c: null + def `_check_repeated`: + repeated: null + not repeated: null + def `new_class`: + a: null + repeated: null + b: null + not repeated: null + c: null + ?: null + def `_set_thal`: + thal: null + Cls1: null + Cls2: null + eversa: null + efect: null + def `_check_thal`: + thal: null + 117: null + 18: null + + 117: null + Cls1: null + Cls2: null + class: razred + reversable defect: null + fixed defect: null + def `test_flow_and_context_handling`: + C1: null + class: razred + thal: null + gender: null + ema: null + 97: null + 206: null + ma: null + + 97: null + C2: null + female: null + def `test_add_remove_lines`: + Cls3: null + a: null + 117: null + 18: null + + 117: null + 166: null + c: null + b: null + Cls1: null + Cls2: null + eversa: null + efect: null + def `test_report`: + thal: null + Cls3: null + a: null + b: null + c: null + def `test_bad_class_name`: + def `assertError`: + Data: null + class: null + gender: null + ' class ': null + def `test_same_class`: + a: null + __main__: null +widgets/data/tests/test_owcreateinstance.py: + class `TestOWCreateInstance`: + def `setUp`: + iris: null + def `test_output`: + created: ustvarjeni + def `test_output_append_data`: + Source ID: Vir + iris: null + created: ustvarjeni + __source_widget: null + def `_get_init_buttons`: + buttonBox: null + def `test_table`: + zoo: null + def `test_missing_values`: + c: null + m: null + a: null + b: null + def `test_cascade_widgets`: + Source ID: Vir + def `test_cascade_widgets_attributes`: + __source_widget: null + def `test_cascade_widgets_class_vars`: + __source_widget: null + class `TestDiscreteVariableEditor`: + def `setUp`: + Foo: null + Bar: null + def `test_init`: + Foo: null + def `test_edit`: + Bar: null + def `test_set_value`: + Bar: null + def `test_edit_missing_value`: + ?: null + def `test_set_missing_value`: + ?: null + class `TestContinuousVariableEditor`: + def `setUp`: + iris: null + def `test_missing_values`: + var: null + def `test_overflow`: + var: null + class `TestStringVariableEditor`: + def `test_edit`: + Foo: null + def `test_set_value`: + Foo: null + class `TestTimeVariableEditor`: + def `setUp`: + var: null + def `test_have_date_have_time`: + var: null + def `test_have_time`: + var: null + def `test_no_date_no_time`: + var: null + class `TestVariableDelegate`: + def `setUp`: + iris: null + __main__: null +widgets/data/tests/test_owcsvimport.py: + W: null + class `TestOWCSVFileImport`: + def `setUp`: + _local_settings: null + ascii: null + data-regions.tab: null + def `_check_data_regions`: + id: null + continent: null + state: null + UK: null + Russia: null + Mexico: null + def `test_restore`: + data-regions.tab: null + _session_items: null + Data: null + data-regions: null + def `test_restore_from_local`: + data-regions.tab: null + recent: null + path: null + options: null + _session_items_v2: null + 'local settings item must be recorded in _session_items_v2 when ': null + activated: null + Data: null + def `test_type_guessing`: + data-csv-types.tab: null + _session_items: null + __version__: null + Data: null + time: null + discrete1: null + discrete2: null + numeric1: null + numeric2: null + string: null + def `test_discrete_values_sort`: + data-csv-types.tab: null + ascii: null + _session_items: null + __version__: null + Data: null + 1: null + 3: null + 4: null + 5: null + 12: null + def `test_backward_compatibility`: + data-csv-types.tab: null + _session_items: null + __version__: null + Data: null + time: null + discrete1: null + discrete2: null + numeric1: null + numeric2: null + string: null + def `_browse_setup`: + _browse_dialog: null + exec: null + def `test_browse_prefix`: + basedir: null + def `test_browse_prefix_parent`: + bs: null + basedir: null + def `test_browse_for_missing`: + /this file does not exist.csv: null + _session_items: null + def `test_browse_for_missing_prefixed`: + __version__: null + _session_items_v2: null + basedir: null + this file does not exist.csv: null + data-regions.tab: null + def `test_browse_for_missing_prefixed_parent`: + origin1: null + basedir: null + this file does not exist.csv: null + __version__: null + _session_items_v2: null + class `TestImportDialog`: + def `test_dialog`: + grep_file.txt: null + utf-8: null + ' ': null + \": null + \\: null + class `TestModel`: + def `test_model`: + prefix: null + data-regions.tab: null + ${prefix}/data-regions.tab (missing): ${prefix}/data-regions.tab (ne obstaja) + ${prefix}/data-regions.tab: null + class `TestUtils`: + def `test_load_csv`: + 1/1/1990,1.0,[,one,\n: null + 1/1/1990,2.0,],two,\n: null + 1/1/1990,3.0,{,three,: null + ascii: null + M8[ns]: null + category: null + one: null + three: null + def `test_convert`: + I, J, K\n: null + ' , A, \n': null + B, , 1\n: null + ?, ., NA: null + ascii: null + B: null + ?: null + 1: null + NA: null + def `test_decimal_format`: + class `Dialect`: + ;: null + 3,21;3,37\n4,13;1.000,142: null + ascii: null + ,: null + .: null + def `test_open_compressed`: + abc: null + txt: null + gz: null + bz2: null + xz: null + zip: null + .{ext}: null + wt: null + ascii: null + rt: null + def `test_sniff_csv`: + A|B|C\n1|2|3\n1|2|3: null + '|': null + .: null + def `_open_write`: + w: null + wb: null + wt: null + r: null + .gz: null + .bz2: null + .xz: null + .zip: null + t: null + __main__: null +widgets/data/tests/test_owdatainfo.py: + class `TestOWDataInfo`: + def `test_data`: + abc: null + xyz: null + nm: null + att 1: null + att 2: null + att 3: null + name: null + foo: null + bar: null + def `test_sparse`: + xyzuw: null + def `test_sql`: + class `SqlTable`: + foo: null + bar: null + y: null + Orange.widgets.data.owdatainfo.SqlTable: null + threading.Thread: null + _p_size: null + __main__: null +widgets/data/tests/test_owdatasampler.py: + class `TestOWDataSampler`: + def `setUpClass`: + iris: null + zoo: null + def `test_bigger_size_with_replacement`: + 'Should be able to set a bigger size ': null + with replacement: null + def `test_cv_output_migration`: + sampling_type: null + compatibility_mode: null + __version__: null + __main__: null +widgets/data/tests/test_owdatasets.py: + class `TestOWDataSets`: + def `test_no_internet_connection`: + Orange.widgets.data.owdatasets.list_remote: null + Orange.widgets.data.owdatasets.list_local: null + Orange.widgets.data.owdatasets.log: null + def `test_only_local`: + Orange.widgets.data.owdatasets.list_remote: null + Orange.widgets.data.owdatasets.list_local: null + core: null + foo.tab: null + Orange.widgets.data.owdatasets.log: null + def `test_filtering`: + Orange.widgets.data.owdatasets.list_remote: null + Orange.widgets.data.owdatasets.list_local: null + core: null + foo.tab: null + language: null + English: null + bar.tab: null + Slovenščina: null + Orange.widgets.data.owdatasets.log: null + foo: null + baz: null + def `test_download_iris`: + Orange.widgets.data.owdatasets.list_remote: null + core: null + iris.tab: null + Orange.widgets.data.owdatasets.list_local: null + Orange.widgets.data.owdatasets.ensure_local: null + def `test_dir_depth`: + Orange.widgets.data.owdatasets.list_remote: null + Orange.widgets.data.owdatasets.list_local: null + dir1: null + dir2: null + foo.tab: null + bar.tab: null + Orange.widgets.data.owdatasets.log: null + __main__: null +widgets/data/tests/test_owdiscretize.py: + class `DataMixin`: + def `prepare_data`: + x: null + y: null + z: null + t: null + u: null + class `TestOWDiscretize`: + def `test_empty_data`: + iris: null + def `test_report`: + brown-selected: null + var_hints: null + alpha 0: null + alpha 7: null + alpha 14: null + alpha 21: null + 0.05: null + alpha 28: null + alpha 35: null + alpha 42: null + 0, 0.125: null + alpha 49: null + __version__: null + def `test_all`: + brown-selected: null + var_hints: null + alpha 0: null + alpha 7: null + alpha 14: null + alpha 21: null + 0.05: null + alpha 28: null + alpha 35: null + alpha 42: null + 0, 0.125: null + alpha 49: null + __version__: null + < 0: null + ≥ 0: null + < -0.15: null + -0.15 - -0.10: null + -0.10 - -0.05: null + -0.05 - 0.00: null + 0.00 - 0.05: null + 0.05 - 0.10: null + ≥ 0.10: null + 0 - 0.125: null + ≥ 0.125: null + def `test_get_values`: + 6: null + 7: null + 1, 2, 3, 4, 5: null + def `test_set_values`: + 6: null + 7: null + 1, 2, 3, 4, 5: null + def `test_varkeys_for_selection`: + x: null + u: null + def `test_change_selection_update_interface`: + x: null + 10: null + y: null + z: null + 5: null + t: null + def `test_update_hints`: + 10: null + x: null + y: null + z: null + t: null + 5: null + u: null + def `test_discretize_var`: + x: null + t: null + 10: null + keep: ohrani + foo error: null + <: null + removed: odstranjena + 1000: null + 1, 2, 3: null + def `test_update_discretizations`: + ytu: null + x: null + y: null + z: null + t: null + u: null + def `test_copy_to_manual`: + x: null + 2.5, 7.5, 12.5: null + z: null + 4.5, 9.5, 14.5: null + y: null + 3.5, 8.5, 13.5: null + u: null + def `test_migration_2_3`: + saved_var_states: null + age: null + rest SBP: null + cholesterol: null + max HR: null + ST by exercise: null + major vessels colored: null + __version__: null + autosend: null + controlAreaVisible: null + default_cutpoints: null + default_k: null + default_method_name: null + EqualFreq: null + context_settings: null + var_hints: null + "'1, 2, 3'": null + class `TestValidator`: + def `test_validate`: + 1: null + ,: null + -: null + 1,,: null + 1,a,: null + a: null + 1,1: null + 1,12: null + '1, 2 ': null + '1, 2, ': null + class `TestModels`: + def `test_model`: + x: null + freq: pogostost + width: širina + 3: null + y: null + keep: ohrani + class `TestDefaultDiscModel`: + def `test_data`: + 314: null + class `TestUtils`: + def `test_show_tip`: + Ha Ha: null + tip-label: null + Ha: null + def `test_format_desc`: + 10: null + 1: null + year: leto + 2: null + years: leti + day: dan + days: dneva + x: null + day(s): dan + def `test_fixed_width_disc`: + 5.3.1: null + abc: null + -5: null + 0: null + Orange.preprocess.discretize.FixedWidth: null + 5.13: null + 5: null + 42: null + def `test_fixed_time_width_disc`: + 5.3.1: null + 5.3: null + abc: null + -5: null + 0: null + Orange.preprocess.discretize.FixedTimeWidth: null + 5: null + 42: null + def `test_custom_discretization`: + 4 5: null + 2, 1, 5: null + 1, foo, 13: null + Orange.preprocess.discretize.Discretizer.: null + create_discretized_var: null + 1, 1.25, 1.5, 4: null + def `test_mdl_discretization`: + iris: null + Orange.preprocess.discretize.EntropyMDL: null + def `test_var_key`: + foo: null + bar: null + __main__: null +widgets/data/tests/test_oweditdomain.py: + class `TestReport`: + def `test_rename`: + X: null + Y: null + def `test_annotate`: + X: null + a: null + 1: null + b: null + z: null + 2: null + j: null + def `test_unlinke`: + X: null + a: null + 1: null + b: null + z: null + unlinked: odvezana + def `test_categories_mapping`: + C: null + a: null + b: null + c: null + aa: null + cc: null + ee: null + : null + def `test_categorical_merge_mapping`: + C: null + a: null + b1: null + b2: null + b: null + c: null + def `test_reinterpret`: + T: null + → (: null + class `TestOWEditDomain`: + def `setUp`: + iris: null + def `test_widget_state`: + sepal length: null + iris: null + Iris-setosa: null + datasets/cyber-security-breaches.tab: null + Date_Posted_or_Updated: null + Business_Associate_Involved: null + def `test_output_data`: + Iris 2: null + def `test_input_from_owcolor`: + Data: null + def `test_list_attributes_remain_lists`: + a: null + list: null + [1, 2, 4]: null + def `test_annotation_bool`: + a: null + hidden: null + False: null + def `test_duplicate_names`: + iris: null + sepal height: null + def `test_unlink`: + x: null + y: null + z: null + def `test_time_variable_preservation`: + datasets/cyber-security-breaches.tab: null + Date: null + def `test_restore`: + Categorical: null + iris: null + Iris-setosa: null + Iris-versicolor: null + Iris-virginica: null + Rename: null + Z: null + AsString: null + class `TestEditors`: + def `test_variable_editor`: + S: null + A: null + 1: null + B: null + b: null + T: null + a: null + 2: null + action-add-label: null + action-delete-label: null + def `test_continuous_editor`: + X: null + A: null + 1: null + B: null + b: null + def `test_discrete_editor`: + C: null + a: null + b: null + c: null + A: null + 1: null + B: null + def `test_discrete_editor_add_remove_action`: + C: null + a: null + b: null + c: null + A: null + 1: null + B: null + d: null + Should only mark item as removed: null + Did not change data: null + def `test_discrete_editor_merge_action`: + Orange.widgets.data.oweditdomain.GroupItemsDialog.exec: null + C: null + a: null + b: null + c: null + A: null + 1: null + B: null + AA: null + BB: null + CC: null + other: ostalo + variable_changed should emit exactly once: null + def `test_discrete_editor_rename_selected_items_action`: + C: null + a: null + b: null + c: null + A: null + 1: null + B: null + setVisible: null + BA: null + variable_changed should emit exactly once: null + def `test_discrete_editor_context_menu`: + C: null + a: null + b: null + c: null + A: null + 1: null + B: null + setVisible: null + def `test_time_editor`: + T: null + A: null + 1: null + B: null + b: null + A: null + a: null + aa: null + B: null + f: null + T: null + M8[us]: null + S: null + 0: null + 1: null + 2: null + Detect automatically: Zaznaj samodejno + def `test_reinterpret_editor`: + Z: null + def `test_reinterpret_editor_simulate`: + type-combo: null + def `cb`: + Z: null + Z: null + def `test_unlink`: + X: null + A: null + 1: null + B: null + b: null + class `TestModels`: + def `test_variable_model`: + A: null + g: null + B: null + class `TestDelegates`: + def `test_delegate`: + a: null + b: null + a \N{RIGHTWARDS ARROW} b: null + reinterpreted: pretolmačena + showText: null + class `TestTransforms`: + def `_test_common`: + _copy: null + A: null + 1: null + def `test_continous`: + X: null + def `test_string`: + S: null + def `test_time`: + X: null + def `test_discrete`: + D: null + a: null + b: null + def `test_discrete_rename`: + D: null + a: null + b: null + A: null + B: null + def `test_discrete_reorder`: + D: null + 2: null + 3: null + 1: null + 0: null + def `test_discrete_add_drop`: + D: null + 2: null + 3: null + 1: null + 0: null + A: null + def `test_discrete_merge`: + D: null + 2: null + 3: null + 1: null + 0: null + x: null + y: null + class `TestReinterpretTransforms`: + def `setUpClass`: + A: null + a: null + b: null + c: null + B: null + 0: null + 1: null + 2: null + C: null + D: null + S: null + T: null + 0.1: null + 2010: null + 1.0: null + 2020: null + def `test_as_string`: + a: null + 2: null + 0.25: null + 00:03:00: null + b: null + 1: null + 1.25: null + 00:06:00: null + c: null + 0: null + 0.2: null + 00:12:00: null + 0.0: null + 00:00:00: null + def `test_as_discrete`: + A: null + a: null + b: null + c: null + B: null + 0: null + 1: null + 2: null + C: null + 0.0: null + 0.2: null + 0.25: null + 1.25: null + D: null + 1970-01-01 00:00:00: null + 1970-01-01 00:03:00: null + 1970-01-01 00:06:00: null + 1970-01-01 00:12:00: null + def `test_as_time`: + _: null + 07.02.2022: null + 18.04.2021: null + 07.02.2022 01:02:03: null + 18.04.2021 01:02:03: null + 2021-02-08 01:02:03+01:00: null + 2021-02-07 01:02:03+01:00: null + 010203: null + 02-07: null + 04-18: null + 25.11.2021: null + 25.11.2021 00:00:00: null + 2021-11-25 00:00:00: null + 000000: null + 11-25: null + 2022-02-07: null + 2021-04-18: null + 2022-02-07 01:02:03: null + 2021-04-18 01:02:03: null + 2021-02-08 01:02:03+0100: null + 2021-02-07 01:02:03+0100: null + 01:02:03: null + 1900-02-07: null + 1900-04-18: null + s{i}: null + d{i}: null + def `test_reinterpret_string`: + {v.name}_{i}: null + Detect automatically: null + 0.1: null + 2010: null + 1.0: null + 2020: null + def `test_compound_transform`: + a: null + Z1: null + Z2: null + b: null + def `test_to_time_variable`: + Detect automatically: null + class `TestUtils`: + def `test_mapper`: + a: null + b: null + O: null + def `test_as_float_or_nan`: + a: null + 1.1: null + .2: null + NaN: null + def `test_column_str_repr`: + S: null + A: null + B: null + ?: null + C: null + 0.1: null + 1: null + D: null + a: null + b: null + T: null + 00:00:00: null + 00:00:01: null + class `TestLookupMappingTransform`: + def `setUp`: + S: null + a: null + b: null + def `test_transform`: + a: null + b: null + c: null + def `test_pickle`: + a: null + b: null + c: null + def `test_equality`: + v1: null + abc: null + v3: null + a: null + b: null + c: null + class `TestGroupLessFrequentItemsDialog`: + def `setUp`: + C: null + a: null + b: null + c: null + A: null + 1: null + B: null + def `test_dialog_open`: + a: null + b: null + def `test_group_selected`: + a: null + b: null + BA: null + def `test_group_less_frequent_abs`: + a: null + b: null + BA: null + c: null + def `test_group_less_frequent_rel`: + a: null + b: null + BA: null + c: null + def `test_group_keep_n`: + a: null + b: null + BA: null + c: null + def `test_group_less_frequent_missing`: + def `_test_correctness`: + b: null + c: null + __main__: null +widgets/data/tests/test_owfeatureconstructor.py: + class `FeatureConstructorTest`: + def `test_construct_variables_discrete`: + iris: null + Discrete Variable: null + "iris_one if iris == 'Iris-setosa' else iris_two ": null + if iris == 'Iris-versicolor' else iris_three: null + iris one: null + iris two: null + iris three: null + def `test_construct_variables_discrete_no_values`: + iris: null + Discrete Variable: null + str(iris)[-1]: null + ar: null + def `test_construct_variables_continuous`: + iris: null + Continuous Variable: null + pow(sepal_length + sepal_width, 2): null + def `test_construct_variables_datetime`: + housing: null + Date: null + '"2019-07-{:02}".format(int(MEDV/3))': null + 2019-07-{int(row['MEDV'] / 3):02}: null + def `test_construct_variables_string`: + iris: null + String Variable: null + str(iris) + '_name': null + _name: null + def `test_construct_numeric_names`: + iris: null + 0.1: null + 1: null + S: null + _0_1 + _1: null + def `test_construct_placement`: + ab: null + x: null + a + b: null + y: null + z: null + def `test_unicode_normalization`: + \u00b5: null + Micro Variable: null + def `test_transform_sparse`: + A: null + X: null + class `TestTools`: + def `test_free_vars`: + foo: null + single: null + foo; bar();: null + exec: null + def `freevars_`: + eval: null + 1: null + ...: null + a: null + f(1): null + f: null + f(x): null + x: null + a + 1: null + a + b: null + b: null + a[b]: null + f(x, *a): null + f(x, *a, y=1): null + f(x, *a, y=1, **k): null + k: null + f(*a, *b, k=c, **d, **e): null + c: null + d: null + e: null + True: null + "'True'": null + None: null + b'None': null + a < b: null + a < b <= c: null + 1 < a <= 3: null + {}: null + []: null + (): null + [a, 1]: null + '{a: b}': null + {a, b}: null + 0 if abs(a) < 0.1 else b: null + abs: null + 'lambda: a': null + 'lambda a: b + 1': null + 'lambda a: a + 1': null + '(lambda a: a + 1)(a)': null + 'lambda a, *arg: arg + (a,)': null + 'lambda a, *arg, **kwargs: arg + (a,)': null + 'lambda a: a + c': null + 'lambda a, b=k: a + c': null + 'lambda *a, b=k: a + c': null + 'lambda a,/, b=k: a + c': null + 'lambda a,/, b=k, **kwg: a + c and kwg': null + [a for a in b]: null + [a for a, k in b]: null + [(a, j) for a in b]: null + j: null + [a for k in b for a in k]: null + [a for k in b if k for a in k if a]: null + [a for k in b if kk for a in k if aa]: null + kk: null + aa: null + [1 + a for c in b if c]: null + {a for _ in [] if b}: null + def `test_validate_exp`: + 1: null + single: null + a; b: null + exec: null + def `validate_`: + eval: null + a: null + a + 1: null + a < 1: null + 1 < a: null + 1 < a < 10: null + a and b: null + not a: null + a if b else c: null + f(x): null + f(g(x)) + g(x): null + f(x, r=b): null + a[b]: null + a in {'a', 'b'}: null + {}: null + "{'a': 1}": null + (): null + []: null + [i async for i in s]: null + (i async for i in s): null + class `FeatureFuncTest`: + def `test_reconstruct`: + iris: null + sepal width: null + a * sepal_width + c: null + sepal_width: null + a: null + c: null + def `test_repr`: + a + 1: null + a: null + FeatureFunc('a + 1', [('a', 2)], {}, None, False, None): null + def `test_call`: + iris: null + sepal_width + 10: null + sepal_width: null + sepal width: null + def `test_string_casting`: + zoo: null + name[0]: null + name: null + def `test_missing_variable`: + zoo: null + type: null + type[0]: null + def `test_time_str`: + T: null + str(T): null + 1970-01-01: null + def `test_invalid_expression_variable`: + iris: null + 1 / petal_length: null + petal_length: null + petal length: null + def `test_hash_eq`: + iris: null + 1 / petal_length: null + petal_length: null + petal length: null + class `OWFeatureConstructorTests`: + def `test_create_variable_with_no_data`: + X1: null + def `test_error_invalid_expression`: + iris: null + X: null + 0: null + 0a: null + def `test_transform_error`: + iris: null + X: null + 1/0: null + 1: null + def `test_renaming_duplicate_vars`: + iris: null + 0: null + def `test_discrete_no_values`: + iris: null + A: null + D1: null + 1: null + def `test_missing_strings`: + S1: null + A: null + B: null + S2: null + S1 + S1: null + AA: null + BB: null + def `test_fix_values`: + Orange.widgets.data.owfeatureconstructor.QMessageBox: null + abc: null + ana: null + berta: null + cilka: null + y: null + ana.value + berta.value + cilka.value: null + ana + berta + cilka: null + ana.value + dani.value + cilka.value: null + apply: null + ana + dani.value + cilka: null + sqrt(berta): null + "sqrt({'a': 0, 'b': 1, 'c': 2}[berta])": null + def `test_migration_discrete_strings`: + Ana: null + 012: null + Cilka: null + context_settings: null + y: null + Ana + int(Cilka): null + u: null + Ana.value + 'X': null + 1X: null + int(Cilka): null + def `test_report`: + context_settings: null + a: null + x + 2: null + b: null + x < 3: null + c: null + x > 15: null + d: null + y > x: null + foo: null + bar: null + e: null + x ** 2 + y == 5: null + f: null + str(x): null + g: null + z: null + xyz: null + abcdefg: null + def `test_output_domain_picklable`: + iris: null + X1: null + max(0, sepal_width - 5): null + D1: null + HIGH if sepal_width > 5 else LOW: null + HIGH: null + LOW: null + D2: null + "'HIGH' if sepal_length > 5 else 'LOW'": null + T1: null + 0: null + T2: null + "'1900-01-01'": null + class `TestFeatureEditor`: + def `test_has_functions`: + abs: null + sqrt: null + class `FeatureConstructorHandlerTests`: + def `test_handles_builtins_in_expression`: + X: null + str(A) + str(B): null + A: null + B: null + str('foo'): null + str(X): null + def `test_handles_special_characters_in_var_names`: + X: null + A_2_f: null + A.2 f: null + __main__: null +widgets/data/tests/test_owfeaturestatistics.py: + VarDataPair: null + variable: null + data: null + continuous_full: null + continuous_missing: null + continuous_all_missing: null + continuous_same: null + rgb_full: null + r: null + g: null + b: null + rgb_missing: null + rgb_all_missing: null + rgb_bins_missing: null + rgb_same: null + ints_full: null + 2: null + 3: null + 4: null + ints_missing: null + ints_all_missing: null + ints_bins_missing: null + ints_same: null + time_full: null + time_missing: null + time_all_missing: null + time_same: null + time_negative: null + string_full: null + a: null + c: null + d: null + e: null + string_missing: null + string_all_missing: null + string_same: null + class `TestVariousDataSets`: + def `setUp`: + auto_commit: null + def `test_runs_on_iris`: + iris: null + def `test_does_not_crash_on_empty_domain`: + iris: null + def `test_on_edge_case_datasets`: + Failed on `{data.name}`: null + class `TestFeatureStatisticsOutputs`: + def `setUp`: + auto_commit: null + def `test_changing_data_updates_output`: + iris: null + def `test_changing_data_updates_output_with_autocommit`: + iris: null + def `test_output_statistics`: + continuous_full: null + 0: null + continuous_missing: null + rgb_full: null + g: null + rgb_missing: null + class `TestFeatureStatisticsUI`: + def `setUp`: + auto_commit: null + iris: null + zoo: null + def `test_settings_migration_to_ver21`: + controlAreaVisible: null + savedWidgetGeometry: null + __version__: null + context_settings: null + auto_commit: null + color_var: null + iris: null + selected_rows: null + sorting: null + petal length: null + petal width: null + sepal length: null + sepal width: null + def `test_report`: + : null + : null + __main__: null +widgets/data/tests/test_owfile.py: + datasets: null + titanic.tab: null + class `FailedSheetsFormat`: + .failed_sheet: null + Make a sheet function that fails: null + def `sheets`: + Not working: null + class `WithWarnings`: + .with_warning: null + Warning: null + def `read`: + Some warning: null + iris: null + class `MyCustomTabReader`: + .tab: null + Always return iris: null + def `read`: + iris: null + class `TestOWFile`: + def `test_describe_call_get_nans`: + iris: null + get_nan_frequency_attribute: null + def `test_dragEnterEvent_skips_osx_file_references`: + /.file/id=12345: null + def `test_dragEnterEvent_skips_usupported_files`: + file.unsupported: null + def `test_domain_changes_are_stored`: + iris: null + text: besedilna + zoo: null + def `test_rename_duplicates`: + iris: null + iris (1): null + iris (2): null + different iris: null + def `test_variable_name_change`: + iris: null + a: null + d: null + b: null + text: besedilna + c: null + categorical: kategorična + zoo: null + numeric: številska + def `test_no_last_path`: + recent_paths: null + def `test_file_not_found`: + test_owfile_data.tab: null + d1: null + a: null + b: null + c1: null + aaa: null + bbb: null + No data.: Ni podatkov. + iris: null + def `test_nothing_selected`: + recent_paths: null + def `test_check_column_noname`: + iris: null + ' ': null + def `test_invalid_role_mode`: + iris: null + def `test_context_match_includes_variable_values`: + '\ +var +a b + +a +': null + '\ +var +a b c + +a +': null + .tab: null + a, b: null + a, b, c: null + def `test_check_datetime_disabled`: + '\ + 01.08.16\t42.15\tneumann\t2017-02-20 + 03.08.16\t16.08\tneumann\t2017-02-21 + 04.08.16\t23.04\tneumann\t2017-02-22 + 03.09.16\t48.84\tturing\t2017-02-23 + 02.02.17\t23.16\tturing\t2017-02-24': null + .tab: null + def `test_reader_custom_tab`: + .tab: null + recent_paths: null + def `test_no_reader_extension`: + .xyz_unknown: null + recent_paths: null + def `test_fail_sheets`: + .failed_sheet: null + def `test_with_warnings`: + .with_warning: null + def `test_fail`: + name\nc\n\nstring: null + .tab: null + Orange.widgets.data.owfile.log.exception: null + def `test_read_format`: + iris: null + def `open_iris_with_no_spec_format`: + ;;: null + AnyQt.QtWidgets.QFileDialog.getOpenFileName: null + Orange.data.io.TabReader: null + Tab-separated: Vrednosti, ločene s tabulatorjem + def `test_no_specified_reader`: + .tab: null + not.a.file.reader.class: null + recent_paths: null + def `test_select_reader`: + iris.tab: null + not.a.file.reader.class: null + recent_paths: null + Tab-separated: Vrednosti, ločene s tabulatorjem + def `test_select_reader_errors`: + iris.tab: null + Orange.data.io.ExcelReader: null + recent_paths: null + Excel: null + def `test_domain_edit_no_changes`: + iris: null + def `test_domain_edit_on_sparse_data`: + iris: null + .pickle: null + wb: null + def `test_drop_data_when_everything_skipped`: + iris: null + skip: izpusti + def `test_call_deprecated_dialog_formats`: + Tab: tabulator + def `test_add_new_format`: + .tab: null + Orange.widgets.data.owfile.open_filename_dialog: null + def `test_domain_editor_conversions`: + 'V0\tV1\tV2\tV3\tV4\tV5\tV6 + c\tc\td\td\tc\td\td + \t \t \t \t \t \t + 3.0\t1.0\t4\ta\t0.0\tx\t1.0 + 1.0\t2.0\t4\tb\t0.0\ty\t2.0 + 2.0\t1.0\t7\ta\t0.0\ty\t2.0 + 0.0\t2.0\t7\ta\t0.0\tz\t2.0': null + .tab: null + categorical: kategorična + text: besedilna + numeric: številska + def `test_domaineditor_continuous_to_string`: + V0\nc\n\n1.0\nnan\n3.0: null + .tab: null + text: besedilna + 1: null + 3: null + def `test_domaineditor_makes_variables`: + V0\tV1\nc\td\n\n1.0\t2: null + V0: null + V1: null + .tab: null + text: besedilna + numeric: številska + def `test_url_no_scheme`: + foo.bar/xxx.csv: null + Orange.widgets.data.owfile.UrlReader: null + http://: null + def `test_adds_origin`: + origin1/images: null + image: null + origin: null + origin1: null + origin2/images: null + origin2: null + def `test_open_moved_workflow`: + Orange.widgets.widget.OWWidget.workflowEnv: null + basedir: null + temp/datasets: null + datasets: null + recent_paths: null + def `test_files_relocated`: + Orange.widgets.widget.OWWidget.workflowEnv: null + basedir: null + temp/datasets: null + datasets: null + recent_paths: null + def `test_sheets`: + ..: null + tests: null + xlsx_files: null + header_0_sheet.xlsx: null + my_sheet: null + Sheet1: null + Sheet3: null + no such sheet: null + def `test_warning_from_another_thread`: + os.path.exists: null + def `read`: + warning from another thread: null + foo: null + def `test_warning_from_this_thread`: + os.path.exists: null + warning from this thread: null + foo: null + def `test_recent_url_serialization`: + load_data: null + https://example.com/test.tab: null + https://example.com/test1.tab: null + recent_urls: null + class `TestOWFileDropHandler`: + def `test_canDropUrl`: + https://example.com/test.tab: null + test.tab: null + def `test_parametersFromUrl`: + https://example.com/test.tab: null + source: null + recent_urls: null + test.tab: null + recent_paths: null + /foo.tab: null + foo.tab: null + defaults: null + __main__: null +widgets/data/tests/test_owgroupby.py: + class `TestOWGroupBy`: + def `setUp`: + iris: null + def `test_data_domain_changed`: + Mean: Povprečje + Mode: Najpogostejša + def `test_attr_table_row_selection`: + Mean: Povprečje + Median: Mediana + Q1: null + Q3: null + Min. value: Najmanjša vrednost + Max. value: Največja vrednost + Mode: Najpogostejša + Sum: Vsota + Standard deviation: Standardna deviacija + Variance: Varianca + Count defined: Število znanih + Count: Velikost skupine + Concatenate: Stakni + Span: Razpon + First value: Prva vrednost + Last value: Zadnja vrednost + Random value: Naključna + Proportion defined: Delež znanih + a: null + b: null + cvar: null + dvar: null + svar: null + def `test_aggregations_change`: + Mean: Povprečje + Mode: Najpogostejša + Concatenate: Stakni + a: null + b: null + cvar: null + dvar: null + svar: null + Median: Mediana + Mean, Median: Povprečje, Mediana + Mean, Median, Mode: Povprečje, Mediana, Najpogostejša + Mean, Mode: Povprečje, Najpogostejša + Count: Velikost skupine + Mean, Mode, Count: Povprečje, Najpogostejša, Velikost skupine + Mode, Count: Najpogostejša, Velikost skupine + Mean, Count: Povprečje, Velikost skupine + Count defined: Število znanih + Mean, Mode, Count defined and 1 more: Povprečje, Najpogostejša, Število znanih in 1 druga + Mean, Mode, Count defined: Povprečje, Najpogostejša, Število znanih + Concatenate, Count defined: Stakni, Število znanih + def `test_aggregation`: + sval1 sval2 sval2 sval1 sval2 sval1: null + sval2 sval1 sval2 sval1 sval2 sval1: null + cvar - Mean: cvar - Povprečje + cvar - Median: cvar - Mediana + cvar - Q1: null + cvar - Q3: null + cvar - Min. value: cvar - Najmanjša vrednost + cvar - Max. value: cvar - Največja vrednost + cvar - Mode: cvar - Najpogostejša + cvar - Standard deviation: cvar - Standardna deviacija + cvar - Variance: cvar - Varianca + cvar - Sum: cvar - Vsota + cvar - Span: cvar - Razpon + cvar - First value: cvar - Prva vrednost + cvar - Last value: cvar - Zadnja vrednost + cvar - Count defined: cvar - Število znanih + cvar - Count: cvar - Velikost skupine + cvar - Proportion defined: cvar - Delež znanih + dvar - Mode: dvar - Najpogostejša + dvar - First value: dvar - Prva vrednost + dvar - Last value: dvar - Zadnja vrednost + dvar - Count defined: dvar - Število znanih + dvar - Count: dvar - Velikost skupine + dvar - Proportion defined: dvar - Delež znanih + svar - First value: svar - Prva vrednost + svar - Last value: svar - Zadnja vrednost + svar - Count defined: svar - Število znanih + svar - Count: svar - Velikost skupine + svar - Proportion defined: svar - Delež znanih + cvar - Concatenate: cvar - Stakni + dvar - Concatenate: dvar - Stakni + svar - Concatenate: svar - Stakni + a: null + b: null + val1: null + val2: null + sval1: null + sval2: null + 0.1 0.2: null + val1 val2: null + sval1 sval2: null + 0.3: null + 0.3 0.4 0.6: null + val1 val2 val1: null + sval1 sval2 sval1: null + 1.0 2.0: null + val2 val1: null + sval2 sval1: null + 3.0 -4.0: null + 5.0 5.0: null + Random value: Naključna + def `test_metas_results`: + svar: null + def `test_context`: + Mean: Povprečje + Mode: Najpogostejša + Concatenate: Stakni + Median: Mediana + Mean, Median: Povprečje, Mediana + a: null + b: null + cvar: null + dvar: null + svar: null + def `test_context_time_variable`: + T: null + G: null + G1: null + G2: null + Sum: null + Median: Mediana + Mean: Povprečje + def `test_unexpected_error`: + Orange.data.aggregate.OrangeTableGroupBy.aggregate: null + Test unexpected err: null + def `test_time_variable`: + ..: null + tests: null + datasets: null + test10.tab: null + c2: null + d2: null + Mean: Povprečje + Mode: Najpogostejša + Mean, Median, Q1 and 14 more: Povprečje, Mediana, Q1 in 14 drugih + def `test_time_variable_results`: + G: null + G1: null + G2: null + G3: null + T: null + Mode: Najpogostejša + Mean: Povprečje + Mean, Median, Q1 and 14 more: Povprečje, Mediana, Q1 in 14 drugih + T - Mean: T - Povprečje + 1970-01-01 00:00:10: null + 1970-01-01 00:12:30: null + 1970-01-01 00:00:01: null + T - Median: T - Mediana + T - Q1: null + 1970-01-01 00:00:05: null + 1970-01-01 00:10:25: null + T - Q3: null + 1970-01-01 00:00:15: null + 1970-01-01 00:14:35: null + T - Min. value: T - Najmanjša vrednost + 1970-01-01 00:00:00: null + 1970-01-01 00:08:20: null + T - Max. value: T - Največja vrednost + 1970-01-01 00:00:20: null + 1970-01-01 00:16:40: null + T - Mode: T - Najpogostejša + T - Standard deviation: T - Standardna deviacija + T - Variance: T - Varianca + T - Span: T - Razpon + T - First value: T - Prva vrednost + T - Last value: T - Zadnja vrednost + T - Count defined: T - Število znanih + T - Count: T - Velikost skupine + T - Proportion defined: T - Delež znanih + T - Concatenate: T - Stakni + 1970-01-01 00:00:00 1970-01-01 00:00:10 1970-01-01 00:00:20: null + 1970-01-01 00:08:20 1970-01-01 00:16:40: null + Random value: Naključna + T - Random value: T - Naključna + def `test_tz_time_variable_results`: + T: null + G: null + G1: null + G2: null + 1970-01-01 01:00:00+01:00: null + 1970-01-01 01:00:10+01:00: null + 1970-01-01 01:00:20+01:00: null + Mode: Najpogostejša + Mean: Povprečje + Mean, Median, Q1 and 14 more: Povprečje, Mediana, Q1 in 14 drugih + T - Mean: T - Povprečje + 1970-01-01 00:00:10: null + T - Median: T - Mediana + T - Q1: null + 1970-01-01 00:00:05: null + T - Q3: null + 1970-01-01 00:00:15: null + T - Min. value: T - Najmanjša vrednost + 1970-01-01 00:00:00: null + T - Max. value: T - Največja vrednost + 1970-01-01 00:00:20: null + T - Mode: T - Najpogostejša + T - Standard deviation: T - Standardna deviacija + T - Variance: T - Varianca + T - Span: T - Razpon + T - First value: T - Prva vrednost + T - Last value: T - Zadnja vrednost + T - Count defined: T - Število znanih + T - Count: T - Velikost skupine + T - Proportion defined: T - Delež znanih + T - Concatenate: T - Stakni + 1970-01-01 00:00:00 1970-01-01 00:00:10 1970-01-01 00:00:20: null + Random value: Naključna + def `test_only_nan_in_group`: + A: null + B: null + B - Mean: B - Povprečje + B - Median: B - Mediana + B - Q1: null + B - Q3: null + B - Min. value: B - Najmanjša vrednost + B - Max. value: B - Največja vrednost + B - Mode: B - Najpogostejša + B - Standard deviation: B - Standardna deviacija + B - Variance: B - Varianca + B - Sum: B - Vsota + B - Span: B - Razpon + B - First value: B - Prva vrednost + B - Last value: B - Zadnja vrednost + B - Random value: B - Naključna + B - Count defined: B - Število znanih + B - Count: B - Velikost skupine + B - Proportion defined: B - Delež znanih + B - Concatenate: B - Stakni + 1.0 1.0: null + __main__: null +widgets/data/tests/test_owimpute.py: + class `TestOWImpute`: + def `test_empty_data`: + iris: null + Data: null + def `test_model_error`: + brown-selected: null + def `test_select_method`: + iris: null + def `test_overall_default`: + c{i}: null + t{i}: null + def `test_value_edit`: + heart_disease: null + chest pain: null + rest SBP: null + cholesterol: null +widgets/data/tests/test_owmelt.py: + def `data_without_commit`: + def `wrapped`: + Orange.widgets.data.owmelt.OWMelt.commit: null + class `TestOWMeltBase`: + def `setUp`: + gender: null + f: null + m: null + age: null + pretzels: null + telezka: null + big: null + small: null + name: null + greeting: null + ana: null + hi: null + berta: null + hello: null + cilka: null + evgen: null + foo: null + class `TestOWMeltFunctional`: + def `test_idvar_model`: + iris: null + def `test_no_suitable_features`: + heart_disease: null + def `test_invalidates`: + heart_disease: null + class `TestOWMeltUnit`: + def `test_is_unique`: + name: null + telezka: null + gender: null + greeting: null + def `test_nonnan_mask`: + Ana: null + Berta: null + Dani: null + def `test_get_useful_vars`: + name: null + gender: null + age: null + pretzels: null + telezka: null + def `test_get_item_names`: + age: null + telezka: null + def `test_prepare_domain_names`: + name: null + the item: null + the value: null + age: null + pretzels: null + Ana: null + Berta: null + Dani: null + telezka: null + def `test_prepare_domain_renames`: + age: null + pretzels: null + Ana: null + Berta: null + Dani: null + a: null + b: null + def `test_prepare_domain_values`: + name: null + age: null + pretzels: null + Ana: null + Berta: null + Dani: null + telezka: null + def `test_reshape_dense_by_meta`: + name: null + def `test_reshape_dense_by_attr`: + telezka: null + def `test_reshape_sparse_by_meta`: + name: null + def `test_reshape_sparse_by_attr`: + telezka: null + class `TestContextHandler`: + def `test_decode_calls_super`: + decode_setting: null + idvar: null + not_idvar: null + def `test_encode_calls_super`: + encode_setting: null + idvar: null + not_idvar: null + __main__: null +widgets/data/tests/test_owmergedata.py: + class `TestOWMergeData`: + def `setUpClass`: + dA1: null + a: null + b: null + c: null + d: null + dA2: null + aa: null + bb: null + clsA: null + aaa: null + bbb: null + ccc: null + mA1: null + cc: null + dd: null + mA2: null + m1: null + m2: null + m3: null + m4: null + dB1: null + dB2: null + clsB: null + mB1: null + m5: null + dataA: null + dataA attributes: null + dataB: null + dataB attributes: null + def `test_attr_combo_tooltips`: + : null + def `test_match_attr_name`: + dA1: null + a: null + b: null + c: null + d: null + dA2: null + aa: null + bb: null + dA3: null + cls: null + aaa: null + bbb: null + ccc: null + mA1: null + cc: null + dd: null + mA2: null + m1: null + m2: null + m3: null + m4: null + dB1: null + m5: null + dataA: null + dataA attributes: null + dataB: null + dataB attributes: null + def `test_migrate_settings`: + Position (index): null + attr_combine_extra: null + Source position (index): null + attr_pairs: null + __version__: null + def `test_migrate_settings_attr_pairs_extra_none`: + attr_pairs: null + sepal length: null + context_settings: null + def `test_migrate_settings_attr_pairs_data_none`: + attr_pairs: null + sepal length: null + context_settings: null + def `test_migrate_settings_attr_pairs_id_idx`: + attr_pairs: null + context_settings: null + def `test_migrate_settings_attr_pairs_vars`: + attr_pairs: null + sepal length: null + sepal width: null + petal length: null + petal width: null + context_settings: null + def `test_no_matches`: + dA1: null + dB2: null + def `test_output_merge_by_ids_inner`: + m2: null + m3: null + clsA: null + def `test_output_merge_by_ids_outer`: + clsA (1): null + clsA (2): null + m2: null + m3: null + m1: null + clsA: null + def `test_output_merge_by_ids_outer_single_class`: + clsA: null + m1: null + m2: null + m3: null + def `test_output_merge_by_index_left`: + m1: null + m2: null + m3: null + m4: null + def `test_output_merge_by_index_inner`: + m1: null + m2: null + m3: null + def `test_output_merge_by_index_outer`: + m1: null + m2: null + m3: null + m4: null + def `test_output_merge_by_attribute_left`: + m1: null + m2: null + m3: null + m4: null + def `test_output_merge_by_attribute_inner`: + m1: null + m2: null + m3: null + def `test_output_merge_by_attribute_outer`: + m1: null + m2: null + m3: null + m4: null + def `test_output_merge_by_attribute_outer_same_attr`: + name: null + x: null + y: null + a: null + b: null + c: null + d: null + ' ': null + a a b b c c d: null + def `test_output_merge_by_class_left`: + m1: null + m2: null + m3: null + m4: null + def `test_output_merge_by_class_inner`: + m2: null + m3: null + def `test_output_merge_by_class_outer`: + m1: null + m2: null + m3: null + m4: null + def `test_output_merge_by_meta_left`: + m1: null + m2: null + m3: null + m4: null + def `test_output_merge_by_meta_inner`: + m4: null + def `test_output_merge_by_meta_outer`: + m1: null + m2: null + m3: null + m4: null + def `test_best_match`: + zoo: null + datasets/zoo-with-images.tab: null + name: null + wrong attributes chosen for merge_type={i}: null + def `test_sparse`: + iris: null + titanic: null + Data: null + Extra Data: null + def `test_multiple_attributes_left`: + a: null + b: null + c: null + d: null + dataA: null + dataB: null + def `test_nonunique`: + x: null + d: null + abc: null + def `test_invalide_pairs`: + x: null + d: null + abc: null + def `test_duplicate_names`: + C1: null + Feature: null + A: null + B: null + Feature (1): null + Feature (2): null + def `test_keep_non_duplicate_variables`: + A: null + B: null + B (1): null + B (2): null + def `test_keep_non_duplicate_variables_missing_rows`: + C: null + a: null + b: null + c: null + A: null + B: null + A (1): null + A (2): null + B (1): null + C (1): null + B (2): null + C (2): null + class `MergeDataContextHandlerTest`: + def `test_attr_pairs_not_present`: + iris: null + a: null + b: null + attr_pairs: null + __main__: null +widgets/data/tests/test_owneighbors.py: + class `TestOWNeighbors`: + def `setUp`: + auto_apply: null + iris: null + def `test_input_reference_disconnect`: + Neighbors: null + def `test_output_neighbors`: + Neighbors: null + def `test_settings`: + Jaccard: null + Neighbors: null + def `test_similarity`: + Neighbors: null + distance: null + def `test_missing_values`: + iris: null + Neighbors: null + def `test_compute_distances_apply_called`: + iris: null + def `test_compute_distances_calls_distance`: + foo: null + iris: null + def `test_compute_distances_distance_no_data`: + foo: null + iris: null + def `test_data_with_similarity`: + iris: null + distance: null + def `test_apply`: + iris: null + def `test_all_equal_ref`: + iris: null + def `test_different_domains`: + a: null + b: null + def `test_different_metas`: + a: null + b: null + c: null + d: null + e: null + def `test_different_domains_same_names`: + a: null + b: null + c: null + d: null + __main__: null +widgets/data/tests/test_owoutliers.py: + class `TestRun`: + def `test_results`: + iris: null + Outlier: null + class `TestOWOutliers`: + def `setUp`: + iris: null + heart_disease: null + def `test_output_empirical_covariance`: + Outlier: null + Mahalanobis: null + def `test_memory_error`: + Orange.classification.outlier_detection._OutlierModel.predict: null + def `test_singular_cov_error`: + Orange.classification.outlier_detection._OutlierModel.predict: null + def `test_covariance_enabled`: + Orange.widgets.data.owoutliers.OWOutliers.MAX_FEATURES: null + Orange.widgets.data.owoutliers.OWOutliers.commit: null + def `test_report`: + Orange.widgets.data.owoutliers.OWOutliers.report_items: null + def `test_migrate_settings`: + cont: null + empirical_covariance: null + gamma: null + nu: null + outlier_method: null + support_fraction: null + __version__: null + __main__: null +widgets/data/tests/test_owpaintdata.py: + class `TestOWPaintData`: + def `setUp`: + autocommit: null + def `test_empty_data`: + iris: null + def `test_var_name_duplicates`: + iris: null + atr1: null + atr2: null + def `test_output_shares_internal_buffer`: + iris: null + def `test_20_values_class`: + A: null + B: null + C: null + a: null + t: null + def `test_sparse_data`: + iris: null + def `test_load_empty_data`: + data: null + def `test_reset_to_input`: + iris: null +widgets/data/tests/test_owpivot.py: + class `TestOWPivot`: + def `setUp`: + iris: null + heart_disease: null + zoo: null + def `test_comboboxes`: + (Same as rows): (Enako kot vrstice) + age: null + def `test_output_grouped_data`: + iris: null + (count): (velikost skupine) + sepal length (sum): sepal length (vsota) + sepal width (sum): sepal width (vsota) + petal length (sum): petal length (vsota) + petal width (sum): petal width (vsota) + def `test_output_grouped_data_time_var`: + d1: null + a: null + b: null + t1: null + [[a, 2, 1987-06-06],\n [b, 2, 1976-05-03]]: null + def `test_output_pivot_table`: + iris: null + Aggregate: Vrednost + Iris-setosa: null + Iris-versicolor: null + Iris-virginica: null + def `test_aggregations`: + (None): null + def `test_group_table_created_once`: + Orange.widgets.data.owpivot.Pivot._initialize: null + def `test_renaming_warning`: + iris: null + Aggregate: Vrednost + def `test_max_values`: + Orange.widgets.data.owpivot.OWPivot.MAX_VALUES: null + def `test_table_values`: + gender: null + thal: null + 72.0: null + normal: null + 25.0: null + reversable defect: null + 92.0: null + 114.0: null + def `test_migrate_settings_1_to_2`: + sel_agg_functions: null + Count: Velikost skupine + Sum: Vsota + class `TestPivot`: + def `setUpClass`: + d1: null + a: null + b: null + d2: null + c: null + d: null + e: null + c1: null + c0: null + c2: null + cls: null + m1: null + m2: null + aa: null + dd: null + bb: null + ee: null + cc: null + def `test_group_table`: + (count): (velikost skupine) + d1 (count defined): d1 (število znanih) + d1 (majority): d1 (večina) + a: null + b: null + d2 (count defined): d2 (število znanih) + d2 (majority): d2 (večina) + c: null + d: null + e: null + c1 (count defined): c1 (število znanih) + c1 (sum): c1 (vsota) + c1 (mean): c1 (povprečje) + c1 (min): c1 (najmanjša) + c1 (max): c1 (največja) + c1 (mode): c1 (najpogostejša) + c1 (median): c1 (mediana) + c1 (var): c1 (varianca) + def `test_group_table_time_var`: + d1: null + a: null + b: null + t1: null + '[[a, 2, 2, a, 2, 1.1e+09, 1987-06-06, 1973-03-03, ': null + '2001-09-09, 1973-03-03, 1987-06-06, 2.025e+17],\n ': null + '[b, 2, 2, b, 1, 2e+08, 1976-05-03, 1976-05-03, ': null + 1976-05-03, 1976-05-03, 1976-05-03, 0]]: null + def `test_group_table_metas`: + d1: null + a: null + b: null + c1: null + d2: null + c2: null + (count): (velikost skupine) + d1 (count defined): d1 (število znanih) + d1 (majority): d1 (večina) + c1 (count defined): c1 (število znanih) + c1 (sum): c1 (vsota) + c1 (mean): c1 (povprečje) + c1 (min): c1 (najmanjša) + c1 (max): c1 (največja) + c1 (mode): c1 (najpogostejša) + c1 (median): c1 (mediana) + c1 (var): c1 (varianca) + d2 (count defined): d2 (število znanih) + d2 (majority): d2 (večina) + c2 (count defined): c2 (število znanih) + c2 (sum): c2 (vsota) + c2 (mean): c2 (povprečje) + c2 (min): c2 (najmanjša) + c2 (max): c2 (največja) + c2 (mode): c2 (najpogostejša) + c2 (median): c2 (mediana) + c2 (var): c2 (varianca) + def `test_group_table_use_cached`: + Orange.widgets.data.owpivot.Pivot.Functions: null + Count: Velikost skupine + Sum: Vsota + Orange.widgets.data.owpivot.Pivot.Sum: null + Orange.widgets.data.owpivot.Pivot.Count: null + Orange.widgets.data.owpivot.Pivot.AutonomousFunctions: null + Orange.widgets.data.owpivot.Pivot.ContVarFunctions: null + Orange.widgets.data.owpivot.Pivot.FloatFunctions: null + (count): (velikost skupine) + d1 (count defined): d1 (število znanih) + d1 (majority): d1 (večina) + a: null + b: null + d2 (count defined): d2 (število znanih) + d2 (majority): d2 (večina) + c: null + d: null + e: null + c1 (count defined): c1 (število znanih) + c1 (sum): c1 (vsota) + c1 (mean): c1 (povprečje) + c1 (min): c1 (najmanjša) + c1 (max): c1 (največja) + c1 (mode): c1 (najpogostejša) + c1 (median): c1 (mediana) + c1 (var): c1 (varianca) + def `test_group_table_no_col_var`: + (count): (velikost skupine) + d1 (count defined): d1 (število znanih) + d1 (majority): d1 (večina) + a: null + b: null + d2 (count defined): d2 (število znanih) + d2 (majority): d2 (večina) + c: null + d: null + e: null + c1 (count defined): c1 (število znanih) + c1 (sum): c1 (vsota) + c1 (mean): c1 (povprečje) + c1 (min): c1 (najmanjša) + c1 (max): c1 (največja) + c1 (mode): c1 (najpogostejša) + c1 (median): c1 (mediana) + c1 (var): c1 (varianca) + def `test_group_table_no_col_var_metas`: + d1: null + a: null + b: null + c1: null + d2: null + c2: null + (count): (velikost skupine) + d1 (count defined): d1 (število znanih) + d1 (majority): d1 (večina) + c1 (count defined): c1 (število znanih) + c1 (sum): c1 (vsota) + c1 (mean): c1 (povprečje) + c1 (min): c1 (najmanjša) + c1 (max): c1 (največja) + c1 (mode): c1 (najpogostejša) + c1 (median): c1 (mediana) + c1 (var): c1 (varianca) + d2 (count defined): d2 (število znanih) + d2 (majority): d2 (večina) + c2 (count defined): c2 (število znanih) + c2 (sum): c2 (vsota) + c2 (mean): c2 (povprečje) + c2 (min): c2 (najmanjša) + c2 (max): c2 (največja) + c2 (mode): c2 (najpogostejša) + c2 (median): c2 (mediana) + c2 (var): c2 (varianca) + def `test_group_table_update`: + (count): (velikost skupine) + d1 (count defined): d1 (število znanih) + d1 (majority): d1 (večina) + a: null + b: null + d2 (count defined): d2 (število znanih) + d2 (majority): d2 (večina) + c: null + d: null + e: null + c1 (count defined): c1 (število znanih) + c1 (sum): c1 (vsota) + c1 (mean): c1 (povprečje) + c1 (min): c1 (najmanjša) + c1 (max): c1 (največja) + c1 (mode): c1 (najpogostejša) + c1 (median): c1 (mediana) + c1 (var): c1 (varianca) + def `test_group_table_1`: + (count): (velikost skupine) + c0 (count defined): c0 (število znanih) + c0 (sum): c0 (vsota) + c0 (mean): c0 (povprečje) + c0 (min): c0 (najmanjša) + c0 (max): c0 (največja) + c0 (mode): c0 (najpogostejša) + c0 (median): c0 (mediana) + c0 (var): c0 (varianca) + d1 (count defined): d1 (število znanih) + d1 (majority): d1 (večina) + a: null + b: null + c1 (count defined): c1 (število znanih) + c1 (sum): c1 (vsota) + c1 (mean): c1 (povprečje) + c1 (min): c1 (najmanjša) + c1 (max): c1 (največja) + c1 (mode): c1 (najpogostejša) + c1 (median): c1 (mediana) + c1 (var): c1 (varianca) + d2 (count defined): d2 (število znanih) + d2 (majority): d2 (večina) + c2 (count defined): c2 (število znanih) + c2 (sum): c2 (vsota) + c2 (mean): c2 (povprečje) + c2 (min): c2 (najmanjša) + c2 (max): c2 (največja) + c2 (mode): c2 (najpogostejša) + c2 (median): c2 (mediana) + c2 (var): c2 (varianca) + cls (count defined): cls (število znanih) + cls (majority): cls (večina) + m1 (count defined): m1 (število znanih) + m2 (count defined): m2 (število znanih) + def `test_pivot`: + Aggregate: Vrednost + Count: Velikost skupine + Count defined: Število znanih + Sum: Vsota + Mean: Povprečje + Min: Najmanjša + Max: Največja + Mode: Najpogostejša + Median: Mediana + Var: Varianca + c: null + d: null + e: null + def `test_pivot_total`: + Total: Skupno + Aggregate: Vrednost + Count: Velikost skupine + Sum: Vsota + c: null + d: null + e: null + def `test_pivot_no_col_var`: + Aggregate: Vrednost + Count: Velikost skupine + Count defined: Število znanih + Sum: Vsota + Mean: Povprečje + Min: Najmanjša + Max: Največja + Mode: Najpogostejša + Median: Mediana + Var: Varianca + a: null + b: null + def `test_pivot_no_val_var`: + Aggregate: Vrednost + Count: Velikost skupine + c: null + d: null + e: null + def `test_pivot_disc_val_var`: + Aggregate: Vrednost + Count defined: Število znanih + Majority: Večina + a: null + 0.0: null + 1.0: null + c: null + d: null + b: null + e: null + def `test_pivot_time_val_var`: + d1: null + a: null + b: null + d2: null + c: null + d: null + t1: null + Aggregate: Vrednost + Min: Najmanjša + Max: Največja + Count defined: Število znanih + Sum: Vsota + 1.0: null + 1973-03-03: null + 1976-05-03: null + 0.0: null + 2001-09-09: null + def `test_pivot_data_subset`: + iris: null + Aggregate: Vrednost + Count: Velikost skupine + Count defined: Število znanih + Majority: Večina + Iris-setosa: null + 0.0: null + 50.0: null + Iris-versicolor: null + def `test_pivot_renaming_domain`: + iris: null + Aggregate: Vrednost + Aggregate (1): Vrednost (1) + Aggregate (2): Vrednost (2) + __main__: null +widgets/data/tests/test_owpreprocess.py: + class `TestOWPreprocess`: + def `setUp`: + zoo: null + def `test_randomize`: + preprocessors: null + orange.preprocess.randomize: null + rand_type: null + rand_seed: null + def `test_remove_sparse`: + iris: null + preprocessors: null + orange.preprocess.remove_sparse: null + filter0: null + useFixedThreshold: null + percThresh: null + fixedThresh: null + def `test_normalize`: + iris: null + preprocessors: null + orange.preprocess.scale: null + method: null + def `test_select_features`: + iris: null + preprocessors: null + orange.preprocess.fss: null + strategy: null + k: null + p: null + def `test_data_column_nans`: + preprocessors: null + orange.preprocess.scale: null + center: null + scale: null + class `TestDiscretizeEditor`: + def `test_editor`: + method: null + n: null + class `TestContinuizeEditor`: + def `test_editor`: + multinomial_treatment: null + class `TestImputeEditor`: + def `test_editor`: + method: null + class `TestFeatureSelectEditor`: + def `test_editor`: + k: null + class `TestRandomFeatureSelectEditor`: + def `test_editor`: + strategy: null + k: null + p: null + class `TestRandomizeEditor`: + def `test_editor`: + rand_type: null + class `TestPCAEditor`: + def `test_editor`: + n_components: null + class `TestCUREditor`: + def `test_editor`: + rank: null + max_error: null + __main__: null +widgets/data/tests/test_owpurgedomain.py: + class `TestOWPurgeDomain`: + def `setUp`: + iris: null + __main__: null +widgets/data/tests/test_owpythonscript.py: + class `TestOWPythonScript`: + def `setUp`: + iris: null + def `test_inputs`: + Data: null + Learner: null + Classifier: null + Object: null + object: null + def `test_outputs`: + Data: null + Learner: null + Classifier: null + out_{0} = in_{0}: null + print(in_{}): null + def `test_local_variable`: + temp = 42\nprint(temp): null + 42: null + print(temp): null + "NameError: name 'temp' is not defined": null + def `test_wrong_outputs`: + Data: null + Learner: null + Classifier: null + out_{} = 42: null + out_{0} = in_{0}: null + def `test_multiple_signals`: + titanic: null + in_data: null + in_datas: null + Data: null + def `test_store_new_script`: + 42: null + def `test_restore_from_library`: + 42: null + def `test_store_current_script`: + 42: null + def `test_read_file_content`: + Content: null + .42: null + wb: null + \xc3\x28: null + def `test_script_insert_mime_text`: + test\n: null + def `test_script_insert_mime_file`: + test: null + .42: null + print('Hello world'): null + "'": null + def `test_dragEnterEvent_accepts_text`: + Content: null + .42: null + def `test_dragEnterEvent_rejects_binary`: + .42: null + wb: null + \xc3\x28: null + def `test_migrate`: + libraryListSource: null + A: null + 1: null + __version__: null + def `test_restore`: + scriptLibrary: null + A: null + 1: null + __version__: null + def `test_no_shared_namespaces`: + x = 42: null + y = 2 * x: null + "NameError: name 'x' is not defined": null + class `TestOWPythonScriptDropHandler`: + def `test_canDropFile`: + test.tab: null + def `test_parametersFromFile`: + scriptLibrary: null + filename: null + name: null + Add: null + script: null + 1 + 1: null + 42: null + __version__: null + defaults: null + __main__: null +widgets/data/tests/test_owrandomize.py: + class `TestOWRandomize`: + def `setUpClass`: + zoo: null + def `test_unconditional_commit_on_new_signal`: + now: null +widgets/data/tests/test_owrank.py: + class `SlowScorer`: + Slow scorer: null + class `TestRankModel`: + def `setUp`: + ann: null + ab: null + great: null + defg: null + def: null + foo: null + bar: null + def `test_data`: + ann: null + great: null + e: null + foo: null + bar: null + class `TestOWRank`: + def `setUp`: + iris: null + housing: null + def `test_input_scorer_fitter`: + heart_disease: null + random forest: null + sgd: null + ignore: null + .*: null + Scorer: null + Data: null + def `test_cls_scorer_reg_data`: + Orange.widgets.data.owrank.log.error: null + def `test_reg_scorer_cls_data`: + Orange.widgets.data.owrank.log.error: null + def `test_scores_updates_cls`: + Gini: null + Orange.widgets.data.owrank.log.error: null + def `test_scores_updates_reg`: + Univar. reg.: null + def `test_scores_updates_no_class`: + Orange.widgets.data.owrank.log.error: null + def `test_no_class_data_learner_class_reg`: + Orange.widgets.data.owrank.log.error: null + def `test_scores_sorting`: + FCBF: null + def `test_score_sorting_int`: + sorting: null + __version__: null + def `test_scores_nan_sorting`: + petal length: null + def `test_data_which_make_scorer_nan`: + c: null + d: null + 01: null + ANOVA: null + def `test_setting_migration_fixes_header_state`: + headerState is not restored in Qt6: null + __version__: null + auto_apply: null + headerState: null + \x00\x00\x00\xff\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00: null + \x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00: null + \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xd0\x00: null + \x00\x00\x08\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00: null + \x00\x00\x00\x00\x00d\xff\xff\xff\xff\x00\x00\x00\x84\x00: null + \x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x14\x00\x00\x00: null + \x01\x00\x00\x00\x00\x00\x00\x02\xbc\x00\x00\x00\x07\x00: null + \x00\x00\x00: null + \x00\x01\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00: null + \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xdc\x00: null + \x00\x00\x03\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00: null + \x01\x00\x00\x00\x00\x00\x00\x00\xc8\x00\x00\x00\x02\x00: null + nSelected: null + selectMethod: null + def `test_auto_selection_manual`: + heart_disease: null + chest pain: null + rest ECG: null + slope peak exc ST: null + thal: null + def `test_resorting_and_selection`: + heart_disease: null + def `test_auto_send`: + petal width: null + petal length: null + def `test_no_attributes`: + iris: null + def `test_dataset`: + Orange.widgets.data.owrank.log.warning: null + Orange.widgets.data.owrank.log.error: null + ignore: null + Features .* are constant: null + __main__: null +widgets/data/tests/test_owsave.py: + def `_w`: + /: null + class `MockFormat`: + .mock: null + Mock file format: null + class `OWSaveTestBase`: + def `setUp`: + class `OWSaveMockWriter`: + .csv: null + iris: null + class `TestOWSave`: + def `test_dataset`: + foo.tab: null + def `test_initial_start_dir`: + ~/: null + os.path.exists: null + /usr/foo/bar.csv: null + /usr/bar: null + /usr/bar/: null + /usr/bar/iris.csv: null + ~/iris.csv: null + def `test_save_file_sets_name`: + Orange.widgets.utils.save.owsavebase.QFileDialog.getSaveFileName: null + /usr/foo/bar.csv: null + /usr/foo/: null + /bar/bar.csv: null + /bar: null + bar.csv: null + def `test_save_file_calls_save_as`: + bar.csv: null + def `test_save_file_checks_can_save`: + foo: null + def `test_save_file_write_errors`: + bar/foo: null + def `test_save_file_write`: + bar/foo.csv: null + def `test_file_name_label`: + /foo/bar/baz.csv: null + def `test_sparse_error`: + foo.xlsx: null + def `test_send_report`: + foo.{writer.EXTENSIONS[0]}: null + for {writer}, annotations={widget.add_type_annotations}: null + File name: Ime datoteke + Type annotations: Oznake tipov + No: Ne + Yes: Da + def `test_migration_to_version_2`: + add_type_annotations: null + auto_save: null + controlAreaVisible: null + last_dir: null + /home/joe/Desktop: null + __version__: null + compress: null + compression: null + gzip (.gz): null + filetype: null + Tab-separated values (.tab): null + filter: null + Tab-separated values (*.tab): null + lzma (.xz): null + Compressed Tab-separated values (*.tab.gz): null + Microsoft Excel spreadsheet (.xlsx): null + Microsoft Excel spreadsheet (*.xlsx): null + Bar file (.bar): null + def `test_migration_to_version_3`: + add_type_annotations: null + stored_name: null + zoo.xlsx: null + __version__: null + zoo.tab: null + class `TestFunctionalOWSave`: + def `setUp`: + iris: null + def `test_save_uncompressed`: + iris: null + read: null + def `test_unsupported_file_format`: + Unsupported filter (*.foo): null + test.foo: null + iris: null + write: null + class `TestOWSaveLinuxDialog`: + linux: null + Tests for dialog on Linux: null + def `test_get_save_filename_linux`: + baz: null + abc: null + b: null + foo: null + bar: null + a;;b;;c: null + def `test_save_file_dialog_enforces_extension_linux`: + filters: null + Save File: null + foo.bar: null + Bar files (*.tab);;Low files (*.csv): null + Low files (*.csv): null + /foo.csv: null + high.bar: null + /high.bar.csv: null + Bar files (*.tab): null + /high.bar.tab: null + middle.pkl: null + /middle.tab: null + /middle.csv: null + high.tab.gz: null + /high.csv: null + high.tab.gz.tab.tab.gz: null + def `test_save_file_dialog_uses_valid_filters_linux`: + a (*.a): null + b (*.b): null + a (*.a);;b (*.b): null + class `TestOWSaveDarwinDialog`: + darwin: null + win32: null + Test for native dialog on Windows and macOS: null + def `remove_star`: + ' (*.': null + ' (.': null + def `test_get_save_filename_darwin`: + Orange.widgets.utils.save.owsavebase.QFileDialog: null + baz: null + aa (*.a): null + bb (*.b): null + cc (*.c): null + foo: null + foo.a: null + aa (*.a);;bb (*.b);;cc (*.c): null + def `test_save_file_dialog_enforces_extension_darwin`: + Orange.widgets.utils.save.owsavebase.QFileDialog: null + .tab: null + .csv.gz: null + foo: null + foo.tab: null + foo.pkl: null + foo.tab.gz: null + foo.csv.gz: null + foo.bar: null + foo.bar.tab: null + foo.bar.csv.gz: null + def `test_save_file_dialog_asks_for_overwrite_darwin`: + Orange.widgets.utils.save.owsavebase.QFileDialog: null + os.path.exists: null + old.tab: null + Orange.widgets.utils.save.owsavebase.QMessageBox: null + def `selected_files`: + old.tab: null + new.tab: null + baz: null + .tab: null + new.tab: null + def `test_save_file_dialog_uses_valid_filters_darwin`: + Orange.widgets.utils.save.owsavebase.QFileDialog: null + aa (*.a): null + bb (*.b): null + aa (*.a);;bb (*.b): null + __main__: null +widgets/data/tests/test_owselectbydataindex.py: + class `TestOWSelectSubset`: + def `test_subset`: + iris: null + def `test_non_matching`: + iris: null + def `test_annotated`: + iris: null + No: Ne + Yes: Da + def `test_subset_nosubset`: + iris: null + titanic: null +widgets/data/tests/test_owselectcolumns.py: + c: null + d: null + class `TestSelectAttributesDomainContextHandler`: + def `setUp`: + c1: null + d1: null + abc: null + d2: null + def: null + d3: null + ghi: null + c2: null + d4: null + jkl: null + def `test_open_context`: + d1: null + available: null + d2: null + meta: null + c1: null + attribute: null + d3: null + d4: null + c2: null + class: null + def `test_open_context_with_imperfect_match`: + d1: null + attribute: null + m2: null + meta: null + available: null + d2: null + c1: null + d6: null + d7: null + c2: null + class: null + class `TestModel`: + def `test_drop_mime`: + iris: null + def `test_flags`: + X: null + class `TestOWSelectAttributes`: + def `assertVariableCountsEqual`: + {name} ({nattrs}): null + def `test_multiple_target_variable`: + iris: null + def `test_input_features`: + zoo: null + def `test_input_features_by_name`: + zoo: null + def `test_input_features_same_domain`: + zoo: null + def `test_input_features_sub_domain`: + zoo: null + def `test_input_features_by_name_sub_domain`: + zoo: null + def `test_input_features_diff_domain`: + zoo: null + iris: null + def `test_input_features_no_data`: + zoo: null + def `test_input_combinations`: + iris: null + def `test_input_features_from_rank`: + iris: null + def `test_use_features_checked`: + iris: null + def `test_used_attrs_supported_types`: + zoo: null + def `_drag_enter_event`: + _items: null + def `test_move_rows`: + iris: null + def `test_drag_drop_move_rows`: + iris: null + exec: null + def `test_domain_new_feature`: + iris: null + a: null + def `test_select_new_features`: + iris: null + def `test_unselect_new_features`: + iris: null + __main__: null +widgets/data/tests/test_owselectrows.py: + 5.4: null + 6.0: null + aardwark: null + cat: null + aa: null + ark: null + class `TestOWSelectRows`: + def `test_filter_cont`: + iris: null + def `test_filter_str`: + zoo: null + def `test_filter_disc`: + datasets/lenses.tab: null + def `test_filter_time`: + datasets/cyber-security-breaches.tab: null + breach_start: null + def `test_continuous_filter_with_c_locale`: + iris: null + is below: je manj kot + 5.2: null + 5,2: null + 52: null + def `test_continuous_filter_with_sl_SI_locale`: + iris: null + is below: je manj kot + 5,2: null + 5.2: null + 52: null + def `test_all_numeric_filter_with_c_locale_from_context`: + iris: null + All numeric variables: Vse številske spremenljivke + 3.14: null + def `test_all_numeric_filter_with_sl_SI_locale`: + iris: null + All numeric variables: Vse številske spremenljivke + 3,14: null + def `test_stores_settings_in_invariant_locale`: + iris: null + is below: je manj kot + 5,2: null + conditions: null + def `test_store_all_numeric_filter_with_c_locale_to_context`: + iris: null + All numeric variables: Vse številske spremenljivke + equal: so + 3.14: null + conditions: null + def `test_store_all_numeric_filter_with_sl_SI_locale_to_context`: + iris: null + All numeric variables: Vse številske spremenljivke + equal: so + 3,14: null + conditions: null + def `test_restores_continuous_filter_in_c_locale`: + iris: null + sepal length: null + 5.2: null + def `test_restores_continuous_filter_in_sl_SI_locale`: + iris: null + sepal length: null + 5.2: null + 5,2: null + def `test_partial_matches`: + iris: null + 5.2: null + def `test_partial_match_values`: + iris: null + def `test_partial_matches_with_missing_vars`: + iris: null + 5.2: null + 4.2: null + def `test_load_settings`: + iris: null + is below: je manj kot + 5.2: null + is at most: je največ + 4: null + sepal width: null + sepal length: null + def `test_is_defined_on_continuous_variable`: + testing_dataset_cls: null + c2: null + is defined: je znan + def `test_output_filter`: + iris: null + is below: je manj kot + -1: null + 10: null + def `test_annotated_data`: + iris: null + is: je + Iris-setosa: null + def `test_change_var_type`: + iris: null + is below: je manj kot + 5.2: null + def `test_keep_operator`: + heart_disease: null + age: null + is not: ni + 42: null + chest pain: null + is below: je manj kot + is: je + def `test_calendar_dates`: + datasets/cyber-security-breaches.tab: null + Date_Posted_or_Updated: null + is below: je manj kot + is greater than: je več kot + equals: je + is between: je med + def `test_add_all`: + question: null + iris: null + def `test_add_all_cancel`: + question: null + iris: null + def `test_report`: + question: null + zoo: null + All numeric variables: Vse številske spremenljivke + equal: so + 42: null + is defined: je znan + is one of: je eden izmed + def `test_migration_to_version_1`: + iris: null + petal length: null + def `test_purge_discretized`: + housing: null + MEDV: null + def `test_meta_setting`: + iris: null + def `test_one_of_click`: + zoo: null + is one of: je eden izmed + def `widget_with_context`: + conditions: null + def `__set_value`: + Unsupported widget {}: null + __main__: null +widgets/data/tests/test_owsql.py: + class `TestOWSqlConnected`: + def `setUpDB`: + iris: null + def `test_connection`: + postgres: null + Select a table: null + Custom SQL: null + def `test_output_iris`: + postgres: null + iris: null + def `set_connection_params`: + port: null + :: null + host: null + database: null + user: null + password: null + class `TestOWSql`: + def `test_missing_extension`: + Orange.widgets.data.owsql.Backend: null + PostgreSQL: null + missing extension: null + host: null + port: null + database: null + DB: null + schema: null + username: null + password: null + def `test_non_postgres`: + Orange.widgets.data.owsql.Backend: null + database: null + host: null + port: null + DB: null + schema: null + username: null + password: null + def `test_restore_table`: + Orange.widgets.data.owsql.Table: null + iris: null + Orange.widgets.data.owsql.SqlTable: null + Orange.widgets.data.owsql.Backend: null + database: null + a: null + b: null + c: null + host: null + port: null + DB: null + schema: null + username: null + password: null + table: null + def `test_selected_backend`: + Orange.data.sql.backend.base.Backend.available_backends: null + B1: null + B2: null + selected_backend: null + B3: null + __main__: null +widgets/data/tests/test_owtable.py: + class `TestOWDataTable`: + def `setUpClass`: + Data: null + def `test_reset_select`: + heart_disease: null + def `test_attrs_appear_in_corner_text`: + c: null + foo: null + a: null + bar: null + baz: null + b: null + \na\nb\nc: null + def `test_unconditional_commit_on_new_signal`: + now: null + def `test_summary`: + No data on input: Ni vhodnih podatkov + No data on output: Ni izhodnih podatkov + zoo: null + {len(data)}: null + def `test_info`: + No data.: Ni podatkov. + class `TestOWDataTableSQL`: + def `test_input_data`: + postgres: null + mssql: null + def `test_input_data_empty`: + no data output: null + def `test_data_model`: + approx_len messes up row count: null + def `test_unconditional_commit_on_new_signal`: + postgres: null + mssql: null + def `test_reset_select`: + postgres: null + mssql: null + def `test_attrs_appear_in_corner_text`: + postgres: null + mssql: null + def `test_pending_selection`: + no data output: null + def `test_sorting`: + sorting not implemented: null + def `test_summary`: + postgres: null + mssql: null + def `test_info`: + does nothing: null + def `test_show_distributions`: + postgres: null + mssql: null + def `test_whole_rows`: + no data output: null + def `test_show_attribute_labels`: + postgres: null + mssql: null + def `test_deprecate_multiple_inputs`: + postgres: null + mssql: null + __main__: null +widgets/data/tests/test_owtransform.py: + class `TestOWTransform`: + def `setUp`: + iris: null + __main__: null +widgets/data/tests/test_owtranspose.py: + class `TestRunner`: + def `setUp`: + zoo: null + def `test_run`: + Feature: null + def `test_run_var`: + name: null + Feature: null + def `test_run_name`: + Foo: null + def `test_run_callback`: + Feature: null + class `TestOWTranspose`: + def `setUp`: + zoo: null + def `test_feature_type`: + datasets/test_asn_data_working.csv: null + Feature: Spremenljivka + Foo: null + 'Foo ': null + def `test_remove_redundant_instance`: + iris: null + petal length: null + def `test_all_whitespace`: + ' ': null + def `test_error`: + Orange.data.Table.transpose: null + foo: null + def `test_feature_names_from_cont_vars`: + iris: null + 0.2 (1): null + 0.2 (2): null + 0.2 (3): null + 0.2 (4): null + 0.2 (5): null + 0.4 (1): null + 0.3 (1): null + 0.2 (6): null + 0.2 (7): null + 0.1 (1): null + def `test_unconditional_commit_on_new_signal`: + now: null + __main__: null +widgets/data/tests/test_owunique.py: + class `TestOWUnique`: + def `setUp`: + a: null + b: null + c: null + abcd: null + e: null + fg: null + def `test_compute`: + Last instance: Zadnji primer + First instance: Prvi primer + Middle instance: Srednji primer + Discard non-unique instances: Zavrzi skupine z več primeri + def `test_use_all_when_non_selected`: + First instance: Prvi primer + def `test_no_output_on_no_unique`: + Discard non-unique instances: Zavrzi skupine z več primeri + __main__: null +widgets/data/utils/pythoneditor/tests/test_api.py: + class `Selection`: + def `test_resetSelection`: + asdf fdsa: null + def `test_setSelection`: + asdf fdsa: null + f fd: null + def `test_selected_multiline_text`: + a\nb: null + class `ReplaceText`: + def `test_replaceText1`: + 123456789: null + xyz: null + 123xyz89: null + def `test_replaceText2`: + 12345\n67890\nabcde: null + Z: null + 12345\n6789Zbcde: null + def `test_replaceText3`: + 12345\n67890\nabcde: null + Z: null + Z45\n67890\nabcde: null + 12345\n67890\nabcdZ: null + Z12345\n67890\nabcde: null + 12345\n67890\nabcdeZ: null + def `test_replaceText4`: + 12345\n67890\nabcde: null + XYZ: null + 12XYZ345\n67890\nabcde: null + def `test_replaceText5`: + 12345\n67890\nabcde: null + Z: null + class `InsertText`: + def `test_1`: + 123456789: null + xyz: null + 123xyz456789: null + def `test_2`: + 12345\n67890\nabcde: null + Z: null + 12345\n6789Z0\nabcde: null + def `test_3`: + 12345\n67890\nabcde: null + Z: null + Z12345\n67890\nabcde: null + 12345\n67890\nabcdeZ: null + class `IsCodeOrComment`: + def `test_1`: + a + b # comment: null + def `test_2`: + '#': null + class `ToggleCommentTest`: + def `test_single_line`: + a = 2: null + '# a = 2\n': null + a = 2\n: null + def `test_two_lines`: + a = 2\nb = 3: null + '# a = 2\n# b = 3\n': null + class `Signals`: + def `test_eol_changed`: + \r\n: null + class `Lines`: + def `setUp`: + abcd\nefgh\nklmn\nopqr: null + def `test_accessByIndex`: + abcd: null + efgh: null + opqr: null + def `test_modifyByIndex`: + new text: null + abcd\nefgh\nnew text\nopqr: null + def `test_getSlice`: + abcd: null + efgh: null + opqr: null + klmn: null + def `test_setSlice_1`: + xyz: null + xyz\nefgh\nklmn\nopqr: null + def `test_setSlice_2`: + xyz: null + abcd\nxyz\nklmn\nopqr: null + def `test_setSlice_3`: + xyz: null + xyz\nefgh\nklmn\nopqr: null + def `test_setSlice_4`: + st: null + uv: null + wx: null + z: null + st\nuv\nwx\nz: null + def `test_setSlice_5`: + st: null + uv: null + wx: null + z: null + st\nuv\nwx\nz: null + def `test_setSlice_6`: + st: null + uv: null + abcd\nst\nuv\nopqr: null + def `test_setSlice_61`: + st: null + uv: null + wx: null + z: null + def `test_setSlice_7`: + st: null + uv: null + abcd\nst\nuv\nopqr: null + def `test_setSlice_8`: + st: null + uv: null + abcd\nst\nuv\nopqr: null + def `test_setSlice_9`: + st: null + class `LinesWin`: + def `setUp`: + \r\n: null + __main__: null +widgets/data/utils/pythoneditor/tests/test_bracket_highlighter.py: + class `Test`: + def `_verify`: + Invalid color: null + def `test_1`: + func(param,: null + ' "text ( param"))': null + __main__: null +widgets/data/utils/pythoneditor/tests/test_draw_whitespace.py: + class `Test`: + def `_ws_test`: + Failed params:\n\tany {}\n\tincorrect {}\n\ttabs {}\n\twidth {}: null + def `_verify`: + 1: null + Item {} is not True:\n\t{}: null + 0: null + Item {} is not False:\n\t{}: null + ' ': null + def `test_1`: + ' m xyz\t ': null + ' 0 00011': null + def `test_2`: + \txyz\t: null + 10001: null + def `test_3`: + ' 2 3 5': null + 111100000000000: null + def `test_4`: + ' 1 1 2 3 5\t': null + 100011011101111101: null + __main__: null +widgets/data/utils/pythoneditor/tests/test_edit.py: + class `Test`: + def `test_overwrite_edit`: + abcd: null + stu: null + stuabcd: null + xy: null + stuxycd: null + z: null + stuxyzcd: null + def `test_overwrite_backspace`: + abcd: null + a d: null + def `test_overwrite_undo`: + abcd: null + axxd: null + def `test_home1`: + ' xx': null + def `test_home2`: + '\n\n ': null + x: null + __main__: null +widgets/data/utils/pythoneditor/tests/test_indent.py: + class `Test`: + def `test_1`: + ab\ncd: null + ab\n\tcd: null + ab\n cd: null + def `test_2`: + ab\n\t\tcd: null + ab\n\tcd: null + ab\ncd: null + def `test_3`: + ab\n cd: null + ab\n cd: null + ab\ncd: null + def `test_4`: + ' ab\n cd': null + ' ab\n cd': null + def `test_4b`: + ab\ncd\nef: null + \tab\ncd\nef: null + def `test_5`: + ' ab\n cd': null + ' ab\n cd': null + def `test_6`: + ' \t \tab': null + ' \t ab': null + ' \tab': null + ' ab': null + ab: null + def `test_7`: + def main():: null + return 7: null + __main__: null +widgets/data/utils/pythoneditor/tests/test_rectangular_selection.py: + class `_Test`: + def `test_real_to_visible`: + abcdfg: null + \tab\tcde\t: null + def `test_visible_to_real`: + abcdfg: null + \tab\tcde\t: null + def `test_basic`: + abcd\nef\nghkl\nmnop: null + ad\ne\ngl\nmnop: null + def `test_reset_by_move`: + abcd\nef\nghkl\nmnop: null + abcd\nef\ngkl\nmnop: null + def `test_reset_by_edit`: + abcd\nef\nghkl\nmnop: null + x: null + def `test_with_tabs`: + abcdefghhhhh\n\tklm\n\t\txyz: null + abcdefhh\n\tkl\n\t\tz: null + abcdefh\n\tkl\n\t\t: null + abcdefhhh\n\tkl\n\t\tyz: null + def `test_delete`: + this is long\nshort\nthis is long: null + 'this is \nshort\nthis is ': null + def `test_copy_paste`: + xx 123 yy\n: null + xx 456 yy\n: null + xx 789 yy\n: null + \n: null + asdfghijlmn\n: null + x\t\n: null + \t\t\n: null + end\n: null + xx 123 yy\nxx 456 yy\nxx 789 yy\n\nasdfghijlm123n\nx\t 456\n\t\t 789\n\t\t\nend\n: null + def `test_copy_paste_utf8`: + фыва: null + фыва фыв: null + def `test_paste_replace_selection`: + asdf: null + asdasdf: null + def `test_paste_replace_rectangular_selection`: + asdf: null + asasdff: null + def `test_paste_new_lines`: + a\nb\nc\nd: null + x\ny: null + x\nya\n b\n c\n d: null + def `test_cut`: + asdf: null + def `test_cut_paste`: + abcd\nefgh\nklmn: null + def `test_warning`: + a\n: null + Rectangular selection area is too big: null + __main__: null +widgets/data/utils/pythoneditor/tests/test_vim.py: + class `_Test`: + def `setUp`: + The quick brown fox: null + jumps over the: null + lazy dog: null + back: null + normal: null + def `click`: + $%^<>: null + class `Modes`: + def `test_01`: + normal: null + i123: null + insert: null + i4: null + 1234The quick brown fox: null + def `test_02`: + A: null + insert: null + XY: null + lazy dogXY: null + def `test_03`: + a: null + insert: null + XY: null + lXYazy dog: null + def `test_04`: + normal: null + d: null + w: null + def `test_05`: + normal: null + R: null + replace: null + asdf: null + asdfquick brown fox: null + insert: null + def `test_05a`: + $: null + R: null + asdf: null + The quick brown foxasdf: null + def `test_06`: + normal: null + v: null + visual: null + i: null + insert: null + def `test_07`: + visual: null + def `test_08`: + v: null + kkk: null + V: null + The quick brown fox: null + visual lines: null + def `test_09`: + V: null + v: null + The quick brown fox: null + visual: null + def `test_10`: + ' indented line': null + j8lI: null + Z: null + ' Zindented line': null + class `Move`: + def `test_01`: + ll: null + jjj: null + h: null + k: null + def `test_02`: + word, comma, word: null + w: null + def `test_03`: + ' word, comma, word': null + e: null + def `test_04`: + $: null + def `test_05`: + 0: null + def `test_06`: + G: null + def `test_07`: + gg: null + def `test_08`: + b: null + def `test_09`: + (asdf fdsa) xxx: null + %: null + def `test_10`: + ' indented line': null + ^: null + def `test_11`: + fv: null + def `test_12`: + Fv: null + def `test_13`: + tv: null + def `test_14`: + Tv: null + def `test_15`: + dff: null + ox: null + def `test_16`: + asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z: null + e: null + E: null + def `test_17`: + asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z: null + W: null + def `test_18`: + asdfk.xx.z asdfk.xx.z asdfk.xx.z asdfk.xx.z: null + B: null + def `test_19`: + ' indented line': null + ' more indented line': null + class `Del`: + def `test_01a`: + xxxxx: null + The brown fox: null + k: null + def `test_01b`: + 5x: null + The brown fox: null + quick: null + def `test_02`: + dl: null + jmps over the: null + dh: null + mps over the: null + def `test_03`: + dj: null + lazy dog: null + back: null + k: null + def `test_04`: + dk: null + The quick brown fox: null + back: null + jumps over the: null + lazy dog: null + def `test_05`: + 3dw: null + fox: null + 'The quick brown ': null + def `test_06`: + dd: null + The quick brown fox: null + lazy dog: null + back: null + def `test_07`: + dG: null + The quick brown fox: null + jumps over the: null + def `test_08`: + dgg: null + lazy dog: null + back: null + def `test_09`: + llX: null + Te quick brown fox: null + def `test_10`: + jll: null + 2D: null + The quick brown fox: null + ju: null + back: null + class `Edit`: + def `test_01`: + ddu: null + def `test_02`: + lllCpig: null + Thepig: null + def `test_03`: + j4sz: null + zs over the: null + def `test_04`: + rZ: null + The Zuick brown fox: null + rW: null + The Wuick brown fox: null + def `test_05`: + c2e: null + asdf: null + asdf brown fox: null + def `test_06`: + ' indented line': null + ' next indented line': null + o: null + asdf: null + ' asdf': null + def `test_07`: + ' indented line': null + ' next indented line': null + j: null + O: null + asdf: null + ' asdf': null + def `test_08`: + ' indented line': null + ' next indented line': null + ljS: null + xyz: null + ' xyz': null + def `test_09`: + (asdf fdsa) xxx: null + d%: null + ' xxx': null + def `test_10`: + 2J: null + The quick brown fox jumps over the lazy dog: null + back: null + class `Indent`: + def `test_01`: + >2j: null + ' The quick brown fox': null + ' jumps over the': null + ' lazy dog': null + back: null + >: null + ' The quick brown fox': null + <<: null + ' The quick brown fox': null + def `test_03`: + 'i ': null + j: null + =j: null + ' The quick brown fox': null + ' jumps over the': null + ' lazy dog': null + back: null + def `test_04`: + 'i ': null + j: null + ==: null + ' The quick brown fox': null + ' jumps over the': null + lazy dog: null + back: null + def `test_11`: + v2>: null + ' The quick brown fox': null + jumps over the: null + v<: null + ' The quick brown fox': null + def `test_12`: + 'i ': null + j: null + Vj=: null + ' The quick brown fox': null + ' jumps over the': null + ' lazy dog': null + back: null + class `CopyPaste`: + def `test_02`: + 5x: null + The brown fox: null + p: null + The quickbrown fox: null + def `test_03`: + 2dd: null + The quick brown fox: null + back: null + kkk: null + p: null + jumps over the: null + lazy dog: null + def `test_04`: + 2dd: null + The quick brown fox: null + back: null + P: null + jumps over the: null + lazy dog: null + def `test_05`: + y2y: null + jll: null + p: null + The quick brown fox: null + jumps over the: null + lazy dog: null + back: null + def `test_06`: + 2wYo: null + P: null + brown fox: null + def `test_08`: + y2w: null + P: null + The quick The quick brown fox: null + class `Visual`: + def `test_01`: + v: null + visual: null + 2w: null + 'The quick ': null + x: null + brown fox: null + normal: null + def `test_02`: + vllA: null + 'asdf ': null + The asdf quick brown fox: null + def `test_03`: + v8l: null + rz: null + The quick brown zzz: null + zzzzz over the: null + def `test_04`: + vjl: null + R: null + Z: null + lazy dog: null + back: null + def `test_05`: + vjl: null + u: null + def `test_06`: + ve: null + y: null + p: null + The quick brown quick: null + def `test_07`: + vey: null + ww: null + vep: null + The quick The fox: null + def `test_08`: + w: null + vec: null + slow: null + The slow brown fox: null + def `test_09`: + jvlX: null + The quick brown fox: null + lazy dog: null + back: null + u: null + jumps over the: null + vjD: null + def `test_10`: + vfo: null + The quick bro: null + def `test_11`: + jvjJ: null + The quick brown fox: null + jumps over the lazy dog: null + back: null + class `VisualLines`: + def `test_01`: + V: null + visual lines: null + x: null + p: null + jumps over the: null + The quick brown fox: null + lazy dog: null + back: null + normal: null + def `test_02`: + Vy: null + j: null + Vp: null + The quick brown fox: null + lazy dog: null + def `test_06`: + V: null + y: null + p: null + The quick brown fox: null + jumps over the: null + def `test_07`: + Vc: null + slow: null + class `Repeat`: + def `test_01`: + o: null + j2.: null + The quick brown fox: null + jumps over the: null + lazy dog: null + back: null + def `test_02`: + 2o: null + j.: null + The quick brown fox: null + jumps over the: null + lazy dog: null + back: null + def `test_03`: + O: null + 2j2.: null + The quick brown fox: null + jumps over the: null + lazy dog: null + back: null + def `test_04`: + ylp.: null + TTThe quick brown fox: null + def `test_05`: + x...: null + quick brown fox: null + def `test_06`: + Dj.: null + lazy dog: null + back: null + def `test_07`: + dw: null + j0.: null + quick brown fox: null + over the: null + lazy dog: null + back: null + def `test_08`: + one more: null + Vjx: null + .: null + def `test_09`: + one more: null + vjX: null + .: null + def `test_10`: + one more: null + Vj>: null + 3j: null + .: null + ' The quick brown fox': null + ' jumps over the': null + lazy dog: null + ' back': null + ' one more': null + __main__: null +widgets/data/utils/pythoneditor/tests/test_indenter/indenttest.py: + ..: null + tests: null + class `IndentTest`: + def `setUp`: + INDENT_WIDTH: null + def `setOrigin`: + \n: null + def `verifyExpected`: + \n: null + def `writeCursorPosition`: + (%d,%d): null + def `writeln`: + \n: null +widgets/data/utils/pythoneditor/tests/test_indenter/test_python.py: + ..: null + class `Test`: + Python: null + def `test_dedentReturn`: + def some_function():: null + ' return': null + pass: null + def `test_dedentContinue`: + while True:: null + ' continue': null + pass: null + def `test_keepIndent2`: + class my_class():: null + ' def my_fun():': null + ' print "Foo"': null + ' print 3': null + ' pass': null + pass: null + def `test_keepIndent4`: + def some_function():: null + ' pass': null + pass: null + def `test_dedentRaise`: + try:: null + ' raise': null + except:: null + def `test_indentColon1`: + def some_function(param, param2):: null + ' pass': null + pass: null + def `test_indentColon2`: + def some_function(1,: null + ' 2):': null + ' pass': null + pass: null + def `test_indentColon3`: + ' a = {1:': null + ' x': null + x: null + def `test_dedentPass`: + def some_function():: null + ' pass': null + pass: null + def `test_dedentBreak`: + def some_function():: null + ' return': null + pass: null + def `test_keepIndent3`: + while True:: null + ' returnFunc()': null + ' myVar = 3': null + ' pass': null + pass: null + def `test_keepIndent1`: + def some_function(param, param2):: null + ' a = 5': null + ' b = 7': null + ' pass': null + pass: null + def `test_autoIndentAfterEmpty`: + while True:: null + ' returnFunc()': null + ' myVar = 3': null + ' x': null + x: null + def `test_hangingIndentation`: + ' return func (something,': null + ' x': null + x: null + def `test_hangingIndentation2`: + ' return func (': null + ' something,': null + ' x': null + x: null + def `test_hangingIndentation3`: + ' a = func (': null + ' something)': null + ' x': null + x: null + def `test_hangingIndentation4`: + ' return func(a,': null + ' another_func(1,': null + ' 2),': null + ' x': null + x: null + def `test_hangingIndentation5`: + ' return func(another_func(1,': null + ' 2),': null + ' x': null + x: null + __main__: null +widgets/evaluate/tests/test_owcalibrationplot.py: + class `TestOWCalibrationPlot`: + def `setUp`: + y: null + a: null + b: null + datasets/lenses.tab: null + majority: null + knn-3: null + knn-1: null + ignore: null + .*: null + def `test_initialization`: + majority: null + knn-3: null + knn-1: null + '#1': null + '#2': null + a: null + b: null + def `test_regression_input_error`: + y: null + def `test_plotting_curves`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + invalid curve for {combo.currentText()}: null + def `test_multiple_fold_curves`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + def `test_change_target_class`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + def `test_rug`: + def `get_rugs`: + connect: null + pairs: null + connect: null + pairs: null + def `test_apply_no_output`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + abcd: null + each training data sample produces a different model: vsaka podmnožica učnih podatkov sestavi drug model + 'test results do not contain stored models - try testing on ': 'rezultati vrednotenja ne vsebujejo modelov - poskusite testirati ' + separate data or on training data: na ločenih podatkih ali na učni množici + select a single model - the widget can output only one: izberite posamični model + cannot calibrate non-binary classes: ne morem kalibrirati ne-binarnih modelov + def `test_shown`: + {msg} not included in the message: null + def `test_output_threshold_classifier`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + def `test_output_calibrated_classifier`: + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + def `test_single_class`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + def `check_error`: + {error} is unexpectedly: null + {'' if error.is_shown() else ' not'} shown: null + def `test_single_class_folds`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + def `test_warn_nan_probabilities`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + def `test_no_folds`: + Orange.widgets.evaluate.owcalibrationplot.ThresholdClassifier: null + Orange.widgets.evaluate.owcalibrationplot.CalibratedLearner: null + __main__: null +widgets/evaluate/tests/test_owconfusionmatrix.py: + class `TestOWConfusionMatrix`: + def `setUpClass`: + titanic: null + Evaluation Results: null + def `setUp`: + auto_apply: null + def `test_show_error_on_regression`: + housing: null + def `test_unique_output_domain`: + iris(Learner #1): iris(Model #1) + iris(Learner #1) (1): iris(Model #1) (1) + def `test_unique_var_names`: + versicolor: null + Selected: Izbrani podatki + Selected (1): Izbrani podatki (1) + iris(Learner #1): iris(Model #1) + iris(Learner #1) (1): iris(Model #1) (1) + p(Iris-setosa): null + p(Iris-virginica): null + p(Iris-setosa) (1): null + p(Iris-versicolor) (1): null + p(Iris-virginica) (1): null + __main__: null +widgets/evaluate/tests/test_owliftcurve.py: + 1.1.1: null + Only test precision-recall with scikit-learn>=1.1.1: null + class `TestOWLiftCurve`: + def `setUpClass`: + datasets/lenses.tab: null + def `setUp`: + display_convex_hull: null + def `test_threshold_tooltip`: + heart_disease: null + Probability threshold(s):\n— 0.526\n— 0.4: praga verjetnosti:\n— 0.526\n— 0.4 + Probability threshold(s):\n— 0.526\n— 0.2: praga verjetnosti:\n— 0.526\n— 0.2 + Probability threshold(s):\n— 0.526\n— 1.0: praga verjetnosti:\n— 0.526\n— 1.0 + def `test_point_tooltip`: + heart_disease: null + 'P Rate: 0.086\nLift: 1.521\nThreshold: 1.0': Delež pozitivnih: 0.086\nDvig: 1.521\nPrag: 1.0 + def `test_output`: + heart_disease: null + def `test_visual_settings`: + def `test_settings`: + Helvetica: null + tickFont: null + Foo: null + pen: null + Fonts: null + Font family: null + Helvetica: null + Title: null + Font size: null + Italic: null + Axis title: null + Axis ticks: null + Annotations: null + Foo: null + Figure: null + Line: null + Width: null + Default Line: null + __main__: null +widgets/evaluate/tests/test_owpredictions.py: + class `TestOWPredictions`: + def `setUp`: + iris: null + housing: null + def `test_no_values_target`: + titanic: null + status: null + first: null + third: null + age: null + adult: null + child: null + sex: null + female: null + male: null + survived: null + def `test_no_class_on_test`: + titanic: null + constant: null + def `test_bad_data`: + '\ + age\tsex\tsurvived + d\td\td + \t\tclass + adult\tmale\tyes + adult\tfemale\tno + child\tmale\tyes + child\tfemale\tyes + ': null + '\ + age\tsex\tsurvived + d\td\td + \t\tclass + adult\tmale\tyes + adult\tfemale\tno + child\tmale\tyes + child\tfemale\tunknown + ': null + def `test_continuous_class`: + housing: null + def `test_changed_class_var`: + heart_disease: null + housing: null + def `test_predictor_fails`: + titanic: null + foo: null + def `test_sort_matching`: + titanic: null + def `test_colors_continuous`: + housing: null + def `test_unique_output_domain`: + constant: null + constant (1): null + def `test_selection_in_setting`: + selection: null + def `test_multi_inputs`: + P1: null + P2: null + P3: null + def `_mock_predictors`: + def `pred`: + c: null + def `predc`: + c: null + abc: null + ab: null + cbd: null + e: null + def `test_update_prediction_delegate_discrete`: + c: null + abc: null + abcde: null + p(a, b, c): null + p(a, b): null + p(c, b, d): null + p(e): null + p(b, c): null + p(a): null + p(b): null + p(c): null + def `test_update_delegates_continuous`: + c: null + abcde: null + def `test_delegate_ranges`: + class `Model1`: + foo: null + class `Model2`: + bar: null + x: null + y: null + abcdefghijklmnopq: null + def `test_change_target`: + Orange.widgets.evaluate.owpredictions.usable_scorers: null + def `test_multi_target_input`: + var1: null + c1: null + c2: null + no: null + yes: null + Mockery: null + def `test_regression_error_delegate_ranges`: + x: null + y: null + def `test_migrate_shown_scores`: + score_table: null + shown_scores: null + Sensitivity: null + show_score_hints: null + class `SelectionModelTest`: + def `setUp`: + iris: null + class `PredictionsModelTest`: + def `test_model_header`: + 4: null + a: null + b: null + error: null + 5: null + class `TestPredictionsItemDelegate`: + def `test_displayText`: + {value:.3f}: null + 0.123: null + {value:.1f}: null + 0.1: null + {value:.1f} - {dist[2]}: null + 0.1 - 3: null + class `TestClassificationItemDelegate`: + def `test_format`: + showText: null + foo: null + bar: null + baz: null + p(foo, baz): null + '0.60 : - : 0.40 → baz': null + def `test_drawbar`: + foo: null + bar: null + baz: null + bax: null + class `TestRegressionItemDelegate`: + def `test_format`: + %6.3f: null + ' 5.130': null + 5.10: null + def `test_drawBar`: + %6.3f: null + class `TestClassificationErrorDelegate`: + def `test_displayText`: + 0.123: null + ?: null + class `TestRegressionErrorDelegate`: + def `test_displayText`: + %.5f: null + 0.12346: null + ?: null + ∞: null + -∞: null + def `test_drawBar`: + %.5f: null + __main__: null +widgets/evaluate/tests/test_owrocanalysis.py: + class `TestROC`: + def `test_ROCData_from_results`: + iris: null + class `TestOWROCAnalysis`: + def `setUpClass`: + datasets/lenses.tab: null + mouseRateLimit: null + def `setUp`: + display_perf_line: null + display_def_threshold: null + display_convex_hull: null + display_convex_curve: null + def `test_tooltips`: + n: null + ppnpppnnpnpnpnnnpnpn: null + y: null + pn: null + showText: null + (#1) 0.900: null + '#2': null + (#1) 1.000\n(#2) 1.000: null + (#1) 0.600\n(#2) 0.590: null + def `test_target_prior`: + none: null + soft: null + __main__: null +widgets/evaluate/tests/test_owtestandscore.py: + class `TestOWTestAndScore`: + def `setUp`: + a: null + b: null + c: null + y: null + n: null + def `test_basic`: + iris: null + housing: null + def `test_multiple_learners`: + iris: null + M1: null + M2: null + def `test_testOnTest`: + iris: null + def `test_testOnTest_incompatible_domain`: + iris: null + x: null + def `test_CrossValidationByFeature`: + iris: null + def `test_migrate_removes_invalid_contexts`: + context_settings: null + def `test_migrate_shown_scores`: + score_table: null + shown_scores: null + Sensitivity: Občutljivost + show_score_hints: null + def `test_memory_error`: + iris: null + Orange.evaluation.testing.Results.get_augmented_data: null + def `test_one_class_value`: + a: null + b: null + c: null + y: null + yyyy: null + Data: null + Learner: null + def `test_data_errors`: + def `assertErrorShown`: + Data: null + iris: null + Target variable has no values.: Cijna spremenljivka nima vrednosti. + Target variable has only one value.: Ciljna spremenljivka ima samo eno vrednost. + Data has no features to learn from.: Podatki nimajo spremenljivk za učenje. + Train dataset is empty.: Tabela učnih primerov je prazna. + def `test_addon_scorers`: + class `NewScore`: + new scorer: null + class `NewClassificationScore`: + new classification scorer: null + iris: null + new scorer: null + new classification scorer: null + NewRegressionScore: null + housing: null + NewScore: null + NewClassificationScore: null + def `test_target_changing`: + iris: null + Iris-setosa: null + Iris-versicolor: null + Iris-virginica: null + def `test_resort_on_data_change`: + iris: null + versicolor: null + setosa: null + def `test_scores_constant`: + yyyn: null + def `test_scores_log_reg_overfitted`: + yyyn: null + def `test_scores_log_reg_bad`: + nnny: null + yyyn: null + def `test_scores_log_reg_bad2`: + nnyy: null + yynn: null + def `test_scores_log_reg_advanced`: + yyynn: null + yynnn: null + def `test_scores_cross_validation`: + iris: null + def `test_no_stratification`: + zoo: null + iris: null + housing: null + def `test_too_many_folds`: + zoo: null + def `_set_three_majorities`: + iris: null + maja: null + majb: null + majc: null + def `test_comparison_requires_cv`: + baycomp.two_on_single: null + iris: null + def `test_comparison_requires_multiple_models`: + majd: null + def `test_comparison_bad_slots`: + Classification accuracy: Klasifikacijska točnost + def `test_comparison_bad_scores`: + Classification accuracy: Klasifikacijska točnost + compute_score: null + def `test_comparison_binary_score`: + F1: null + iris: null + compute_score: null + target: null + average: null + weighted: null + def `test_fill_table`: + baycomp.two_on_single: null + {(row + 1) / (row + col + 2):.3f}: null + {probs(row, col, w.rope)[0]:.3f}: null + {probs(row, col, w.rope)[1]:.3f}: null + def `test_nan_on_comparison`: + baycomp.two_on_single: null + NA: null + def `test_unique_output_domain`: + random forest: null + random forest (1): null + def `test_copy_to_clipboard`: + iris: null + \t: null + def `test_multi_target_input`: + var1: null + c1: null + c2: null + no: null + yes: null + Mockery: null + class `TestHelpers`: + def `test_results_one_vs_rest`: + datasets/lenses.tab: null + __main__: null +widgets/evaluate/tests/test_utils.py: + class `TestUsableScorers`: + def `setUp`: + iris: null + housing: null + class `TestScoreTable`: + def `setUp`: + class `NewScore`: + new score: null + def `tearDown`: + NewScore: null + def `test_show_column_chooser`: + def `execmenu`: + F1: F1 + Classification accuracy (CA): Klasifikacijska točnost (Točnost) + Area under ROC curve (AUC): Površina pod krivuljo ROC (AUC) + Specificity (Spec): Specifičnost (Spec) + new score: null + error in section {scorer.name}: null + CA: null + error at {k}: null + AUC: null + AnyQt.QtWidgets.QMenu.addAction: null + AnyQt.QtWidgets.QMenu.exec: null + def `test_sorting`: + D: null + C: null + b: null + A: null + E: null + AbCDE: null + EDCbA: null + CDb: null + bDC: null + CED: null + DEC: null + def `test_shown_scores_backward_compatibility`: + F1: null + AUC: null + new score: null + def `test_migration`: + Sensitivity: null + show_score_hints: null + __main__: null +widgets/model/tests/test_owadaboost.py: + class `TestOWAdaBoost`: + def `setUp`: + auto_apply: null + algorithm: null + classification: null + loss: null + regression: null + learning_rate: null + n_estimators: null + random_seed: null + random_state: null + def `test_input_learner`: + The default base estimator should not be none: null + The default base estimator should support weights: null + The base estimator was not updated when valid learner on input: null + The base estimator was not reset to default when None on input: null + def `test_error_message_cleared_when_valid_learner_on_input`: + Error message was not hidden on input disconnect: null + 'Error message was not hidden when a valid learner appeared on ': null + input: null +widgets/model/tests/test_owcalibratedlearner.py: + class `TestOWCalibratedLearner`: + def `setUp`: + auto_apply: null + heart_disease: null + testing_dataset_reg: null + Calibrated classifier: null + def `test_output_learner`: + Learner: null + Does not initialize the learner output: null + Does not send a new learner instance on `Apply`.: null + def `test_output_model`: + Data: null + def `test_name_changes`: + foo: null + Foo + Isotonic + CA: Foo + Izotonična + točnost + Foo + CA: Foo + točnost + Calibrated Learner: Kalibriran model +widgets/model/tests/test_owconstant.py: + class `TestOWConstant`: + def `setUp`: + auto_apply: null +widgets/model/tests/test_owcurvefit.py: + class `TestFunctions`: + def `test_functions`: + any: null + all: null + arctan2: null + copysign: null + fmod: null + gcd: null + hypot: null + isclose: null + ldexp: null + power: null + remainder: null + class `TestParameter`: + def `test_to_tuple`: + foo: null + def `test_repr`: + foo: null + 'Parameter(name=foo, initial=2, use_lower=True, ': null + lower=10, use_upper=False, upper=50): null + class `TestParametersWidget`: + def `test_add_row`: + p1: null + def `test_add_row_with_data`: + a: null + def `test_set_data`: + a: null + b: null + def `test_reset_data`: + a: null + def `test_clear_all`: + a: null + class `TestOWCurveFit`: + def `setUp`: + auto_apply: null + housing: null + def `__init_widget`: + 'p1 + ': null + def `test_output_model_name`: + Model Name: null + def `test_output_coefficients`: + coef: null + name: null + def `test_output_mixed_features`: + coef: null + name: null + def `test_features_combo`: + Select Feature: Izberite spremenljivko + CRIM: null + def `test_parameters_combo`: + Select Parameter: Izberite parameter + p1: null + def `test_function_combo`: + Select Function: Izberite funkcijo + abs(): null + def `test_expression`: + ' + ': null + gcd: null + 2: null + arctan2: null + copysign: null + fmod: null + hypot: null + isclose: null + ldexp: null + power: null + remainder: null + def `test_sanitized_expression`: + heart_disease: null + p1 + rest_SBP: null + def `test_discrete_expression`: + heart_disease: null + p1 + gender_female: null + def `test_invalid_expression`: + ' + ': null + ' 2 ': null + def `test_duplicated_parameter_name`: + p1: null + p2: null + def `test_parameter_name_in_features`: + p1: null + cls: null + a: null + def `test_no_parameter`: + LSTAT + 1: null + LSTAT + a: null + def `test_unused_parameter`: + p1 + LSTAT + p2: null + p1 + LSTAT: null + def `test_unknown_parameter`: + p1 + LSTAT: null + p2 + LSTAT: null + def `test_saved_parameters`: + a: null + parameters: null + def `test_output`: + p1 * exp(-p2 * LSTAT) + p3: null + coef: null + name: null + __main__: null +widgets/model/tests/test_owgradientboosting.py: + def `create_parent`: + class `DummyWidget`: + Mock: null + class `TestLearnerItemModel`: + def `test_missing_lib`: + Orange.widgets.model.owgradientboosting.LearnerItemModel.LEARNERS: null + Gradient Boosting (catboost): null + catboost: null + class `TestGBLearnerEditor`: + def `test_arguments`: + n_estimators: null + learning_rate: null + max_depth: null + random_state: null + subsample: null + min_samples_split: null + def `test_learner_parameters`: + Method: null + Gradient Boosting (scikit-learn): null + Number of trees: null + Learning rate: null + Replicable training: null + Yes: null + Maximum tree depth: null + Fraction of training instances: null + Stop splitting nodes with maximum instances: null + def `test_default_parameters_cls`: + heart_disease: null + n_estimators: null + learning_rate: null + max_depth: null + subsample: null + min_samples_split: null + random_state: null + def `test_default_parameters_reg`: + housing: null + n_estimators: null + learning_rate: null + max_depth: null + subsample: null + min_samples_split: null + random_state: null + class `TestXGBLearnerEditor`: + def `test_arguments`: + n_estimators: null + learning_rate: null + max_depth: null + reg_lambda: null + colsample_bytree: null + colsample_bylevel: null + colsample_bynode: null + subsample: null + random_state: null + def `test_learner_parameters`: + Missing 'xgboost' package: null + Method: null + Extreme Gradient Boosting (xgboost): null + Number of trees: null + Learning rate: null + Replicable training: null + Yes: null + Maximum tree depth: null + Regularization strength: null + Fraction of training instances: null + Fraction of features for each tree: null + Fraction of features for each level: null + Fraction of features for each split: null + def `test_default_parameters_cls`: + Missing 'xgboost' package: null + heart_disease: null + learner: null + gradient_booster: null + updater: null + grow_colmaker: null + train_param: null + n_estimators: null + learning_rate: null + max_depth: null + reg_lambda: null + subsample: null + colsample_bytree: null + colsample_bylevel: null + colsample_bynode: null + def `test_default_parameters_reg`: + Missing 'xgboost' package: null + housing: null + learner: null + gradient_booster: null + updater: null + grow_colmaker: null + train_param: null + n_estimators: null + learning_rate: null + max_depth: null + reg_lambda: null + subsample: null + colsample_bytree: null + colsample_bylevel: null + colsample_bynode: null + class `TestXGBRFLearnerEditor`: + def `test_arguments`: + n_estimators: null + learning_rate: null + max_depth: null + reg_lambda: null + colsample_bytree: null + colsample_bylevel: null + colsample_bynode: null + subsample: null + random_state: null + def `test_learner_parameters`: + Missing 'xgboost' package: null + Method: null + Extreme Gradient Boosting Random Forest (xgboost): null + Number of trees: null + Learning rate: null + Replicable training: null + Yes: null + Maximum tree depth: null + Regularization strength: null + Fraction of training instances: null + Fraction of features for each tree: null + Fraction of features for each level: null + Fraction of features for each split: null + def `test_default_parameters_cls`: + Missing 'xgboost' package: null + heart_disease: null + learner: null + gradient_booster: null + updater: null + grow_colmaker: null + train_param: null + n_estimators: null + learning_rate: null + max_depth: null + reg_lambda: null + subsample: null + colsample_bytree: null + colsample_bylevel: null + colsample_bynode: null + def `test_default_parameters_reg`: + Missing 'xgboost' package: null + housing: null + learner: null + gradient_booster: null + updater: null + grow_colmaker: null + train_param: null + n_estimators: null + learning_rate: null + max_depth: null + reg_lambda: null + subsample: null + colsample_bytree: null + colsample_bylevel: null + colsample_bynode: null + class `TestCatGBLearnerEditor`: + def `test_arguments`: + n_estimators: null + learning_rate: null + max_depth: null + reg_lambda: null + colsample_bylevel: null + random_state: null + def `test_learner_parameters`: + Missing 'catboost' package: null + Method: null + Gradient Boosting (catboost): null + Number of trees: null + Learning rate: null + Replicable training: null + Yes: null + Maximum tree depth: null + Regularization strength: null + Fraction of features for each tree: null + def `test_default_parameters_cls`: + Missing 'catboost' package: null + heart_disease: null + iterations: null + depth: null + l2_leaf_reg: null + rsm: null + def `test_default_parameters_reg`: + Missing 'catboost' package: null + housing: null + iterations: null + depth: null + l2_leaf_reg: null + rsm: null + class `TestOWGradientBoosting`: + def `setUp`: + auto_apply: null + n_estimators: null + learning_rate: null + max_depth: null + min_samples_split: null + def `test_xgb_params`: + Missing 'xgboost' package: null + n_estimators: null + learning_rate: null + max_depth: null + reg_lambda: null + def `test_missing_lib`: + orange: null + xgboost: null + catboost: null + method_index: null + __main__: null +widgets/model/tests/test_owknn.py: + class `TestOWKNNLearner`: + def `setUp`: + auto_apply: null + metric: null + weights: null + n_neighbors: null +widgets/model/tests/test_owlinearregression.py: + class `TestOWLinearRegression`: + def `setUp`: + auto_apply: null +widgets/model/tests/test_owloadmodel.py: + class `TestOWLoadModel`: + def `setUp`: + iris: null + .pkcls: null + def `test_browse_file_opens_file`: + AnyQt.QtWidgets.QFileDialog.getOpenFileName: null + *.pkcls: null + def `test_select_file`: + pickle.load: null + .pkcls: null + \\: null + /: null + def `test_load_error`: + AnyQt.QtWidgets.QFileDialog.getOpenFileName: null + *.pkcls: null + pickle.load: null + last_path: null + foo: null + def `test_no_last_path`: + recent_paths: null + def `test_open_moved_workflow`: + Orange.widgets.widget.OWWidget.workflowEnv: null + basedir: null + pickle.load: null + temp/models: null + models: null + recent_paths: null + \\: null + /: null + class `TestOWLoadModelDropHandler`: + def `test_canDropFile`: + test.pkcls: null + test.txt: null + def `test_parametersFromFile`: + test.pkcls: null + recent_paths: null + __main__: null +widgets/model/tests/test_owlogisticregression.py: + class `LogisticRegressionTest`: + def `test_coef_table_single`: + titanic: null + def `test_coef_table_multiple`: + zoo: null + class `TestOWLogisticRegression`: + def `setUp`: + auto_apply: null + penalty: null + C: null + def `test_output_coefficients`: + Data: null + def `test_domain_with_more_values_than_table`: + iris: null + Data: null + def `test_coefficients_one_value`: + a: null + b: null + c: null + yes: null + no: null + Data: null + def `test_target_with_nan`: + iris: null + Data: null + Coefficients: null + def `test_class_weights`: + iris: null + Data: null + balanced: null + def `test_no_penalty`: + none: null + N/A: NN + l2: null + C=1: null +widgets/model/tests/test_ownaivebayes.py: + class `TestOWNaiveBayes`: + def `setUp`: + auto_apply: null +widgets/model/tests/test_owneuralnetwork.py: + class `TestOWNeuralNetwork`: + def `setUp`: + ignore: null + .*: null + auto_apply: null + def `test_migrate_setting`: + alpha_index: null + def `test_no_layer_warning`: + 10,: null +widgets/model/tests/test_owrandomforest.py: + class `TestOWRandomForest`: + def `setUp`: + auto_apply: null + n_estimators: null + min_samples_split: null + def `test_parameters_checked`: + max_features: null + max_depth: null + def `test_parameters_unchecked`: + max_features: null + sqrt: null + random_state: null + max_depth: null + min_samples_split: null + def `test_class_weights`: + iris: null + Data: null + balanced: null + __main__: null +widgets/model/tests/test_owrulesclassification.py: + class `TestOWRulesClassification`: + def `setUp`: + auto_apply: null + Evaluation measure: Ocena kvalitete pravila + Beam width: Širina snopa + Minimum rule coverage: Najmanjše število pokritih primerov + Maximum rule length: Največja dolžina pravila + def `test_sparse_data`: + iris: null + Data: null + def `test_out_of_memory`: + iris: null + Orange.widgets.model.owrules.CustomRuleLearner.__call__: null + Data: null + def `test_default_rule`: + zoo: null + Data: null +widgets/model/tests/test_owsavemodel.py: + class `OWSaveTestBase`: + def `setUp`: + iris: null + __main__: null +widgets/model/tests/test_owsgd.py: + class `TestOWSGD`: + def `setUp`: + ignore: null + .*: null + auto_apply: null + loss: null + classification: null + epsilon: null + regression: null + penalty: null + alpha: null + l1_ratio: null + learning_rate: null + eta0: null + power_t: null +widgets/model/tests/test_owstack.py: + class `TestOWStackedLearner`: + def `setUp`: + auto_apply: null + iris: null + def `test_input_data`: + Data: null + def `test_output_learner`: + Learners: null + Learner: null + Does not initialize the learner output: null + Does not send a new learner instance on `Apply`.: null + def `test_output_model`: + Learners: null + Data: null +widgets/model/tests/test_owsvm.py: + class `TestOWSVMClassification`: + def `setUp`: + auto_apply: null + C: null + gamma: null + coef0: null + degree: null + tol: null + max_iter: null + def `test_parameters_unchecked`: + max_iter: null + def `test_parameters_svm_type`: + nu: null + def `test_sparse_warning`: + iris: null + Data: null +widgets/model/tests/test_tree.py: + class `TestOWClassificationTree`: + def `setUp`: + auto_apply: null + max_depth: null + min_internal: null + min_samples_split: null + min_leaf: null + min_samples_leaf: null + def `test_sparse_data_classification`: + iris: null + Data: null + Model: null + def `test_sparse_data_regression`: + housing: null + Data: null + Model: null +widgets/report/tests/test_report.py: + def `get_owwidgets`: + ow: null + .py: null + {}.{}: null + .: null + 'Failed to import module: ': null + OW: null + name: null + send_report: null + Orange.widgets.data: null + Orange.widgets.visualize: null + Orange.widgets.model: null + class `TestReportWidgets`: + def `test_report_widgets_model`: + titanic: null + def `test_report_widgets_data`: + zoo: null + def `test_report_widgets_evaluate`: + zoo: null + LR l2: null + def `test_report_widgets_unsupervised`: + zoo: null + def `test_report_widgets_unsupervised_dist`: + zoo: null + def `test_report_widgets_visualize`: + zoo: null + def `test_report_widgets_all`: + pyqt5: null + Segfaults on PyQt5: null + __main__: null +widgets/tests/test_class_values_context_handler.py: + x: null + class `TestClassValuesContextHandler`: + def `setUp`: + c1: null + d1: null + abc: null + d2: null + def: null + d3: null + ghi: null + c2: null + d4: null + jkl: null + def `test_open_context`: + g: null + h: null + i: null + u: null + d1: null + d2: null + def `test_open_context_with_no_match`: + g: null + h: null + i: null + u: null + d1: null + d2: null + a: null + b: null + c: null +widgets/tests/test_credentials.py: + class `TestCredentialManager`: + def `setUp`: + Orange: null + def `test_credential_manager`: + Orange: null + Foo: null + def `test_set_password`: + keyring.set_password: null + Orange.widgets.credentials.log.exception: null + def `test_delete_password`: + keyring.delete_password: null + Orange.widgets.credentials.log.exception: null + def `test_get_password`: + keyring.get_password: null + Orange.widgets.credentials.log.exception: null +widgets/tests/test_domain_context_handler.py: + x: null + class `TestDomainContextHandler`: + def `setUp`: + c1: null + d1: null + abc: null + d2: null + def: null + d3: null + ghi: null + c2: null + d4: null + jkl: null + def `test_encode_domain_with_match_none`: + c1: null + d1: null + d2: null + d3: null + c2: null + d4: null + def `test_encode_domain_with_match_class`: + c1: null + d1: null + d2: null + d3: null + ghi: null + c2: null + d4: null + def `test_encode_domain_with_match_all`: + c1: null + d1: null + abc: null + d2: null + def: null + d3: null + ghi: null + c2: null + d4: null + jkl: null + def `test_match_returns_1_if_everything_matches`: + d1: null + d4: null + def `test_match_returns_zero_on_incompatible_context`: + u: null + d1: null + def `test_clone_context`: + u: null + d1: null + c1: null + text: null + with_metas: null + required: null + def `test_open_context`: + u: null + d1: null + d2: null + def `test_open_context_with_imperfect_match`: + u: null + d1: null + c1: null + def `test_open_context_not_first_match`: + u: null + d1: null + c1: null + def `test_open_context_with_no_match`: + u: null + text: null + def `test_filter_value`: + value: null + def `test_filter`: + value: null + d1: null + c1: null + abcd: null + def `test_filter_value_dict`: + value: null + def `test_filter`: + value: null + d1: null + c1: null + abcd: null + def `test_backward_compatible_params`: + always: null +widgets/tests/test_gui.py: + class `TestDoubleSpin`: + def `test_checked_extension`: + some_param: null + some_option: null + class `TestListModel`: + def `setUp`: + foo: null + def `test_select_callback`: + abc: null + def `test_select_callfront`: + abc: null + b: null + class `ComboBoxTest`: + def `test_set_initial_value`: + abc: null + foo: null + def `test_warn_value_type`: + Orange.widgets.gui.gui_comboBox: null + foo: null + class `TestRankModel`: + def `test_argsort`: + Bertha: null + daniela: null + ann: null + Cecilia: null +widgets/tests/test_matplotlib_export.py: + def `add_intro`: + import matplotlib.pyplot as plt\n: null + from numpy import array\n: null + plt.clf(): null + class `TestScatterPlot`: + def `test_owscatterplot_ignore_empty`: + iris: null + plt.scatter: null + def `test_scatterplot_simple`: + w: null + plt.scatter: null +widgets/tests/test_perfect_domain_context_handler.py: + x: null + class `TestPerfectDomainContextHandler`: + def `setUp`: + c1: null + d1: null + abc: null + d2: null + def: null + d3: null + ghi: null + c2: null + d4: null + jkl: null + def `test_encode_domain_simple`: + c1: null + d1: null + d2: null + d3: null + c2: null + d4: null + def `test_encode_domain_match_values`: + c1: null + d1: null + abc: null + d2: null + def: null + d3: null + ghi: null + c2: null + d4: null + jkl: null + def `test_encode_setting`: + d1: null + d4: null + class `SimpleWidget`: + foo: null +widgets/tests/test_settings_handler.py: + class `MigrationsTestCase`: + def `test_migrate_str_to_variable`: + foo: null + baz: null + qux: null + quuux: null +widgets/tests/test_widgets_outputs.py: + class `TestWidgetOutputs`: + def `test_outputs`: + \\n\s+self.send\("([^"]*)": null + .: null + utf-8: null + - {} ({}): null + ', ': null + Some widgets send to undeclared outputs:\n: null + \n: null +widgets/tests/test_workflows.py: + def `discover_workflows`: + .ows: null + class `TestWorkflows`: + SKIP_EXAMPLE_WORKFLOWS: null + Example workflows inflate coverage: null + def `test_scheme_examples`: + workflows: null + rb: null + Old workflow '{}' could not be loaded\n'{}': null + def `test_examples_order`: + orange3: null + !Testname: null + orangecontrib.any_addon.tutorials: null + exampletutorials: null + orangecontrib.other_addon.tutorials: null + orange.widgets.tutorials: null + 000-Orange3: null +widgets/unsupervised/tests/test_owcorrespondence.py: + class `TestOWCorrespondence`: + def `setUp`: + titanic: null + def `test_no_data`: + iris: null + def `test_data_values_in_column`: + a: null + b: null + t: null + f: null + c: null + y: null + n: null + d: null + k: null + l: null + z: null + yyyy: null + klkk: null + def `test_data_one_value_zero`: + a: null + 0: null + def `test_no_discrete_variables`: + a: null + iris: null +widgets/unsupervised/tests/test_owdbscan.py: + class `TestOWDBSCAN`: + def `setUp`: + iris: null + def `test_cluster`: + Cluster: Gruča + DBSCAN Core: Jedro DBSCAN-a + def `test_unique_domain`: + Cluster: Gruča + Cluster (1): Gruča (1) + def `test_sparse_csr_data`: + Cluster: Gruča + DBSCAN Core: Jedro DBSCAN-a + def `test_sparse_csc_data`: + Cluster: Gruča + DBSCAN Core: Jedro DBSCAN-a + def `test_get_kth_distances`: + euclidean: null + def `test_titanic`: + titanic: null + def `test_missing_data`: + Cluster: Gruča + def `test_normalize_data`: + heart_disease: null + eps: null + min_samples: null + metric: null + euclidean: null + __main__: null +widgets/unsupervised/tests/test_owdistancefile.py: + class `TestOWDistanceFile`: + def `test_non_square`: + xlsx_files/distances_nonsquare.xlsx: null + xlsx_files/distances_with_nans.xlsx: null + def `test_nan_to_num`: + xlsx_files/distances_with_nans.xlsx: null + class `TestOWDistanceFileDropHandler`: + def `test_canDropFile`: + test.dst: null + test.xlsx: null + test.bin: null + def `test_parametersFromFile`: + test.dst: null + recent_paths: null +widgets/unsupervised/tests/test_owdistancemap.py: + class `TestOWDistanceMap`: + def `setUpClass`: + Distances: null + __main__: null +widgets/unsupervised/tests/test_owdistancematrix.py: + class `TestOWDistanceMatrix`: + def `setUp`: + iris: null + def `test_set_distances`: + ab: null + def: null + def `test_context_attribute`: + None: null + Enumerate: null + def `test_unconditional_commit_on_new_signal`: + now: null + def `test_labels`: + xy: null + s: null + Bill: null + Cynthia: null + Demi: null + Fred: null + George: null + 9: null + def `test_num_meta_labels_w_nan`: + xy: null + s: null + a: null + b: null + 1: null + ?: null + def `test_choose_label`: + xyz: null + t: null + m: null + abc: null + a: null + b: null + c: null + def `test_non_square_labels`: + aa: null + bb: null + cc: null + dd: null + ee: null + 2: null + def `test_migrate_settings_v1_and_use_them`: + __version__: null + None: null + Enumerate: null + sepal length: null + sepal width: null + petal length: null + petal width: null + iris: null + context_settings: null + def `test_square_settings`: + ab: null + __main__: null +widgets/unsupervised/tests/test_owdistances.py: + class `TestDistanceRunner`: + def `setUpClass`: + iris: null + zoo: null + class `TestOWDistances`: + def `setUp`: + iris: null + titanic: null + def `test_distance_combo`: + at {metricdef.name}: null + def `test_jaccard_messages`: + heart_disease: null + def `test_too_big_array`: + at {exc}: null + def `test_migrate_3_to_4`: + __version__: null + at {old} to {MetricDefs[new].name}: null + def `test_limit_mahalanobis`: + {i}: null + def `test_non_binary_in_metas`: + zoo: null + name: null + legs: null + __main__: null +widgets/unsupervised/tests/test_owhierarchicalclustering.py: + class `TestOWHierarchicalClustering`: + def `setUpClass`: + Distances: null + def `_compare_selected_annotated_domains`: + Other: Drugo + def `test_annotation_settings_retrieval`: + Enumeration: Številčenje + None: Brez + Name: Ime + def `test_infinite_distances`: + a: null + b: null + y: null + yy: null + ignore: null + .*: null + def `test_column_distances`: + cluster: Gruča + sepal width: null + petal length: null + sepal length: null + petal width: null +widgets/unsupervised/tests/test_owkmeans.py: + class `TestClusterTableModel`: + def `test_model`: + bad: null + another bad: null + NA: NN + 0.250: null + 4: null + class `TestOWKMeans`: + def `setUp`: + auto_commit: null + version: null + heart_disease: null + def `test_migrate_version_1_settings`: + auto_apply: null + def `test_use_cache`: + _compute_clustering: null + k: null + def `test_centroids_on_output`: + heart_disease centroids: centroidi heart_disease + def `test_centroids_domain_on_output`: + heart_disease: null + at attribute '{attr.name}': null + centroids: centroidi + class `KMeansFail`: + def `fit`: + n_clusters: null + k={} fails: null + def `test_optimization_fails`: + Orange.widgets.unsupervised.owkmeans.KMeans: null + set_scores: null + def `test_run_fails`: + Orange.widgets.unsupervised.owkmeans.KMeans: null + def `test_select_best_row`: + housing: null + error: null + def `test_normalize_sparse`: + Orange.widgets.unsupervised.owkmeans.Normalize: null + def `test_report`: + report_items: null + report_data: null + report_table: null + selected_row: null + Number of clusters: Število gruč + Optimization: Optimizacija + def `test_silhouette_column`: + Orange.widgets.unsupervised.owkmeans.SILHOUETTE_MAX_SAMPLES: null + Silhouette: Silhuete + def `test_do_not_recluster_on_same_data`: + now: null + def `test_correct_smart_init`: + _compute_clustering: null + init: null + k-means++: null + random: null + __main__: null +widgets/unsupervised/tests/test_owlouvain.py: + class `TestOWLouvain`: + def `setUp`: + auto_commit: null + iris: null + def `test_clusters_ordered_by_size`: + Cluster: Gruča + def `test_empty_dataset`: + meta_var: null + def `test_do_not_recluster_on_same_data`: + _invalidate_output: null + def `test_only_recluster_when_necessary_pca_components_change`: + _invalidate_output: null + def `test_normalize_data`: + Orange.preprocess.Normalize: null + def `test_graph_output`: + graph: null + def `test_migrate_settings`: + context_settings: null + __version__: null + apply_pca: null + k_neighbors: null + metric_idx: null + normalize: null + pca_components: null + resolution: null +widgets/unsupervised/tests/test_owmanifoldlearning.py: + class `TestOWManifoldLearning`: + def `setUpClass`: + iris: null + def `setUp`: + auto_apply: null + def `test_sparse_data`: + iris: null + def `test_metrics`: + t-SNE: null + def `test_unique_domain`: + MDS: null + C0: null + C0 (1): null + def `test_singular_matrices`: + a: null + b: null + c: null + 0: null + 1: null + def `test_out_of_memory`: + iris: null + Orange.projection.manifold.MDS.__call__: null + Data: null + def `test_unconditional_commit_on_new_signal`: + now: null +widgets/unsupervised/tests/test_owmds.py: + class `TestOWMDS`: + def `setUpClass`: + Distances: null + ..: null + datasets: null + def `setUp`: + __version__: null + max_iter: null + initialization: null + slovenian-towns.dst: null + def `test_plot_once`: + heart_disease: null + def `test_out_of_memory`: + Orange.projection.MDS.__call__: null + sys.excepthook: null + def `test_other_error`: + Orange.projection.MDS.__call__: null + sys.excepthook: null + def `test_distances_without_data_0`: + Distances: null + def `test_distances_without_data_1`: + Distances: null + def `test_migrate_settings_from_version_1`: + iris: null + petal length: null + petal width: null + sepal length: null + sepal width: null + __version__: null + color_value: null + shape_value: null + size_value: null + Stress: Napetost + label_value: null + autocommit: null + connected_pairs: null + initialization: null + jitter: null + label_only_selected: null + legend_anchor: null + max_iter: null + refresh_rate: null + symbol_opacity: null + symbol_size: null + context_settings: null + savedWidgetGeometry: null + def `test_attr_label_from_dist_matrix_from_file`: + label: null + def `test_attr_label_from_dist_matrix_from_data`: + zoo: null + def `test_attr_label_from_data`: + zoo: null + def `test_attr_label_matrix_and_data`: + zoo: null + def `test_saved_matrix_and_data`: + label: null + def `test_matrix_columns_tooltip`: + sepal length: null + def `test_matrix_columns_default_label`: + labels: null + def `test_update_stress`: + {expected:.3f}: null + -: null + class `TestOWMDSRunner`: + def `setUpClass`: + iris: null + def `test_run_mds`: + Running...: Tečem... + __main__: null +widgets/unsupervised/tests/test_owpca.py: + class `TestOWPCA`: + def `setUp`: + iris: null + def `test_constant_data`: + ignore: null + def `test_migrate_settings_limits_components`: + ncomponents: null + def `test_migrate_settings_changes_variance_covered_to_int`: + variance_covered: null + nan: null + def `test_unique_domain_components`: + components: komponente + components (1): komponente (1) + def `test_variance_attr`: + variance: null + def `test_all_components_continuous`: + datasets/cyber-security-breaches.tab: null + Some variables aren't of type ContinuousVariable: null + def `test_normalize_data`: + Normalize: null + def `test_do_not_mask_features`: + iris.tab: null + __main__: null +widgets/unsupervised/tests/test_owsavedistances.py: + class `OWSaveTestBase`: + def `setUp`: + iris: null + def `_save_and_load`: + .dst: null + def `test_save_part_labels_as_table`: + rows: vrsticah + columns: stolpcih + failed when labels in {labels_in}: napaka, ko so podatkih v {labels_in} + def `test_save_trivial_labels`: + label: null + def `test_nonsquare`: + .xlsx: null + def `test_send_report`: + test.dst: null + __main__: null +widgets/unsupervised/tests/test_owsom.py: + def `_patch_recompute_som`: + def `patched`: + winner_from_weights: null + _recompute_som: null + class `TestOWSOM`: + def `setUp`: + iris: null + def `test_requires_continuous`: + heart_disease: null + zoo: null + def `test_missing_all_data`: + heart_disease: null + def `test_single_row_data`: + heart_disease: null + def `test_sparse_data`: + from_table: null + def `test_attr_color_change`: + heart_disease: null + gender: null + age: null + def `test_get_color_column`: + heart_disease: null + rest ECG: null + gender: null + max HR: null + age: null + def `test_invalidated`: + heart_disease: null + __main__: null +widgets/unsupervised/tests/test_owtsne.py: + class `TestOWtSNE`: + def `setUpClass`: + Data: null + def `setUp`: + Orange.projection.manifold.TSNE: null + Orange.projection.manifold.TSNEModel: null + multiscale: null + Stage name: null + STG1: null + STG2: null + GeneName: null + def `tearDown`: + stop called on unstarted patcher: null + def `test_wrong_input`: + STG1: null + def `test_input`: + STG1: null + STG2: null + def `test_normalize_data`: + Orange.preprocess.preprocess.Normalize: null + def `test_exaggeration_is_passed_through_properly`: + Orange.projection.manifold.TSNEModel.optimize: null + def `_check_exaggeration`: + exaggeration: null + def `test_modified_info_message_behaviour`: + The modified info message should be hidden by default: null + 'The modified info message should be hidden even after toggling ': null + options if no data is on input: null + 'The modified info message should be hidden after the widget ': null + computes the embedding: null + 'The modified info message should be hidden when reloading the ': null + same data set and no previous messages were shown: null + 'The modified info message should be shown when a setting is ': null + changed, but the embedding is not recomputed: null + housing: null + The information message was not cleared on new data: null + The information message was not cleared on no data: null + def `test_invalidation_flow`: + '#46befa': null + brush: null + '#000000': null + class `TestTSNERunner`: + def `setUpClass`: + iris: null + def `test_run`: + Computing PCA...: Računam PCA... + Preparing initialization...: Pripravljam začetno stanje... + Finding nearest neighbors...: Iščem najbližje sosede... + Running optimization...: Optimizacija teče... + __main__: null +widgets/utils/localization/tests/test_localization.py: + class `TestEn`: + def `test_pl`: + cat: null + cats: null + cat|cats: null + __main__: null +widgets/utils/save/tests/test_owsavebase.py: + class `SaveWidgetsTestBaseMixin`: + def `test_input_handler`: + Widget defines no inputs: null + widget has multiple inputs; input handler can't be tested: null + def `test_filters`: + Widget defines no filters: null + class `TestOWSaveBaseWithWriters`: + class `OWSaveMockWriter`: + Mock save: null + .csv: null + csv (*.csv): null + def `test_no_data_no_save`: + foo.tab: null + def `test_save_calls_writer`: + foo: null + def `test_base_methods`: + ~{os.sep}: null + def `assertPathEqual`: + win32: null + \\: null + /: null + def `test_open_moved_workflow`: + os.path.exists: null + /home/u/orange/a/b: null + /foo/bar: null + c.foo: null + Orange.widgets.widget.OWWidget.workflowEnv: null + a/b: null + /a/d: null + /foo/bar/c.foo: null + .: null + basedir: null + /home/u/orange/: null + /home/u/orange/a/b/c.foo: null + a/d: null + /home/u/orange/a/d: null + /home/u/orange/a/d/c.foo: null + /home/u/orange/c.foo: null + def `test_move_workflow`: + Orange.widgets.widget.OWWidget.workflowEnv: null + basedir: null + /home/u/orange/: null + /home/u/orange/a/b/c.foo: null + /home/u/orange/a/b: null + a/b/: null + c.foo: null + /tmp/u/work/: null + /tmp/u/work: null + /home/u/orange: null + /home/u/orange/a/b/: null + /tmp/u/work/a/b/c.foo: null + /tmp/u/work/a/b/: null + /home/u/orange/c.foo: null + .: null + def `test_migrate_pre_relative_settings`: + os.path.exists: null + /a/b: null + /a/b/c.foo: null + c.foo: null + def `test_save_button_label`: + c.foo: null + ' c.foo': null + def `test_invalid_filter`: + class `OWSaveNoWriter`: + Mock save: null + csv (*.csv): null + Unsupported format (*.foo): null + test.foo: null + /home/u/orange/a/b/c.csv: null + csv (*.csv): null + def `test_default_filter`: + class `OWSave`: + Mock save: null + csv (*.csv): null + txt (*.txt): null + csv (*.csv): null + class `TestOWSaveBase`: + def `setUp`: + class `OWSaveMockWriter`: + Mock save: null + csv (*.csv): null + def `test_no_data_no_save`: + foo.tab: null + def `test_base_methods`: + ~{os.sep}: null + def `test_default_filter`: + class `OWSave`: + Mock save: null + csv (*.csv): null + txt (*.txt): null + def `test_paths_win`: + win: null + windows path tests: null + class `OWSave`: + Mock save: null + csv (*.csv): null + txt (*.txt): null + C:/Temp: null + C:/Temp/abc.csv: null + C:/Temp/Project/abc.csv: null + C:/Temp/: null + c:\\Temp\\Project\\abc.csv: null + c:\\Temp: null + c:/Temp/Project\\abc.csv: null + basedir: null + C:/Folder/abc.csv: null + C:/Temp/Project: null + C:\\Temp\\Project: null + C:\\Temp\\abc.csv: null + def `test_paths_unix`: + class `OWSave`: + Mock save: null + csv (*.csv): null + txt (*.txt): null + /temp: null + /temp/abc.csv: null + /temp/project/abc.csv: null + /temp/: null + basedir: null + /folder/abc.csv: null + /temp/project: null + class `TestOWSaveUtils`: + def `test_replace_extension`: + class `OWMockSaveBase`: + Tab delimited (*.tab): null + Compressed tab delimited (*.gz.tab): null + Comma separated (*.csv): null + Compressed comma separated (*.csv.gz): null + Excel File (*.xlsx): null + /bing.bada.boom/foo.1942.tab: null + .tab: null + .tab.gz: null + /bing.bada.boom/foo.1942.tab.gz: null + .xlsx: null + /bing.bada.boom/foo.1942.xlsx: null + foo.tab.gz: null + foo.tab: null + .csv: null + foo.csv: null + .csv.gz: null + foo.csv.gz: null + /bing.bada.boom/foo: null + def `test_extension_from_filter`: + Description (*.ext): null + .ext: null + Description (*.foo.ba): null + .foo.ba: null + Description (.ext): null + Description (.foo.bar): null + .foo.bar: null + __main__: null +widgets/utils/tests/test_annotated_data.py: + class `TestAnnotatedData`: + def `setUp`: + zoo: null + def `test_cascade_annotated_tables`: + {} ({}): null + def `test_cascade_annotated_tables_with_missing_middle_feature`: + {ANNOTATED_DATA_FEATURE_NAME} (3): null + {} ({}): null + def `test_cascade_annotated_tables_with_missing_annotated_feature`: + {ANNOTATED_DATA_FEATURE_NAME} (3): null + {} ({}): null + def `test_create_groups_table_include_unselected`: + Selected: Izbrani podatki + Unselected: Neizbrani + G1: null + G2: null + def `test_create_groups_table_set_values`: + this: null + that: null + rest: null + Selected: Izbrani podatki +widgets/utils/tests/test_colorgradientselection.py: + class `TestColorGradientSelection`: + def `test_setModel`: + A: null + B: null + def `test_center_changed`: + 41: null +widgets/utils/tests/test_colorpalettes.py: + class `PaletteTest`: + def `test_copy`: + custom: null + c123: null + def `test_qcolors`: + custom: null + c123: null + class `IndexPaletteTest`: + def `setUp`: + custom: null + c123: null + class `PatchedVariableTest`: + def `test_colors`: + x: null + def `test_palette`: + x: null + def `test_exclusive`: + x: null + colors: null + palette: null + class `PatchedDiscreteVariableTest`: + def `test_colors`: + a: null + F: null + M: null + colors: null + '#000102': null + '#030405': null + x: null + A: null + B: null + '#0a0b0c': null + '#0d0e0f': null + foo: null + d: null + r: null + e: null + k: null + C: null + '#0D0E0F': null + v{i}: null + def `test_colors_fallback_to_palette`: + a: null + F: null + M: null + palette: null + {i}: null + def `test_colors_default`: + a: null + F: null + M: null + {i}: null + colors: null + foo: null + def `test_colors_no_values`: + a: null + def `test_get_palette`: + a: null + M: null + F: null + palette: null + dark: null + colors: null + '#0a0b0c': null + '#0d0e0f': null + def `test_ignore_malfformed_atrtibutes`: + a: null + M: null + F: null + colors: null + foo: null + bar: null + class `PatchedContinuousVariableTest`: + def `test_colors`: + a: null + colors: null + '#010203': null + '#040506': null + def `test_colors_from_palette`: + a: null + rainbow_bgyr_35_85_c73: null + palette: null + diverging_bwr_40_95_c42: null + def `test_palette`: + rainbow_bgyr_35_85_c73: null + a: null + palette: null + from_colors: null + colors: null + '#0a0b0c': null + '#0d0e0f': null + def `test_proxy_has_separate_colors`: + abc: null + __main__: null +widgets/utils/tests/test_combobox.py: + class `TestItemStyledComboBox`: + def `test_combobox`: + ...: null + 1: null + Windings: null + class `TestTextEditCombo`: + def `test_texteditcombo`: + !!: null + BB: null + AA: null + CC: null + AB: null + BC: null + BBA: null + BCA: null + def `test_activate_editing_finished_emit_ordering`: + def `activated`: + activated: null + def `finished`: + finished: null + AA: null + finished: null + activated: null +widgets/utils/tests/test_concurrent.py: + class `TestTask`: + def `setUp`: + ignore: null + `Task` has been deprecated: null + `submit_task` will be deprecated: null +widgets/utils/tests/test_concurrent_example.py: + class `TestOWConcurrentWidget`: + def `setUpClass`: + Data: null + def `test_button_no_data`: + Start: null + def `test_button_with_data`: + Stop: null + Start: null + def `test_button_toggle`: + Resume: null + def `test_plot_once`: + heart_disease: null + __main__: null +widgets/utils/tests/test_distmatrixmodel.py: + class `TestModel`: + def `test_header_data`: + abc: null + b: null + de: null + e: null + __main__: null +widgets/utils/tests/test_domaineditor.py: + class `MockWidget`: + mock: null + class `DomainEditorTest`: + def `setUp`: + d1: null + x, y, z, ...: null + d2: null + 1, 2, 3, ...: null + c1: null + d3: null + 4, 3, 6, ...: null + s: null + t: null + xyzw: null + 12345: null + 4368: null + def `test_deduplication`: + foo: null + d1: null + d2: null + c1: null + d3: null + s: null + t: null + d2 (1): null + d2 (2): null + s (1): null + s (2): null + skip: izpusti + __main__: null +widgets/utils/tests/test_graphicstextlist.py: + class `TestTextListWidget`: + def `test_setItems`: + Aa: null + Bb: null + def `test_orientation`: + x: null + def `test_alignment`: + a: null + def `test_tool_tips`: + A: null + class `TestUtils`: + def `test_scaled`: + scaled({size}, {const}): null + scaled({size}, {const}, Qt.KeepAspectRatioByExpanding): null +widgets/utils/tests/test_headerview.py: + class `TestHeaderView`: + def `test_header`: + A: null +widgets/utils/tests/test_itemmodels.py: + class `TestPyTableModel`: + def `test_data`: + 1: null + def `test_setHeaderLabels`: + Col 1: null + Col 2: null + class `TestVariableListModel`: + def `setUpClass`: + gender: null + M: null + F: null + age: null + name: null + birth: null + Foo: null + def `test_placeholder`: + None: Brez + Bar: null + def `test_displayrole`: + age: null + None: Brez + Foo: null + gender: null + name: null + birth: null + def `test_tooltip`: + age: null + Numeric: Številska + gender: null + M: null + F: null + 2: null + Categorical: Kategorična + name: null + Text: Besedilna + birth: null + Time: Časovna + foo: null + bar: null + def `test_other_roles`: + data: null + class `TestDomainModel`: + def `test_separators`: + abg: null + deh: null + ijf: null + foo: null + def `test_placeholder_placement`: + foo: null + bar: null + baz: null + def `test_subparts`: + abg: null + deh: null + ijf: null + def `test_filtering`: + abc: null + def: null + hidden: null + def `test_no_separators`: + abg: null + deh: null + ijf: null + def `test_read_only`: + abc: null + foo: null + class `TestContinuousPalettesModel`: + def `test_category_selection`: + Diverging: null + Linear: null + def `test_single_category`: + Diverging: null + def `test_data`: + Palettes: null + color_strip: null + def `test_select_flags`: + Palettes: null + def `testIndexOf`: + Palettes: null + class `TestPyListModelTooltip`: + def `test_tooltips_size`: + foo: null + bar: null + baz: null + footip: null + bartip: null + btip: null + def `test_tooltip_arg`: + ta: null + tb: null + foo: null + footip: null + class `TestTableModel`: + def `test_dense_data`: + postgres: null + mssql: null + def `test_local_dense_data`: + iris.tab: null + def `test_sparse_data`: + datasets/iris_basket.basket: null + sepal_length=1.5, sepal_width=5.3, petal_length=4.1, petal_width=2: null + Iris-setosa: null + __main__: null +widgets/utils/tests/test_owbasesql.py: + UN: null + PASS: null + class `BrokenBackend`: + def `__init__`: + Error connecting to DB.: null + class `TestableSqlWidget`: + SQL: null + def `get_table`: + iris: null + class `TestOWBaseSql`: + def `setUp`: + host: null + port: null + DB: null + database: null + schema: null + def `test_connect`: + host: null + port: null + database: null + user: null + password: null + Host: Strežnik + Port: Vrata + Database: Baza + User name: Uporabniško ime + __main__: null +widgets/utils/tests/test_owlearnerwidget.py: + class `TestOWBaseLearner`: + def `setUpClass`: + iris: null + def `test_error_on_learning`: + class `FailingLearner`: + def `__call__`: + boom: null + class `OWFailingLearner`: + foo: null + Data: null + def `test_subclasses_do_not_share_outputs`: + class `WidgetA`: + A: null + class `WidgetB`: + B: null + class `WidgetC`: + C: null + class `Outputs`: + test: null + test: null + def `test_send_backward_compatibility`: + class `WidgetA`: + A: null + Foo: null + Predictor: null + Bar: null + def `test_old_style_signals_on_subclass_backward_compatibility`: + class `WidgetA`: + A: null + set_data: null + inputs: null + outputs: null + A: null + def `test_persists_learner_name_in_settings`: + class `WidgetA`: + A: null + MyWidget: null + def `test_converts_sparse_targets_to_dense`: + class `WidgetLR`: + lr: null + def `test_invalid_number_of_targets`: + class `MockLearner`: + mock: null + classification: null + class `WidgetLR`: + lr: null + heart_disease: null + age: null + gender: null + chest pain: null + target: ciljne spremenljivke + def `test_default_name`: + class `TestLearner`: + Test: null + class `TestWidget`: + Test: null + Test: null + Foo: null + Bar: null + Frob: null + This is not a test: null + Blarg: null + def `test_preprocessor_warning`: + class `TestLearnerNoPreprocess`: + Test: null + class `TestWidgetNoPreprocess`: + Test: null + class `TestLearnerPreprocess`: + Test: null + class `TestWidgetPreprocess`: + Test: null + class `TestFitterPreprocess`: + Test: null + class `TestWidgetPreprocessFit`: + Test: null + def `test_multiple_sends`: + class `TestLearner`: + Test: null + class `TestWidget`: + Test: null + send: null +widgets/utils/tests/test_slidergraph.py: + class `SimpleWidget`: + Simple widget: null + def `__init__`: + label1: null + label2: null + class `TestSliderGraph`: + def `test_init`: + label1: null + bottom: null + label2: null + left: null + def `test_plot`: + label1: null + bottom: null + label2: null + left: null + def `test_plot_selection_limit`: + label1: null + bottom: null + label2: null + left: null +widgets/utils/tests/test_sql.py: + class `TestSQLDecorator`: + class `MockWidget`: + MockWidget: null + class `Inputs`: + Data: null + def `test_inputs_check_sql`: + Orange.widgets.utils.sql.Table: null + Orange.widgets.utils.state_summary.format_summary_details: null + __main__: null +widgets/utils/tests/test_state_summary.py: + VarDataPair: null + variable: null + data: null + continuous_full: null + continuous_missing: null + rgb_full: null + r: null + g: null + b: null + rgb_missing: null + ints_full: null + 2: null + 3: null + 4: null + ints_missing: null + time_full: null + time_missing: null + string_full: null + a: null + c: null + d: null + e: null + string_missing: null + class `TestUtils`: + def `test_details`: + zoo: null + 'zoo: {len(data)} instances, ': null + {n_features} variables\n: null + 'Features: {len(data.domain.attributes)} categorical ': null + (no missing values)\n: null + 'Target: categorical\n': null + 'Metas: string': null + housing: null + 'housing: {len(data)} instances, ': null + 'Features: {len(data.domain.attributes)} numeric ': null + 'Target: numeric': null + heart_disease: null + 'heart_disease: {len(data)} instances, ': null + 'Features: {len(data.domain.attributes)} ': null + (7 categorical, 6 numeric) (0.2% missing values)\n: null + 'Target: categorical': null + '{len(data)} instances, ': null + (10.0% missing values)\n: null + 'Target: {len(data.domain.class_vars)} categorical\n': null + 'Metas: {len(data.domain.metas)} categorical': null + (2 categorical, 1 numeric, 1 time) (5.0% missing values)\n: null + 'Target: {len(data.domain.class_vars)} ': null + (1 categorical, 1 numeric)\n: null + 'Metas: {len(data.domain.metas)} string': null + {len(data.domain.variables)} variables\n: null + 'Features: {len(data.domain.attributes)} time ': null + 'Features: {len(data.domain.variables)} categorical ': null + 'Target: —': null + {len(data.domain.variables)} variable\n: null + 'Features: categorical (no missing values)\n': null + '{len(data):n} instances, ': null + 'Features: {len(data.domain.variables)} numeric \n': null + get_nan_frequency_attribute: null + def `test_multiple_summaries`: + zoo: null + 'Data:
      zoo: {len(data)} instances, ': null + {n_features_data} variables
      : null + 'Features: {len(data.domain.attributes)} categorical ': null + (no missing values)
      : null + 'Target: categorical
      ': null + 'Metas: string
      ': null + 'Extra Data:
      zoo: {len(extra_data)} instances, ': null + {n_features_extra_data} variables
      : null + 'Features: {len(extra_data.domain.attributes)} ': null + categorical (no missing values)
      : null + 'Metas: string': null + Data: null + Extra Data: null + 'zoo: {len(data)} instances, ': null + 'zoo: {len(extra_data)} instances, ': null + No data on output.
      : null + 'Extra data:
      zoo: {len(extra_data)} instances, ': null + No data on output.: null + Extra data: null + output: null + __main__: null +widgets/utils/tests/test_textimport.py: + ,A,B,C: null + A: null + A, B, C, D\n: null + a, 1, 2, *\n: null + b, 2, 4, *: null + a\tb\n: null + class `WidgetsTests`: + def `test_options_widget`: + iso8859-1: null + a: null + b: null + c: null + delimiter-combo-box: null + custom-delimiter-edit: null + quote-edit-combo-box: null + __main__: null +widgets/visualize/tests/test_owbarplot.py: + class `TestOWBarPlot`: + def `setUpClass`: + Data: null + titanic: null + housing: null + heart_disease: null + def `test_input_to_many_instances`: + Orange.widgets.visualize.owbarplot.MAX_INSTANCES: null + def `test_init_attr_values`: + None: (Brez) + (Same color): (Enaka barva) + age: null + diameter narrowing: null + sepal length: null + iris: null + MEDV: null + def `test_group_axis`: + bottom: null + iris: null + def `test_plot_data_subset`: + brushes: null + def `test_saved_workflow`: + cholesterol: null + chest pain: null + gender: null + thal: null + def `test_sparse_data`: + iris: null + def `test_hidden_vars`: + iris: null + hidden: null + sepal width: null + def `test_visual_settings`: + Helvetica: null + Fonts: null + Font family: null + Title: null + Font size: null + Italic: null + Axis title: null + Axis ticks: null + tickFont: null + Legend: null + Annotations: null + Foo: null + Figure: null + Gridlines: null + Show: null + left: null + Bottom axis: null + Vertical ticks: null + bottom: null + rotateTicks: null + Group axis: null + def `assertSelectedIndices`: + pens: null + __main__: null +widgets/visualize/tests/test_owboxplot.py: + class `TestOWBoxPlot`: + def `setUpClass`: + iris: null + zoo: null + housing: null + titanic: null + heart_disease: null + Data: null + def `test_dont_show_hidden_attrs`: + iris: null + hidden: null + petal length: null + def `test_input_data_missings_disc_group_var`: + Data: null + def `test_input_data_missings_disc_no_group_var`: + cls: null + Data: null + def `test_apply_sorting_group`: + Data: null + sex: null + survived: null + age: null + status: null + thal: null + chest pain: null + major vessels colored: null + ST by exercise: null + max HR: null + exerc ind ang: null + slope peak exc ST: null + gender: null + rest ECG: null + rest SBP: null + cholesterol: null + fasting blood sugar > 120: null + diameter narrowing: null + def `test_apply_sorting_vars`: + Data: null + None: Brez + sex: null + survived: null + age: null + status: null + thal: null + chest pain: null + exerc ind ang: null + slope peak exc ST: null + gender: null + rest ECG: null + fasting blood sugar > 120: null + diameter narrowing: null + def `test_continuous_metas`: + str: null + def `test_label_overlap`: + chest pain: null + gender: null + def `test_empty_groups`: + datasets/cyber-security-breaches.tab: null + US State: null + def `test_sorting_disc_group_var`: + heart_disease: null + gender: null + chest pain: null + def `test_unconditional_commit_on_new_signal`: + commit: null + def `test_stretching`: + chest pain: null + gender: null + def `test_value_all_missing_for_group`: + a: null + v1: null + v2: null + v3: null + b: null + v4: null + def `test_valid_data_range`: + petal width: null + iris: null + __main__: null +widgets/visualize/tests/test_owdistributions.py: + class `TestOWDistributions`: + def `setUp`: + iris: null + def `test_histogram_data`: + sepal length: null + iris: null + def `test_switch_cvar`: + foo: null + a: null + b: null + CI: null + def `test_disable_hide_bars`: + petal length: null + iris: null + def `test_hide_bars`: + petal length: null + iris: null + brush: null + def `test_sort_by_freq_no_split`: + heart_disease: null + gender: null + female: null + male: null + def `test_sort_by_freq_split`: + heart_disease: null + gender: null + rest ECG: null + female: null + normal: null + male: null + left vent hypertrophy: null + __main__: null +widgets/visualize/tests/test_owfreeviz.py: + class `TestOWFreeViz`: + def `setUpClass`: + Data: null + heart_disease: null + def `test_number_of_targets`: + age: null + gender: null + chest pain: null + def `test_optimization`: + Stop: Stoj + def `test_optimization_cancelled`: + Resume: Nadaljuj + def `test_optimization_reset`: + Stop: Stoj + def `test_optimization_finish`: + Stop: Stoj + Start: Začni + def `test_optimization_no_data`: + Start: Začni + def `test_constant_data`: + titanic: null + def `test_output_components`: + component: null + freeviz-x: null + freeviz-y: null + def `test_discrete_attributes`: + zoo: null + class `TestOWFreeVizRunner`: + def `setUpClass`: + iris: null + def `test_run`: + Calculating...: Računam... + __main__: null +widgets/visualize/tests/test_owheatmap.py: + class `TestOWHeatMap`: + def `setUpClass`: + Data: null + def `setUp`: + housing: null + titanic: null + brown-selected: null + def `test_information_message`: + heart_disease.tab: null + def `test_not_enough_data_settings_changed`: + ignore: null + Number of distinct clusters: null + def `test_cluster_column_on_all_zero_column`: + iris: null + def `test_empty_clusters`: + y: null + ignore: null + Number of distinct clusters: null + def `test_use_enough_colors`: + y: null + def `test_cls_with_single_instance`: + c1: null + c2: null + a: null + b: null + def `test_unconditional_commit_on_new_signal`: + now: null + def `test_saved_selection`: + iris: null + def `test_saved_selection_when_not_possible`: + iris: null + petal width: null + __version__: null + col_clustering_method: null + Clustering: null + selected_rows: null + def `_brown_selected_10`: + diau g: null + def `test_set_split_column_key`: + function: null + def `test_set_split_column_key_missing`: + function: null + def `test_palette_centering`: + y: null + center_palette: null + def `test_centering_threshold_change`: + y: null + diverging_bwr_40_95_c42: null + def `test_migrate_settings_v3`: + row_clustering: null + col_clustering: null + def `test_row_color_annotations`: + function: null + def `test_row_color_annotations_with_na`: + function: null + diau g: null + def `test_col_color_annotations`: + function: null + diau g: null + def `test_col_color_annotations_with_na`: + function: null + diau g: null + def `test_data_with_hidden`: + hidden: null + __main__: null +widgets/visualize/tests/test_owlinearprojection.py: + class `TestOWLinearProjection`: + def `setUpClass`: + Data: null + def `test_bad_data`: + iris: null + class: null + a: null + def `test_no_data_for_lda`: + housing: null + def `test_data_no_cont_features`: + titanic: null + def `test_invalid_data`: + iris: null + def `test_migrate_settings_from_version_1`: + __version__: null + alpha_value: null + auto_commit: null + class_density: null + context_settings: null + iris: null + petal length: null + petal width: null + sepal length: null + sepal width: null + color_index: null + shape_index: null + size_index: null + variable_state: null + jitter_value: null + legend_anchor: null + point_size: null + savedWidgetGeometry: null + def `test_two_classes_dataset`: + heart_disease: null + def `test_unique_name`: + iris: null + C-y: null + C-x (1): null + C-y (1): null + Selected: Izbrani podatki + class `LinProjVizRankTests`: + def `setUpClass`: + iris: null + def `test_continuous_class`: + housing: null + __main__: null +widgets/visualize/tests/test_owlineplot.py: + class `TestOWLinePLot`: + def `setUpClass`: + Data: null + def `setUp`: + titanic: null + housing: null + def `test_select_lines_enabled`: + Orange.widgets.visualize.owlineplot.SEL_MAX_INSTANCES: null + def `test_max_features`: + Orange.widgets.visualize.owlineplot.MAX_FEATURES: null + def `test_group_view`: + None: (Brez skupin) + def `test_plot_subset`: + show_range: null + def `test_plot_only_mean`: + show_range: null + def `test_sparse_data`: + iris: null + def `test_unconditional_commit_on_new_signal`: + now: null + def `test_visual_settings`: + Helvetica: null + Fonts: null + Font family: null + Title: null + Font size: null + Italic: null + Axis title: null + bottom: null + left: null + Axis ticks: null + tickFont: null + Legend: null + Annotations: null + Foo: null + x-axis title: null + Foo2: null + y-axis title: null + Foo3: null + Figure: null + Lines (missing value): null + Width: null + pen: null + Selected lines (missing value): null + __main__: null +widgets/visualize/tests/test_owmosaic.py: + class `TestOWMosaicDisplay`: + def `setUpClass`: + Data: null + def `test_continuous_metas`: + c1: null + m: null + def `test_string_meta`: + m: null + meta: null + def `test_missing_values`: + c1: null + a: null + b: null + c: null + cls: null + def `test_keyerror`: + iris: null + def `test_combos_and_mosaic`: + iris: null + def `test_different_number_of_attributes`: + Orange.widgets.visualize.owmosaic.CanvasRectangle: null + Orange.widgets.visualize.owmosaic.QGraphicsItemGroup.addToGroup: null + 01: null + abcd: null + {:04b}: null + variable: null + def `test_vizrank_receives_manual_change`: + Orange.widgets.visualize.owmosaic.MosaicVizRank.on_manual_change: null + iris.tab: null + def `test_selection_setting`: + iris.tab: null + titanic: null + class `MosaicVizRankTests`: + def `setUpClass`: + iris.tab: null + def `test_row_for_state`: + abcd: null + a, b, d: null + def `test_does_not_crash_cont_class`: + housing.tab: null + def `test_pause_continue`: + housing.tab: null + def `test_finished`: + iris.tab: null + def `test_max_attr_combo_1_disabling`: + iris.tab: null + def `test_attr_range`: + iris.tab: null + failed at max_attrs={vizrank.max_attrs}: null + def `test_nan_column`: + a: null + b: null + c: null + def `test_color_combo`: + titanic: null + (Pearson residuals): (Pearsonovi residuali) + Data: null + def `test_scores`: + status: null + sex: null + age: null + titanic: null + def `test_subset_data`: + titanic: null + housing: null + def `test_incompatible_subset`: + titanic: null + def `test_on_manual_change`: + iris.tab: null + __main__: null +widgets/visualize/tests/test_ownomogram.py: + class `TestOWNomogram`: + def `setUpClass`: + heart_disease: null + titanic: null + datasets/lenses.tab: null + def `test_nomogram_lr_multiclass`: + ovr: null + liblinear: null + def `test_nomogram_with_instance_nb`: + status: null + age: null + sex: null + def `test_nomogram_with_instance_lr`: + status: null + age: null + sex: null + def `test_constant_feature_disc`: + d1: null + a: null + c: null + d2: null + b: null + cls: null + e: null + d: null + def `test_constant_feature_cont`: + d: null + a: null + b: null + c: null + cls: null + def `_test_helper_check_probability`: + 'Probability: {}': Verjetnost: {} + def `_check_values`: + '{}: 100%': null + 'Value: {}': null + def `test_tooltip`: + Some text.: null + def `test_adjust_scale`: + Orange.widgets.visualize.ownomogram.QGraphicsTextItem: null + var1: null + foo1: null + foo2: null + var2: null + foo3: null + foo4: null + def `test_reconstruct_domain`: + heart_disease: null + def `test_missing_class_value`: + iris: null + def `test_compute_value`: + iris: null + __main__: null +widgets/visualize/tests/test_owprojectionwidget.py: + class `TestOWProjectionWidget`: + def `test_get_column`: + cont: null + disc: null + abcdefghijklmno: null + disc2: null + abc: null + disc3: null + string: null + foo: null + bar: null + baz: null + def `test_get_column_merge_infrequent`: + disc: null + abcdefghijklmno: null + disc2: null + abc: null + Other: Drugo + def `test_get_tooltip`: + v: null + 3: null + 1: null + def `test_get_palette`: + v: null + abc: null + abcdefghijklmn: null + a: null + c: null + d: null + h: null + n: null + Others: null + foo: null + bar: null + class `TestOWDataProjectionWidget`: + def `setUpClass`: + Data: null + def `test_annotation_with_nans`: + Selected: Izbrani podatki + def `test_invalid_subset`: + iris: null + titanic: null + def `test_sparse_data_reload`: + heart_disease: null + def `test_unconditional_commit_on_new_signal`: + now: null + def `test_model_update`: + iris: null + diverging_tritanopic_cwr_75_98_c20: null + __main__: null +widgets/visualize/tests/test_owpythagorastree.py: + class `TestOWPythagorasTree`: + def `setUpClass`: + Tree: null + titanic: null + housing: null + def `test_changing_target_class_changes_node_coloring`: + def `_test`: + Colors did not change for %s data: null + classification: null + regression: null + def `test_log_scale_slider`: + Should be disabled with no tree: null + Normal: Sorazmerna + Should be disabled when no size adjustment: null + Square root: Kvadratni koren + Should be disabled when square root size adjustment: null + Logarithmic: Logaritmična + Should be enabled when square root size adjustment: null + Squares are drawn in same positions after changing log factor: null + def `test_checking_legend_checkbox_shows_and_hides_legend`: + Hiding legend failed: null + Showing legend failed: null + def `test_checking_tooltip_shows_and_hides_tooltips`: + Hiding tooltips failed: null + Showing tooltips failed: null + def `test_changing_max_depth_slider`: + Full tree should be drawn initially: null + Lowering tree depth limit did not hide squares: null + Increasing tree depth limit did not show squares: null + def `test_label_on_tree_connect_and_disconnect`: + Nodes:(.+)\s*Depth:(.+): Število vozlišč:(.+), globina:(.+) + Initial info should not contain node or depth info: null + Valid tree does not update info: null + def `test_tree_determinism`: + 'The tree was not drawn identically in the %d times it was ': null + sent to widget after receiving the iris dataset.: null + tests: null + datasets: null + same_entropy.tab: null + 'sent to widget after receiving a dataset with variables with ': null + same entropy.: null + def `test_forest_tree_table`: + titanic: null + housing: null + def `test_changing_data_restores_depth_from_previous_settings`: + titanic: null + def `test_context`: + iris: null + __main__: null +widgets/visualize/tests/test_owpythagoreanforest.py: + class `TestOWPythagoreanForest`: + def `setUpClass`: + titanic: null + housing: null + def `test_migrate_version_1_settings`: + zoom: null + version: null + def `test_sending_rf_draws_trees`: + No trees should be drawn when no forest on input: null + Incorrect number of trees when forest on input: null + Trees are cleared when forest is disconnected: null + def `test_info_label`: + Trees:(.+): Drevesa:(.+) + Initial info should not contain info on trees: null + Valid RF does not update info: null + Removing RF does not clear info box: null + def `_get_first_tree`: + Empty list of tree widgets: null + def `test_changing_target_class_changes_coloring`: + def `_test`: + Colors did not change for %s data: null + classification: null + regression: null + def `test_context`: + iris: null + __main__: null +widgets/visualize/tests/test_owradviz.py: + class `TestOWRadviz`: + def `setUpClass`: + Data: null + heart_disease: null + def `test_output_components`: + component: null + radviz-x: null + radviz-y: null + angle: null + def `test_discrete_attributes`: + zoo: null + __main__: null +widgets/visualize/tests/test_owruleviewer.py: + class `TestOWRuleViewer`: + def `setUpClass`: + titanic: null + Classifier: null +widgets/visualize/tests/test_owscatterplot.py: + class `TestOWScatterPlot`: + def `setUpClass`: + Data: null + def `test_score_heuristics`: + abcd: null + e: null + ab: null + def `test_data_column_nans`: + b: null + a: null + def `test_data_column_infs`: + b: null + def `test_group_selections`: + No: Ne + Yes: Da + def `test_saving_selection`: + selection: null + def `test_points_selection`: + selection_group: null + titanic: null + def `test_migrate_selection`: + selection_group: null + def `test_invalid_points_selection`: + selection_group: null + def `test_set_strings_settings`: + context_settings: null + attr_label: null + sepal length: null + attr_color: null + sepal width: null + attr_shape: null + iris: null + attr_size: null + petal width: null + def `test_features_and_no_data`: + iris: null + def `test_output_features`: + iris: null + def `test_vizrank`: + iris: null + housing: null + def `test_vizrank_class_nan`: + iris: null + def `test_vizrank_nonprimitives`: + zoo: null + Orange.widgets.visualize.owscatterplot.ReliefF: null + def `test_vizrank_enabled_no_data`: + No data on input: Ni podatkov na vhodu. + def `test_vizrank_enabled_sparse_data`: + Data is sparse: Podatki so redki. + def `test_vizrank_enabled_constant_data`: + c1: null + c2: null + c3: null + c4: null + cls: null + a: null + b: null + def `test_vizrank_enabled_two_features`: + Not enough features for ranking: Ni dovolj spremenljivk za rangiranje, + def `test_vizrank_enabled_no_color_var`: + Color variable is not selected: Barva ni določena, + def `test_vizrank_enabled_color_var_nans`: + c1: null + c2: null + c3: null + c4: null + cls: null + a: null + b: null + Color variable has no values: Spremenljivka z barvo nima znanih vrednosti. + def `test_auto_send_selection`: + iris: null + def `test_color_is_optional`: + zoo: null + backbone: null + breathes: null + airborne: null + type: null + def `test_handle_metas`: + iris: null + def `test_subset_data`: + iris: null + def `test_opacity_warning`: + iris: null + def `test_metas_zero_column`: + iris: null + def `test_tooltip`: + heart_disease: null + chest pain: null + cholesterol: null + mapFromScene: null + showText: null + pointsAt: null + age = {}: null + age: null + gender = {}: null + gender: null + max HR = {}: null + max HR: null + others: null + ... and 4 others: ... in še 4 + def `test_many_discrete_values`: + def `prepare_data`: + iris: null + iris5: null + def `test_vizrank_receives_manual_change`: + Orange.widgets.visualize.owscatterplot.ScatterPlotVizRank.: null + on_manual_change: null + iris.tab: null + def `test_on_manual_change`: + iris.tab: null + def `test_update_regression_line_calls_add_line`: + '#505050': null + def `test_time_axis`: + time: null + value: null + bottom: null + axis should display floats: null + def `test_visual_settings`: + Helvetica: null + Fonts: null + Axis title: null + Font size: null + Italic: null + Axis ticks: null + tickFont: null + Line label: null + Figure: null + Lines: null + Width: null + __main__: null +widgets/visualize/tests/test_owscatterplotbase.py: + class `MockWidget`: + Mock: null + class `TestOWScatterPlotBase`: + def `test_update_coordinates`: + data: null + size: null + symbol: null + pen: null + brush: null + def `test_update_coordinates_and_labels`: + a: null + b: null + def `test_update_coordinates_and_density`: + a: null + b: null + def `test_update_coordinates_reset_view`: + a: null + b: null + def `test_update_coordinates_indices`: + data: null + def `test_sampling`: + size: null + symbol: null + pen: null + get_label_data: null + Orange.widgets.visualize.owscatterplotgraph.OWScatterPlotBase.: null + def `test_reset_calls_all_updates_and_update_doesnt`: + update_sizes: null + update_colors: null + update_selection_colors: null + update_shapes: null + update_labels: null + def `test_size_normalization`: + size: null + def `test_size_rounding_half_pixel`: + size: null + def `test_size_with_nans`: + size: null + def `test_sizes_all_same_or_nan`: + size: null + def `test_sizes_point_width_is_linear`: + size: null + def `test_sizes_custom_imputation`: + size: null + def `test_sizes_selection`: + size: null + def `test_size_animation`: + Orange.widgets.visualize.owscatterplotgraph: null + .MAX_N_VALID_SIZE_ANIMATE: null + def `test_colors_discrete`: + pen: null + brush: null + def `test_colors_discrete_nan`: + pen: null + brush: null + def `test_colors_continuous_reused`: + pen: null + brush: null + def `test_colors_continuous_nan`: + pen: null + brush: null + def `test_colors_subset`: + def `run_tests`: + brush: null + def `test_colors_none`: + pen: null + brush: null + def `test_selection_colors`: + pen: null + AnyQt.QtWidgets.QApplication.keyboardModifiers: null + def `test_z_values`: + def `check_ranks`: + error at pair ({j}, {i}): null + setZ: null + def `test_z_values_with_sample`: + def `check_ranks`: + error at pair ({j}, {i}): null + setZ: null + def `test_density`: + Orange.widgets.utils.classdensity.class_density_image: null + def `test_density_with_missing`: + Orange.widgets.utils.classdensity.class_density_image: null + def `test_density_with_max_colors`: + Orange.widgets.visualize.owscatterplotgraph.MAX_COLORS: null + Orange.widgets.utils.classdensity.class_density_image: null + def `test_labels_observes_mask`: + 1: null + 2: null + def `test_shapes`: + symbol: null + def `test_shapes_nan`: + symbol: null + ?: null + def `test_show_legend`: + a: null + b: null + c: null + d: null + error at {}, {}: null + def `test_show_legend_no_data`: + a: null + b: null + c: null + d: null + def `test_legend_combine`: + a: null + b: null + c: null + d: null + def `test_select_by_click`: + AnyQt.QtWidgets.QApplication.keyboardModifiers: null + def `test_select_by_indices`: + def `select`: + AnyQt.QtWidgets.QApplication.keyboardModifiers: null + def `test_no_needless_buildatlas`: + atlas: null + class `TestScatterPlotItem`: + def `test_paint_mapping`: + pyqtgraph.ScatterPlotItem.paint: null + def `test_paint_mapping_exception`: + pyqtgraph.ScatterPlotItem.paint: null + def `test_paint_mapping_integration`: + pyqtgraph.ScatterPlotItem.paint: null + __main__: null +widgets/visualize/tests/test_owsieve.py: + class `TestOWSieveDiagram`: + def `setUpClass`: + Data: null + titanic: null + iris: null + def `test_missing_values`: + c1: null + a: null + b: null + c: null + cls: null + def `test_chisquare`: + a: null + y: null + n: null + b: null + o: null + yynny: null + ynyyn: null + def `test_metadata`: + a: null + b: null + y: null + n: null + yynn: null + Orange.widgets.visualize.owsieve.Discretize: null + def `test_sparse_data`: + Data: null + def `test_vizrank_receives_manual_change`: + Orange.widgets.visualize.owsieve.SieveRank.on_manual_change: null + iris.tab: null + __main__: null +widgets/visualize/tests/test_owsilhouetteplot.py: + class `TestOWSilhouettePlot`: + def `setUpClass`: + Data: null + Silhouette ({}): Silhueta ({}) + def `setUp`: + auto_commit: null + def `test_nan_distances`: + Cosine: Kosinusna + def `test_ignore_categorical`: + heart_disease: null + Cosine: Kosinusna + def `test_meta_object_dtype`: + iris: null + S: null + def `test_memory_error`: + iris: null + numpy.asarray: null + def `test_bad_data_range`: + a: null + b: null + c: null + d: null + y: null + n: null + nyy: null + def `test_saved_selection`: + iris: null + def `test_distance_input`: + heart_disease: null + def `test_no_group_var`: + iris: null + def `test_unique_output_domain`: + Silhouette (iris): Silhueta (iris) + Silhouette (iris) (1): Silhueta (iris) (1) + def `test_report`: + zoo: null + nnotated: značen + def `test_migration`: + foo: null + bar: null + baz: null + bax: null + cfoo: null + mbaz: null + cluster_var_idx: null + annotation_var_idx: null + cluster_var: null + annotation_var: null + __main__: null +widgets/visualize/tests/test_owtreegraph.py: + class `TestOWTreeGraph`: + def `setUpClass`: + Tree: null + tests: null + datasets: null + same_entropy.tab: null + aaa: null + e: null + f: null + g: null + bbb: null + ijkl: null + ccc: null + y: null + def `test_tree_determinism`: + 'The tree was not drawn identically in the %d times it was ': null + sent to widget after receiving the iris dataset.: null + 'sent to widget after receiving a dataset with variables with ': null + same entropy.: null + def `test_update_node_info`: + foo: null + bar
      ban: null + bar: null + bar
      ban
      foo: null + def `test_tree_labels`: + 42.0 ± 8.0: null + 50 instances: 50 primerov + aaa: null + 38.0 ± 5.0: null + 16 instances: 16 primerov + bbb: null + 13.0 ± 3.0: null + 14 instances: 14 primerov + 78.0 ± 12.0: null + 20 instances: 20 primerov + ccc: null + __main__: null +widgets/visualize/tests/test_owvenndiagram.py: + class `TestOWVennDiagram`: + def `test_rows_id`: + zoo: null + hair: null + feathers (1): null + feathers (2): null + eggs: null + milk: null + airborne: null + aquatic: null + predator: null + toothed: null + backbone: null + breathes: null + venomous: null + fins: null + legs: null + tail: null + domestic: null + catsize: null + def `test_disable_duplicates`: + zoo: null + def `test_disable_match_equality`: + zoo: null + def `test_multiple_input_over_cols`: + Selected: null + Data: null + sepal length (2): null + sepal length (1): null + def `test_unconditional_commit_on_new_signal`: + now: null + def `test_rows_identifiers`: + zoo: null + Data: null + def `test_migration_to_3`: + selected_feature: null + __main__: null +widgets/visualize/tests/test_owviolinplot.py: + class `TestOWViolinPlot`: + def `setUpClass`: + Data: null + housing: null + def `test_no_cont_features`: + zoo: null + def `test_enable_controls`: + None: (Brez skupin) + def `test_show_grid_sets_show_grid`: + Orange.widgets.visualize.owviolinplot.ViolinPlot.set_show_grid: null + def `test_show_grid_orientation`: + bottom: null + left: null + def `test_unique_values`: + petal width: null + def `test_select`: + mapToView: null + def `test_selection_rect`: + mapToView: null + def `test_selection_sort_violins`: + sepal width: null + def `test_saved_selection`: + AnyQt.QtWidgets.QApplication.keyboardModifiers: null + def `test_visual_settings`: + def `test_settings`: + Helvetica: null + tickFont: null + Foo: null + rotateTicks: null + Fonts: null + Font family: null + Helvetica: null + Title: null + Font size: null + Italic: null + Axis title: null + Axis ticks: null + Annotations: null + Foo: null + Figure: null + Bottom axis: null + Vertical tick text: null + __main__: null +widgets/visualize/tests/test_vizrankdialog.py: + class `TestRunner`: + def `setUpClass`: + iris: null + __main__: null +widgets/visualize/utils/tests/test_customizableplot.py: + class `TestFonts`: + def `test_available_font_families`: + QFont: null + QFontDatabase: null + mock regular: null + a: null + .d: null + e: null + .b: null + c: null + mock bold: null + mock italic: null + mock semi: null + __main__: null +widgets/visualize/utils/tests/test_heatmap.py: + class `TestHeatmapGridWidget`: + 0-0: null + 1-0: null + 0-1: null + a: null + 1-1: null + b: null + 2-2-split: null + 2-2-cl: null + 2-2: null + def `test_widget_annotations`: + 2-2: null + 1: null + 2: null + a: null + b: null + def `test_selection`: + 2-2: null + def `test_colormap`: + 2-2: null + class `TestCategoricalColorLegend`: + def `test_font_propagation`: + a: null + b: null + Title: null + Windings: null +widgets/visualize/utils/tests/test_plotutils.py: + class `TestInteractiveViewBox`: + def `test_update_scale_box`: + mapToView: null + __main__: null +widgets/visualize/utils/tree/tests/test_rules.py: + class `TestRules`: + def `test_merging_two_gt_continuous_rules`: + Rule: null + def `test_merging_gt_with_gte_continuous_rule`: + Rule: null + def `test_merging_two_lt_continuous_rules`: + Rule: null + def `test_merging_lt_with_lte_rule`: + Rule: null + def `test_merging_lt_with_gt_continuous_rules`: + Rule: null + def `test_merging_interval_rule_with_smaller_continuous_rule`: + Rule: null + def `test_merging_interval_rule_with_larger_continuous_rule`: + Rule: null + def `test_merging_interval_rule_with_larger_lt_continuous_rule`: + Rule: null + def `test_merging_interval_rule_with_smaller_gt_continuous_rule`: + Rule: null + def `test_merging_interval_rules_with_smaller_lt_component`: + Rule: null + def `test_merging_interval_rules_with_larger_lt_component`: + Rule: null + def `test_merging_interval_rules_generally`: + Rule: null + def `test_merge_commutativity_on_continuous_rules`: + Rule1: null + def `test_merge_commutativity_on_interval_rules`: + Rule: null + def `test_merge_keeps_gt_on_continuous_rules`: + Rule1: null + def `test_merge_keeps_attr_name_on_continuous_rules`: + Rule1: null +widgets/visualize/utils/tree/tests/test_treeadapter.py: + class `TestTreeAdapter`: + def `setUp`: + v1: null + v2: null + abc: null + v3: null + def: null + y: null + def `test_adapter`: + v1 > 13: null diff --git a/i18n/trans.sh b/i18n/trans.sh new file mode 100755 index 00000000000..42fba294866 --- /dev/null +++ b/i18n/trans.sh @@ -0,0 +1,8 @@ +if [ "$#" -ne 1 ] +then + echo "trans " + exit +else + dest=$1 + trubar --conf trubar-config.yaml translate -s ../Orange -d $dest/Orange msgs.jaml +fi diff --git a/i18n/trans1.sh b/i18n/trans1.sh new file mode 100755 index 00000000000..26750b17e16 --- /dev/null +++ b/i18n/trans1.sh @@ -0,0 +1,10 @@ +if [ "$#" -ne 2 ] +then + echo "trans " + exit +else + lang=$1 + dest=$2 + trubar --conf $lang/trubar-config.yaml translate -s ../Orange -d $dest/Orange --static $lang/static $lang/msgs.jaml + trubar --conf $lang/tests-config.yaml translate -s ../Orange -d $dest/Orange $lang/tests-msgs.jaml +fi diff --git a/i18n/trubar-config.yaml b/i18n/trubar-config.yaml new file mode 100644 index 00000000000..46595f963a9 --- /dev/null +++ b/i18n/trubar-config.yaml @@ -0,0 +1,13 @@ +languages: + en: + name: English + original: true + si: + name: Slovenščina + international-name: Slovenian + auto-import: from orangecanvas.localization.si import plsi, plsi_sz, z_besedo # pylint: disable=wrong-import-order +auto-import: |2 + from orangecanvas.localization import Translator # pylint: disable=wrong-import-order + _tr = Translator("Orange", "biolab.si", "Orange") + del Translator +encoding: "utf-8" \ No newline at end of file diff --git a/pylintrc b/pylintrc index c678600595f..15cb68e34ea 100644 --- a/pylintrc +++ b/pylintrc @@ -10,13 +10,15 @@ # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS +# Ignore unittests +ignore-patterns=^test_.*?py # Pickle collected data for later comparisons. persistent=yes # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. -load-plugins= +load-plugins=pylint.extensions.eq_without_hash # Use multiple processes to speed up Pylint. jobs=0 @@ -30,15 +32,6 @@ unsafe-load-any-extension=no # run arbitrary code extension-pkg-whitelist=Orange.distance._distance,Orange.data._variable,numpy.random.mtrand -# Allow optimization of some AST trees. This will activate a peephole AST -# optimizer, which will apply various small optimizations. For instance, it can -# be used to obtain the result of joining multiple strings with the addition -# operator. Joining a lot of strings can lead to a maximum recursion error in -# Pylint and this flag can prevent that. It has one side effect, the resulting -# AST will be different than the one from reality. -optimize-ast=no - - [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show @@ -64,7 +57,9 @@ disable= no-name-in-module, no-member, # too many false positives from Qt import-error, # false positives, and we don't expect any true positives too-many-ancestors, # I don't think this is a problem - no-else-return # else's may actually improve readability + no-else-return, # else's may actually improve readability + duplicate-code, # too strict; we're disciplined and don't do this by mistake + too-many-positional-arguments # controversial check with no good solution [REPORTS] @@ -74,11 +69,6 @@ disable= # mypackage.mymodule.MyReporterClass. output-format=text -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - # Tells whether to display a full report or only the messages reports=yes @@ -106,12 +96,6 @@ ignore-long-lines=^\s*(# )??$ # else. single-line-if-stmt=yes -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator - # Maximum number of lines in a module max-module-lines=1000 @@ -162,7 +146,7 @@ ignore-comments=yes ignore-docstrings=yes # Ignore imports when computing similarities. -ignore-imports=no +ignore-imports=yes [VARIABLES] @@ -182,11 +166,11 @@ additional-builtins= # name must start or end with one of those strings. callbacks=cb_,_cb,on_,_on_ +# Rarely used builtins but frequent parameter names +allowed-redefined-builtins=id,format,input -[BASIC] -# List of builtins function names that should not be used, separated by a comma -bad-functions=print +[BASIC] # Good variable names which should always be accepted, separated by a comma good-names=ex,Run,_,a,c,d,e,i,j,k,m,n,o,p,t,u,v,w,x,y,A,X,Y,M @@ -204,53 +188,33 @@ include-naming-hint=yes # Regular expression matching correct method names # TODO: find a better way to allow long method names in test classes. method-rgx=[a-z_][a-zA-Z0-9_]{2,80}$ -# Naming hint for method names -method-name-hint=[a-z_][a-zA-Z0-9_]{2,30}$ # Regular expression matching correct class names class-rgx=[A-Z_][a-zA-Z0-9]{2,30}$ -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]{2,30}$ # Regular expression matching correct module names module-rgx=([a-z_][a-z0-9_]{2,30})$ -# Naming hint for module names -module-name-hint=([a-z_][a-z0-9_]{2,30})$ # Regular expression matching correct class attribute names class-attribute-rgx=[A-Za-z_][A-Za-z0-9_]{2,30}$ -# Naming hint for class attribute names -class-attribute-name-hint=[A-Za-z_][A-Za-z0-9_]{2,30}$ # Regular expression matching correct constant names const-rgx=[A-Za-z_][A-Za-z0-9_]+$ -# Naming hint for constant names -const-name-hint=[A-Za-z_][A-Za-z0-9_]+$ # Regular expression matching correct function names function-rgx=[a-z_][a-zA-Z0-9_]{2,30}$ -# Naming hint for function names -function-name-hint=[a-z_][a-zA-Z0-9_]{2,30}$ # Regular expression matching correct variable names variable-rgx=[a-zA-Z_][a-zA-Z0-9_]{0,30}$ -# Naming hint for variable names -variable-name-hint=[a-zA-Z_][a-zA-Z0-9_]{0,30}$ # Regular expression matching correct attribute names attr-rgx=[a-z_][a-zA-Z0-9_]*$ -# Naming hint for attribute names -attr-name-hint=[a-z_][a-zA-Z0-9_]{2,30}$ # Regular expression matching correct inline iteration names inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ # Regular expression matching correct argument names argument-rgx=[a-z_][a-zA-Z0-9_]*$ -# Naming hint for argument names -argument-name-hint=[a-z_][a-zA-Z0-9_]{2,30}$ # Regular expression which should only match function or class names that do # not require a docstring. @@ -271,7 +235,7 @@ max-nested-blocks=5 # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes +ignored-checks-for-mixins=no-member # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime @@ -371,7 +335,7 @@ int-import-graph= # Exceptions that will emit a warning when being caught. Defaults to # "Exception" -overgeneral-exceptions=Exception +overgeneral-exceptions=builtins.Exception [isort] -known-standard-library=pkg_resources +known-standard-library= diff --git a/pyproject.toml b/pyproject.toml index d78fb71c1da..0fd2b2891eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,33 @@ [build-system] requires = [ - "setuptools>=40.8.0,<50.0", - "wheel", - "cython", - "oldest-supported-numpy", - "sphinx", + "cython>=3.0", + "numpy>=2.0", "recommonmark", + "setuptools>=51.0", + "sphinx>=4.2.0,<8", + "sphinx-multiproject", + "myst-parser", + "wheel", + "trubar>=0.3.4" ] build-backend = "setuptools.build_meta" + +[tool.cibuildwheel] +# Restrict the set of builds to mirror the wheels available in Orange3. +skip = ["cp36-*", "cp37-*", "cp38-*", "cp39-*", "pp*", "*-musllinux_*"] +build-verbosity = 2 + +[tool.cibuildwheel.linux] +archs = ["x86_64", "aarch64"] + +[tool.cibuildwheel.windows] +archs = ["AMD64"] + +[tool.cibuildwheel.macos] +# https://cibuildwheel.readthedocs.io/en/stable/faq/#what-to-provide suggests to provide +# x86_64 and one of universal2 or amr64 wheels, since unviversal pack what is already in +# x86_64, currently universal2 is not built but arm64 is built instead +# x86_64 is still reuqired because of older pips, when remcomendation changes we can +# build only universal2 +archs = ["x86_64", "arm64"] diff --git a/requirements-core.txt b/requirements-core.txt index 525cacf7ed7..eb52c628c69 100644 --- a/requirements-core.txt +++ b/requirements-core.txt @@ -1,26 +1,27 @@ -pip>=9.0 -numpy>=1.16.0 -scipy>=0.16.1 -scikit-learn>=0.22.0,!=0.23.0 # 0.23.0 have error with slow k-means, it will be fixed in 0.23.1 -bottleneck>=1.0.0 -# Reading Excel files -xlrd>=0.9.2 -# Writing Excel Files -xlsxwriter +baycomp>=1.0.2 +bottleneck>=1.3.4 # Encoding detection chardet>=3.0.2 +httpx>=0.21.0,<1 # Multiprocessing abstraction -joblib>=0.9.4 +joblib>=1.2.0 keyring keyrings.alt # for alternative keyring implementations -setuptools>=36.3 -serverfiles # for Data Sets synchronization networkx +numpy>=1.21.0,<2.4 +openpyxl>=3.1.3 +openTSNE>=0.6.2,!=0.7.0 # 0.7.0 segfaults +packaging +pandas>=2.0.1,<3 python-louvain>=0.13 -requests -openTSNE>=0.6.0 -baycomp>=1.0.2 -pandas>=1.0.0 pyyaml -openpyxl -httpx>=0.14.0,<0.17 +requests +scikit-learn>=1.5.1 +scipy>=1.9 +serverfiles # for Data Sets synchronization +xgboost>=1.7.4,<2.1; sys_platform=="darwin" +xgboost>=1.7.4; sys_platform!="darwin" +# Reading Excel files +xlrd>=1.2.0 +# Writing Excel Files +xlsxwriter diff --git a/requirements-dev.txt b/requirements-dev.txt index 2ea73d2f7f0..4f2ef427c4d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ pylint radon +recommonmark # for build_htmlhelp command sphinx>=1.5 -recommonmark +trubar diff --git a/requirements-doc.txt b/requirements-doc.txt index 90538ac9d1e..4b278c4f31c 100644 --- a/requirements-doc.txt +++ b/requirements-doc.txt @@ -1,2 +1,4 @@ -Sphinx>=1.3 recommonmark +Sphinx>=4.2.0,<8 +sphinx-multiproject +myst-parser diff --git a/requirements-gui.txt b/requirements-gui.txt index bfe7c8d15bd..6f419327858 100644 --- a/requirements-gui.txt +++ b/requirements-gui.txt @@ -1,9 +1,9 @@ -orange-canvas-core>=0.1.21,<0.2a -orange-widget-base>=4.13.0 +orange-canvas-core>=0.2.9,<0.3a +orange-widget-base>=4.25.0 -PyQt5>=5.12,!=5.15.1 # 5.15.1 skipped because of QTBUG-87057 - affects select columns -PyQtWebEngine>=5.12 -AnyQt>=0.0.11 +AnyQt>=0.2.0 -pyqtgraph>=0.11.1 -matplotlib>=2.0.0 +matplotlib>=3.2.0 +pygments>=2.8.0 +pyqtgraph>=0.13.1 +qtconsole>=4.7.2 diff --git a/requirements-opt.txt b/requirements-opt.txt deleted file mode 100644 index 25daee426ed..00000000000 --- a/requirements-opt.txt +++ /dev/null @@ -1,2 +0,0 @@ -catboost -xgboost diff --git a/requirements-pyqt.txt b/requirements-pyqt.txt new file mode 100644 index 00000000000..c365e25884a --- /dev/null +++ b/requirements-pyqt.txt @@ -0,0 +1,6 @@ +PyQt6>=6.5 +PyQt6-WebEngine>=6.5 + +# Alternatively, for PyQt5 +# PyQt5>=5.12,!=5.15.1 # 5.15.1 skipped because of QTBUG-87057 - affects select columns +# PyQtWebEngine>=5.12 diff --git a/requirements-readthedocs.txt b/requirements-readthedocs.txt index 2756978e0ea..05d72518dd7 100644 --- a/requirements-readthedocs.txt +++ b/requirements-readthedocs.txt @@ -2,3 +2,5 @@ cython -r requirements-core.txt -r requirements-doc.txt -r requirements-gui.txt +-r requirements-pyqt.txt +-e . diff --git a/setup.cfg b/setup.cfg index d3906e8b4d7..d6bf13a7712 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [config] # applies to sdist and build commands -with-htmlhelp=1 +with_htmlhelp=1 [aliases] # build a sdist and a wheel release with included widget help diff --git a/setup.py b/setup.py index cc4914f560f..2120488753f 100755 --- a/setup.py +++ b/setup.py @@ -4,15 +4,13 @@ import sys import subprocess from setuptools import setup, find_packages, Command +from setuptools.command.install import install from distutils.command import install_data, sdist from distutils.command.build_ext import build_ext from distutils.command import config, build from distutils.core import Extension -if sys.version_info < (3, 4): - sys.exit('Orange requires Python >= 3.4') - try: import numpy have_numpy = True @@ -21,23 +19,22 @@ try: # need sphinx and recommonmark for build_htmlhelp command - from sphinx.setup_command import BuildDoc # pylint: disable=unused-import + import sphinx import recommonmark have_sphinx = True except ImportError: have_sphinx = False try: - from Cython.Distutils.build_ext import new_build_ext as build_ext + from Cython.Build import cythonize have_cython = True except ImportError: have_cython = False - NAME = 'Orange3' -VERSION = '3.30.0' +VERSION = '3.41.0' ISRELEASED = False # full version identifier including a git revision identifier for development # build/releases (this is filled/updated in `write_version_py`) @@ -49,7 +46,13 @@ LONG_DESCRIPTION_CONTENT_TYPE = 'text/markdown' AUTHOR = 'Bioinformatics Laboratory, FRI UL' AUTHOR_EMAIL = 'info@biolab.si' -URL = 'http://orange.biolab.si/' +URL = 'https://orangedatamining.com/' +PROJECT_URLS = { + 'Documentation': 'https://orangedatamining.com/docs', + 'Source Code': 'https://github.com/biolab/orange3', + 'Issue Tracker': 'https://github.com/biolab/orange3/issues', + 'Donate': 'https://github.com/sponsors/biolab' +} LICENSE = 'GPLv3+' KEYWORDS = [ @@ -76,8 +79,12 @@ 'Intended Audience :: Developers', ] +PYTHON_REQUIRES = ">=3.11" + + requirements = ['requirements-core.txt', 'requirements-gui.txt'] + INSTALL_REQUIRES = sorted(set( line.partition('#')[0].strip() for file in (os.path.join(os.path.dirname(__file__), file) @@ -86,9 +93,7 @@ ) - {''}) -EXTRAS_REQUIRE = { - ':python_version<="3.4"': ["typing"], -} +EXTRAS_REQUIRE = {} ENTRY_POINTS = { "orange.widgets": ( @@ -97,6 +102,12 @@ "orange.canvas.help": ( "html-index = Orange.widgets:WIDGET_HELP_PATH", ), + "orange.canvas.drophandler": ( + "File = Orange.widgets.data.owfile:OWFileDropHandler", + "Load Model = Orange.widgets.model.owloadmodel:OWLoadModelDropHandler", + "Distance File = Orange.widgets.unsupervised.owdistancefile:OWDistanceFileDropHandler", + "Python Script = Orange.widgets.data.owpythonscript:OWPythonScriptDropHandler", + ), "gui_scripts": ( "orange-canvas = Orange.canvas.__main__:main", ), @@ -153,8 +164,12 @@ def write_version_py(filename='Orange/version.py'): GIT_REVISION = git_version() elif os.path.exists('Orange/version.py'): # must be a source distribution, use existing version file - import imp - version = imp.load_source("Orange.version", "Orange/version.py") + import importlib.util + spec = importlib.util.spec_from_file_location( + "Orange.version", filename + ) + version = importlib.util.module_from_spec(spec) + spec.loader.exec_module(version) GIT_REVISION = version.git_revision else: GIT_REVISION = "Unknown" @@ -191,7 +206,7 @@ def write_version_py(filename='Orange/version.py'): "icons/paintdata/*.svg"], "Orange.widgets.data.tests": ["origin1/*.tab", "origin2/*.tab", - "*.txt", "*.tab"], + "*.txt", "*.tab", "*.foo", "*.xlsx"], "Orange.widgets.evaluate": ["icons/*.svg"], "Orange.widgets.model": ["icons/*.svg"], "Orange.widgets.visualize": ["icons/*.svg"], @@ -376,16 +391,23 @@ def finalize_options(self): HAVE_BUILD_HTML = os.path.exists("doc/visual-programming/build/htmlhelp/index.html") if have_sphinx and HAVE_SPHINX_SOURCE: - class build_htmlhelp(BuildDoc): + class build_htmlhelp(Command): + user_options = [] + + def finalize_options(self): + pass + def initialize_options(self): - super().initialize_options() self.build_dir = "doc/visual-programming/build" - self.source_dir = "doc/visual-programming/source" - self.builder = "htmlhelp" - self.version = VERSION def run(self): - super().run() + subprocess.check_call([ + "sphinx-build", "-b", "htmlhelp", "-d", "build/doctrees", + "-D", f"version={VERSION}", + "source", "build" + ], + cwd="doc/visual-programming" + ) helpdir = os.path.join(self.build_dir, "htmlhelp") files = find_htmlhelp_files(helpdir) # add the build files to distribution @@ -424,14 +446,7 @@ def ext_modules(): if os.name == 'posix': libraries.append("m") - return [ - # Cython extensions. Will be automatically cythonized. - Extension( - "*", - ["Orange/*/*.pyx"], - include_dirs=includes, - libraries=libraries, - ), + modules = [ Extension( "Orange.classification._simple_tree", sources=[ @@ -454,10 +469,38 @@ def ext_modules(): ), ] + if have_cython: + modules += cythonize(Extension( + "*", + ["Orange/*/*.pyx"], + include_dirs=includes, + libraries=libraries, + )) + + return modules + + +class InstallMultilingualCommand(install): + def run(self): + super().run() + self.compile_to_multilingual() + + def compile_to_multilingual(self): + # Import locally so that editable install won't require trubar + # pylint: disable=import-outside-toplevel + from trubar import translate + + package_dir = os.path.dirname(os.path.abspath(__file__)) + translate( + "msgs.jaml", + source_dir=os.path.join(self.install_lib, "Orange"), + config_file=os.path.join(package_dir, "i18n", "trubar-config.yaml")) + def setup_package(): write_version_py() cmdclass = { + 'install': InstallMultilingualCommand, 'lint': LintCommand, 'coverage': CoverageCommand, 'config': config, @@ -468,17 +511,14 @@ def setup_package(): # numpy.distutils insist all data files are installed in site-packages 'install_data': install_data.install_data } - if have_numpy and have_cython: - extra_args = {} - cmdclass["build_ext"] = build_ext - else: + if not (have_numpy and have_cython): # substitute a build_ext command with one that raises an error when # building. In order to fully support `pip install` we need to # survive a `./setup egg_info` without numpy so pip can properly # query our install dependencies - extra_args = {} cmdclass["build_ext"] = build_ext_error + extra_args = {} setup( name=NAME, version=FULLVERSION, @@ -488,6 +528,7 @@ def setup_package(): author=AUTHOR, author_email=AUTHOR_EMAIL, url=URL, + project_urls=PROJECT_URLS, license=LICENSE, keywords=KEYWORDS, classifiers=CLASSIFIERS, @@ -497,6 +538,7 @@ def setup_package(): data_files=DATA_FILES, install_requires=INSTALL_REQUIRES, extras_require=EXTRAS_REQUIRE, + python_requires=PYTHON_REQUIRES, entry_points=ENTRY_POINTS, zip_safe=False, test_suite='Orange.tests.suite', diff --git a/tox.ini b/tox.ini index ab2e9ec87ee..a81229e22e6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py-orange-{latest, released} + orange-{oldest, latest, released} pylint-ci build_doc add-ons @@ -8,8 +8,8 @@ skip_missing_interpreters = true isolated_build = true [testenv] -# must use latest pip (version 20.3.1 enables Big Sur support - https://github.com/pypa/pip/issues/9138) -pip_version = pip +# https://tox.wiki/en/latest/config.html#download +download = true passenv = * # we MUST changedir to avoid installed being shadowed by working dir # https://github.com/tox-dev/tox/issues/54 @@ -22,22 +22,48 @@ setenv = # Need this otherwise unittest installs a warning filter that overrides # our desire to have OrangeDeprecationWarnings raised PYTHONWARNINGS=module - # Skip loading of example workflows as that inflates coverage - SKIP_EXAMPLE_WORKFLOWS=True # set coverage output and project config COVERAGE_FILE = {toxinidir}/.coverage COVERAGE_RCFILE = {toxinidir}/.coveragerc deps = - pyqt5==5.12.* - pyqtwebengine==5.12.* - -r {toxinidir}/requirements-opt.txt + pyqt5==5.15.* + pyqtwebengine==5.15.* coverage - psycopg2-binary - # no wheels for mac - pymssql<3.0;platform_system!='Darwin' and python_version<'3.8' - latest: git+git://github.com/pyqtgraph/pyqtgraph.git#egg=pyqtgraph - latest: git+git://github.com/biolab/orange-canvas-core.git#egg=orange-canvas-core - latest: git+git://github.com/biolab/orange-widget-base.git#egg=orange-widget-base + psycopg2-binary; platform_system=="Linux" # we only test this on Linux + pymssql; platform_system=="Linux" # we only test this on Linux + latest: https://github.com/biolab/orange-canvas-core/archive/refs/heads/master.zip#egg=orange-canvas-core + latest: https://github.com/biolab/orange-widget-base/archive/refs/heads/master.zip#egg=orange-widget-base + # GUI requirements + oldest: orange-canvas-core==0.2.9 + oldest: orange-widget-base==4.25.0 + oldest: AnyQt==0.2.0 + oldest: matplotlib==3.6.0 + oldest: pygments==2.8.0 + oldest: pyqtgraph>=0.13.1 + oldest: qtconsole==4.7.2 + # core requirements + oldest: baycomp==1.0.2 + oldest: bottleneck==1.3.7 + oldest: catboost==1.2.2 + oldest: chardet==3.0.2 + oldest: httpx==0.21.0 + oldest: joblib==1.2.0 + # oldest: keyring + # oldest: keyrings.alt + # oldest: networkx + oldest: numpy==1.23.2 + oldest: openpyxl==3.1.3 + oldest: openTSNE==1.0.0 + oldest: pandas==2.0.1 + oldest: python-louvain==0.13 + # oldest: pyyaml + # oldest: requests + oldest: scikit-learn==1.7 + oldest: scipy==1.10 + # oldest: serverfiles + oldest: xgboost==2.1.0 + oldest: xlrd==1.2.0 + # oldest: xlsxwriter commands_pre = # Verify installed packages have compatible dependencies @@ -45,10 +71,50 @@ commands_pre = # freeze environment pip freeze commands = - coverage run {toxinidir}/quietunittest.py Orange.tests Orange.widgets.tests Orange.canvas.tests - coverage run {toxinidir}/quietunittest.py discover Orange.canvas.tests + coverage run -m unittest -v Orange.tests Orange.widgets.tests coverage combine coverage report + # codecov-actions wants xml report + coverage xml -o {toxinidir}/coverage.xml + +[testenv:beta] +changedir = + {envsitepackagesdir} +setenv = + QT_API=PyQt6 + ANYQT_HOOK_DENY=pyqt5 + PIP_EXTRA_INDEX_URL=https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + PIP_PRE=1 +deps = + https://github.com/pyqtgraph/pyqtgraph/archive/refs/heads/master.zip#egg=pyqtgraph + https://github.com/biolab/orange-canvas-core/archive/refs/heads/master.zip#egg=orange-canvas-core + https://github.com/biolab/orange-widget-base/archive/refs/heads/master.zip#egg=orange-widget-base + PyQt6==6.8.* + PyQt6-Qt6==6.8.* + PyQt6-WebEngine==6.8.* + PyQt6-WebEngine-Qt6==6.8.* +commands_pre = + # Verify installed packages have compatible dependencies + pip check + # freeze environment + pip freeze +commands = + python -m unittest -v Orange.tests Orange.widgets.tests + +[testenv:pyqt6] +changedir = + {envsitepackagesdir} +setenv = + QT_API=PyQt6 + ANYQT_HOOK_DENY=pyqt5 +deps = + PyQt6==6.8.* + PyQt6-Qt6==6.8.* + PyQt6-WebEngine==6.8.* + PyQt6-WebEngine-Qt6==6.8.* + +commands = + python -m unittest -v Orange.widgets.tests [testenv:add-ons] deps = @@ -63,15 +129,19 @@ commands = [testenv:pylint-ci] changedir = {toxinidir} skip_install = true -whitelist_externals = bash -deps = pylint +allowlist_externals = bash +deps = + orange-widget-base + anyqt + PyQt5==5.12.* + pylint commands = bash .github/workflows/check_pylint_diff.sh [testenv:build_doc] changedir = {toxinidir} usedevelop = true -whitelist_externals = bash +allowlist_externals = bash deps = {[testenv]deps} -r {toxinidir}/requirements-doc.txt